diff --git a/PhysicsTools/TruthInfo/README.md b/PhysicsTools/TruthInfo/README.md index a14dfd07e2302..bc12cf5225196 100644 --- a/PhysicsTools/TruthInfo/README.md +++ b/PhysicsTools/TruthInfo/README.md @@ -1,10 +1,8 @@ -# TruthInfo prototype +# TruthInfo -> **Status: under heavy development — not open to external contributions.** -> This is an experimental prototype: its data model, APIs, and configuration -> change frequently and without notice, and it targets **Phase-2 (Run 4) only** -> (no Phase-1/Run-2 support). Please do not submit external changes or depend on -> it in production at this stage. +> **Status: open for contributions.** The data model and the levels are covered by +> unit tests; a change to either should come with one. The package targets +> **Phase-2 (Run 4) only** (no Phase-1/Run-2 support). > > **Original author and maintainer:** Felice Pantaleo (CERN), > . diff --git a/PhysicsTools/TruthInfo/doc/resource_cost.md b/PhysicsTools/TruthInfo/doc/resource_cost.md deleted file mode 100644 index 4cc3736c0c9ff..0000000000000 --- a/PhysicsTools/TruthInfo/doc/resource_cost.md +++ /dev/null @@ -1,323 +0,0 @@ -# Computing cost: the MC-truth graph against the legacy frozen truth objects - -What it costs to keep `TrackingParticle`, `TrackingVertex`, `CaloParticle` and -`SimCluster`, and what it costs to keep the truth graph plus its associators instead. -Every number below was measured on one sample; nothing is extrapolated. Where a -quantity was not measured it is listed as not measured rather than estimated. - -## Sample and conditions - -Identical for every number in this document. - -| | | -|---|---| -| Process | `TTbar_14TeV_TuneCP5`, no pileup (`mixNoPU_cfi`) | -| Events | 10 | -| Release | `CMSSW_20_1_X_2026-07-22-2300` | -| Geometry, era, global tag | `ExtendedRun4D122`, `Phase2C26I13M9`, `auto:phase2_realistic_T35` | -| Steps | GEN,SIM then DIGI,L1,DIGI2RAW,HLT:@relvalRun4 then RAW2DIGI,L1Reco,RECO | -| Host | AMD EPYC 9754, 1 thread, 1 stream | - -Sections 1 to 6 are NO PILEUP. Section 7 measures the event size at PU200, where the -two schemes scale very differently; the CPU and memory numbers remain no-PU only. - -## 1. Event size - -Read with `edmEventSize -v`. Both schemes first appear at DIGI and are copied unchanged -into RECO, so the DIGI and RECO byte counts are identical. - -| Scheme | branches | uncompressed kB/event | compressed kB/event | -|---|---:|---:|---:| -| Legacy: TrackingParticle, TrackingVertex, CaloParticle, SimCluster and their Refs | 14 | 1731.4 | 622.5 | -| Graph, **default** (shared) hit index: `TruthGraph`, `truth::Graph`, `truth::LogicalGraphHitIndex` | 3 | 1149.8 | 368.3 | -| Graph, materialised hit index (`sharedSubgraphStore=False`) | 3 | 3304.2 | 611.3 | -| Truth-branch association maps and their root/target index vectors | 23 | 168.8 | 25.1 | -| **Graph total, default index** | **26** | **1318.6** | **393.4** | - -The legacy row is carried over unchanged: nothing in the graph work touches those -collections. The graph and association rows were re-measured on the current build. - -Dropping the legacy collections and keeping the graph plus all four association working -points saves **229.1 kB/event compressed, 37%** of the truth payload, and that is with the -full signal GEN half included, which the legacy collections do not carry at all. Keeping -the materialised index instead would cost 13.9 kB/event more than legacy, +2.2%, so the -shared index is what makes the graph a saving rather than a cost. - -!!! note "The chain is not bit-reproducible, so read the last digit as noise" - Two runs of the identical configuration differ in 64 of 1338 branches, HLT tracking - among them. `TruthGraph_mix` ranged 53793 to 56124 compressed bytes/event over four - runs, a 4.2% spread, so the graph row uses its mean of 54.9 kB/event. The hit index - and `truth::Graph` are stable to the byte, and so is everything derived from them. - Section 6 has the detail. - -The single largest graph branch is the hit index: 202.3 kB/event compressed by default, -445.3 if the materialised layout is selected. It is a separate product and can be dropped on its own; the -two graph structures alone are 166.0 kB/event. - -For context, the whole RECO event is 7991.4 kB/event compressed. Graph products are 4.9% -of it, legacy truth 7.8%. - -### 1.1 The table above predates the pruning-scope fix and is now an UNDERCOUNT - -The hitless-subgraph pruning used to be wired to the HGCAL endcap only, so every -particle depositing solely in the ECAL/HCAL barrel, and under pileup every pileup -charged particle, was pruned as hitless. The graph was smaller because it was wrong. - -Controlled A/B, same 10 ttbar events, same job, only the producer's sim-hit collections -changed (compressed bytes per event): - -| branch | pruning on HGCAL only | pruning on the full detector | -|---|---:|---:| -| `TruthGraph` (`mix`) | 35264 | 34661 | -| `truth::Graph` | 50570 | 128166 | -| `truth::LogicalGraphHitIndex` | 116202 | 143477 | -| **total** | **202037** | **306304** | - -The graph branch grows 2.5x because it now keeps the barrel particles it always should -have kept. The table in section 1 was measured before this fix and must be re-measured -on the reference conditions before any size claim is quoted from it again. - -### 1.2 What changed since the first version of this document - -Two things moved in opposite directions and the net is a saving. - -The graph now carries the **full signal GEN half**, contracted: the parton shower and the -intermediate copies of a resonance are collapsed away by `truth::collapseGenShower`, so a -resonance appearing several times is one node whose children are its decay products. That -half did not exist when this document was first written. - -The hit index now uses the **shared subgraph store**. Each hit is written once, in an -order that makes a particle's subtree a contiguous run of slots, so a subgraph is a set of -ranges of that one store rather than a second materialised copy under every ancestor. A -GEN-only particle sits above the SIM tree in a DAG, so it owns a merged set of runs rather -than a single one; measured over 3 events, 1039 such particles need 2547 ranges, mean -2.45, median 1, max 183. - -A/B on the same GEN-SIM, 10 events, compressed bytes/event of the three graph branches: - -| variant | hit index | `truth::Graph` | `TruthGraph_mix` | total | -|---|---:|---:|---:|---:| -| no GEN half, materialised index | 271013 | 92499 | 54037 | 417549 | -| full GEN half, materialised index | 696729 | 126768 | 74681 | 898178 | -| collapsed GEN half, materialised index | 445329 | 111070 | 55413 | 611812 | -| **collapsed GEN half, shared index (the default)** | **202300** | **111070** | **54938** | **368308** | - -The `TruthGraph_mix` column carries the run-to-run spread noted above, so differences of -about a kB in that column and in the total are not significant. The hit index column, -which is what these variants are about, is stable to the byte. - -The materialised index stored a hit once per ancestor containing it: 26744 hits per event -became 46259 stored entries even with no GEN half at all, and 1467228 with the full one. -The shared store keeps the 26744. Reading is automatic in both layouts, which is what -keeps every file written before this change readable. - -The cost is paid at query time: a range is in tree order and repeats a detId hit by -several descendants, so a consumer that needs per-cell energies coalesces it. -`BranchHitAssociator` does this once per candidate root at construction. Measured on the -same 10 events, `allTrackToTruthBranchAssociators` goes from 10.1 to 12.2 ms/event and the -whole RECO job from 692.5 to 694.9 ms/event, +0.3%. - -### Cost per object - -| Collection | objects/event | compressed bytes/object | -|---|---:|---:| -| `truth::Graph` particles + vertices | 1186.5 + 557.3 | 53 | -| `TrackingParticle` (MergedTrackTruth) | 896.1 | 99 | -| `CaloParticle` (MergedCaloTruth) | 185.4 | 414 | - -The graph node is cheaper for a structural reason, not a compression accident. A -`TrackingParticle` embeds `std::vector g4Tracks_`, and so do `CaloParticle` and -`SimCluster`; each `SimTrack` carries 12 members and the same SimTracks are already -persisted separately in `SimTracks_g4SimHits` (431.2 kB/event compressed). The legacy -classes also store their topology as `edm::Ref` and `RefVector` members -(`parentVertex_`, `decayVertices_`, `daughterTracks_`, `sourceTracks_`, `simClusters_`). -`truth::ParticleData` and `truth::VertexData` embed no SimTrack and no Ref: topology -lives once, in the CSR offset arrays of `truth::Graph`, and the hit payload is factored -out into the hit index instead of being duplicated as parallel `hits_` and `fractions_` -vectors in every `CaloParticle` and every `SimCluster`. - -The duplication is the point. The legacy scheme writes four distinct `SimCluster` -collections plus `CaloParticle` plus `TrackingParticle` and `TrackingVertex`, each -re-embedding its own SimTrack copies and hit arrays. The graph writes one structure -covering tracker and calorimeter together: 1186.5 particles per event against -896.1 + 185.4 + 423.5 legacy objects. - -## 2. CPU and allocated memory at DIGI - -`TrackingTruthAccumulator`, `CaloTruthAccumulator` and `TruthGraphAccumulator` are all -`DigiAccumulatorMixMod` plugins inside the single `mix` module, so the FastTimerService -cannot separate them. They were isolated by an A/B on the same input, same 10 events, -same host, deleting entries from `process.mix.digitizers`, three repetitions each. - -| Variant | mix real ms/event (mean, sd, n=3) | mix allocated kB/event | -|---|---:|---:| -| as shipped | 2449.0, 2.6 | 969 965.4 | -| without `TruthGraphAccumulator` | 2469.1, 40.6 | 960 045.1 | -| without `TrackingTruth` and `CaloTruth` | 2430.0, 14.2 | 940 884.7 | -| without all three | 2408.2, 6.2 | 930 964.4 | - -All three truth accumulators together cost **40.8 +- 3.9 ms/event**, which is **1.7% of -the 2449 ms/event that `mix` costs**. The split of that time between graph and legacy is -NOT resolved at three repetitions: the two independent estimates of each disagree by -more than their errors. - -The allocated-memory split is exact and additive (9920.3 + 29080.7 = 39001.0): - -- `TruthGraphAccumulator`: **9.9 MB/event** -- `TrackingTruthAccumulator` + `CaloTruthAccumulator`: **29.1 MB/event**, a factor **2.9** - more. - -The graph's own downstream stages are ordinary EDProducers and are separately timed: -`truthLogicalGraphProducer` 3.5 ms/event, `truthLogicalGraphHitIndexProducer` -5.4 ms/event. - -## 3. CPU at RECO - -Re-run of the RECO step with the FastTimerService; the framework TimeReport agrees to the -microsecond. - -| Module | real ms/event | allocated kB/event | -|---|---:|---:| -| `allTrackToTruthBranchAssociators` | 3.713 | 1669.0 | -| `allVertexToTruthBranchAssociators` | 0.475 | 155.6 | -| `allSecondaryVertexToTruthBranchAssociators` | 0.091 | 140.3 | -| total | 4.28 | 1964.9 | - -That is **0.38% of the 1119.7 ms/event** summed over all scheduled RECO modules, and it -is with four working points per domain. No legacy truth producer runs in this RECO -sequence, so there is no legacy counterpart to compare the associators against. - -## 4. What the graph does NOT save - -Stated plainly, because the opposite is easy to assume. - -**The DIGI-time accumulation step is not removed.** All three accumulators exist for the -same reason: pileup sub-event SimTracks, SimVertices and SimHits are only reachable -through `PileUpEventPrincipal` while mixing runs, and are never in the output event. The -sources make this explicit: - -- `SimGeneral/TrackingAnalysis/plugins/TrackingTruthAccumulator.cc:407` and `:471` -- `SimGeneral/CaloAnalysis/plugins/CaloTruthAccumulator.cc:684` and `:782` -- `PhysicsTools/TruthInfo/plugins/TruthGraphAccumulator.cc:458` and `:469` - -The graph replaces two accumulators with one; it does not eliminate the step. The saving -is in what that step allocates and writes, not in skipping it. - -**MTD legacy truth is untouched.** `MtdSimClusters`, `MtdSimLayerClusters`, -`MtdSimTracksters` and `MtdCaloParticles` are a further 243.6 kB/event compressed that -the graph does not currently replace. - -**Raw SimTracks and SimVertices stay.** 529.8 kB/event compressed, written by the SIM -step and consumed by both schemes. - -## 5. Reading a subgraph in either layout - -`LogicalGraphHitIndex::subgraphHits` returns a single span. In the shared layout a -particle that carries hits owns exactly one slot range, so its span is fine, but a -GEN-only particle owns several and the accessor returns an **empty** span. Four validators -used its size as a smallest-footprint tie-break, where a zero-size answer makes a GEN-only -root win a comparison meant to pick the tightest branch, so a naive switch of layout would -have read as a near-total loss of reproduction efficiency. - -`truth::SubgraphHitView` is what consumers use instead. It returns the coalesced, -detId-sorted span in either layout: the materialised one already persists that form and is -handed back untouched, the shared one is coalesced once per particle and cached, and a -particle whose subgraph is a single one-slot range needs neither, since the builder already -sorted and summed its own direct hits. Hold one per event and per module; it caches, so it -is not thread safe. - -All seven consumers now go through it, and the arithmetic in each is unchanged. Verified on -10 ttbar events by running the calorimeter validator over a materialised index and a shared -index and comparing every monitor element: **50 compared, 50 non-empty, 0 differing**. That -rests on the accessor-level equivalence measured in section 1.1, which covered every -particle and every channel. - -## 6. This chain is not bit-reproducible, and that is not a truth-graph property - -Read the byte counts in section 1 with this in mind. Two runs of the identical -configuration, same input file, same host, one thread, do not write the same file: -**64 of 1338 branches differ in compressed size**, and five of them differ uncompressed -too, so the difference is real content and not just packing. The largest by absolute -delta happens to be `TruthGraph_mix` (56123.9 against 55250.2 compressed bytes/event, -uncompressed identical at 498985), but it is in company: - -| branch | compressed | uncompressed | -|---|---|---| -| `TruthGraph_mix` | 56123.9 -> 55250.2 | same | -| `recoTrackExtras_hltGeneralTracks` | 46859.7 -> 46867.5 | differs | -| `recoTracks_hltInitialStepTrackSelectionHighPurity` | 8934.8 -> 8937.2 | same | -| `recoVertexs_hltOfflinePrimaryVertices` | 521.2 -> 519.8 | same | -| `uints_hltPhase2PixelTracksCAExtension` | 525.9 -> 527.2 | same | - -HLT tracking output changing between identical runs is the root of most of this, and it -is upstream of and independent of the truth graph. - -The part that matters here is that the truth **content** is reproducible. `truth::Graph` -and `truth::LogicalGraphHitIndex` hash identically across the same runs, over the logical -particle and vertex records, all eight CSR arrays, and every hit and offset of all four -channels. Of `TruthGraph`'s own persisted arrays, `offsets`, `edges`, `edgeKind`, -`pdgId`, `status`, `statusFlags`, `eventId`, `genEventOfNode`, `simVertexProcessType`, -`simTrackBackscattered` and `simTrackToGen` are all identical run to run. - -One loose end specific to this package, worth a look but not a blocker: -`TruthGraph_mix` is the one branch whose compressed size moves while its uncompressed -size does not, which means same length and different bytes. `TruthGraph::NodeRef` is -`{ NodeKind kind; int64_t key; }` with `NodeKind` a `uint8_t`, so it carries seven bytes -of padding and the branch is written unsplit; uninitialised padding would produce exactly -that signature without changing any value a consumer reads. This is a hypothesis, not a -result: confirming it needs a C++-side dump of the raw buffer, because PyROOT cannot read -the unsplit struct reliably, which is also why `kind` is excluded from the hashes above. - -## 7. Event size at PU200 - -Same signal process, same release and geometry, 10 events, classic mixing with an -average of 200 minimum-bias interactions from a D122 truth-enabled library, default -truth wiring, no selection preset. Compressed kB/event: - -| Scheme | kB/event | vs no pileup | -|---|---:|---:| -| Legacy: TrackingParticle, 2x TrackingVertex, 4x SimCluster, CaloParticle | 56717 | x91 | -| Graph: `TruthGraph`, `truth::Graph`, `truth::LogicalGraphHitIndex` | 8791 | x24 | - -At PU200 the graph is **15.5% of the legacy truth payload**, a factor 6.5, against 59% -with no pileup: the saving grows with pileup because the legacy objects re-embed their -SimTrack copies and hit arrays per object and per collection, and pileup multiplies the -objects, while the graph's topology stays CSR and its hits stay stored once. The shared -hit index is 4762 kB/event of the graph total and remained the persisted layout in 10 of -10 events, with no fallback and no degradation warnings. - -MTD legacy truth, which neither scheme replaces, is a further 9859 kB/event at PU200. - -The pileup GEN half is collapsed (`collapsePileupGen=True`): each pileup interaction -carries one Interaction vertex, one UnderlyingEvent vertex holding its stable particles, -and nothing else, so the graph cost of 200 extra interactions is dominated by their -SIM tracks and hits, not their generator records. - -## 8. Not measured - -- CPU and allocated memory at PU200. Section 7 measures the event size there; the - accumulator A/B of section 2 and the RECO timings of section 3 are no-PU only. -- The RECO-side associator numbers in section 3 predate the shared hit index. Section 1.1 - gives the measured before/after for `allTrackToTruthBranchAssociators` on the same 10 - events, but the per-module table in section 3 was not re-measured with the - FastTimerService. -- Peak RSS. No log in this chain reports `SimpleMemoryCheck`, and no instrumentation was - added. The memory figures above are the FastTimerService allocated-bytes counter. -- The CPU split between `TruthGraphAccumulator` and the two legacy accumulators inside - `mix`. Only the combined 40.8 +- 3.9 ms/event is resolved at three repetitions. -- A true A/B of file size with the graph removed from the chain; the per-branch sizes - above are read off the single existing chain. -- Legacy associator CPU at RECO, for example `quickTrackAssociatorByHits`, which is not - in this RECO sequence. -- Any multi-threaded scaling. Everything is one thread, one stream. - -## Summary - -On a no-pileup ttbar event, replacing the frozen truth objects with the graph and its -associators is a **37% reduction of the persisted truth payload** (622.5 to -393.4 kB/event compressed), a **factor 2.9 less memory allocated during mixing** (29.1 to -9.9 MB/event), and a RECO-side association cost of a few ms/event, well under 1% of the -scheduled reconstruction. That is with the full signal GEN half included, which the -legacy collections do not carry at all. The DIGI-time accumulation step itself is not -removed. At PU200 the size advantage grows to a factor 6.5 (section 7); the CPU and -memory numbers remain no-PU only. diff --git a/PhysicsTools/TruthInfo/interface/Branch.h b/PhysicsTools/TruthInfo/interface/Branch.h index 15a83645a611c..9da011b91484d 100644 --- a/PhysicsTools/TruthInfo/interface/Branch.h +++ b/PhysicsTools/TruthInfo/interface/Branch.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #ifndef PhysicsTools_TruthInfo_interface_Branch_h #define PhysicsTools_TruthInfo_interface_Branch_h @@ -73,7 +71,10 @@ namespace truth { [[nodiscard]] int bunchCrossing() const; [[nodiscard]] int event() const; [[nodiscard]] bool isInTime() const { return bunchCrossing() == 0; } - [[nodiscard]] bool isFromPileup() const { return bunchCrossing() != 0; } + // Anything that is not the signal interaction. NOT bunchCrossing() != 0: in-time + // pileup carries bunch crossing 0 and a nonzero event number, and the default + // production keeps in-time pileup only. + [[nodiscard]] bool isFromPileup() const { return !isSignal(); } [[nodiscard]] bool isSignal() const { return bunchCrossing() == 0 && event() == 0; } // Relations between branches. diff --git a/PhysicsTools/TruthInfo/interface/BranchHitAssociator.h b/PhysicsTools/TruthInfo/interface/BranchHitAssociator.h index 9d0c21e397a47..5c5e7d2942361 100644 --- a/PhysicsTools/TruthInfo/interface/BranchHitAssociator.h +++ b/PhysicsTools/TruthInfo/interface/BranchHitAssociator.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #ifndef PhysicsTools_TruthInfo_interface_BranchHitAssociator_h #define PhysicsTools_TruthInfo_interface_BranchHitAssociator_h diff --git a/PhysicsTools/TruthInfo/interface/BranchSelector.h b/PhysicsTools/TruthInfo/interface/BranchSelector.h index f3d0408ae4cea..0298fc9cae925 100644 --- a/PhysicsTools/TruthInfo/interface/BranchSelector.h +++ b/PhysicsTools/TruthInfo/interface/BranchSelector.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #ifndef PhysicsTools_TruthInfo_interface_BranchSelector_h #define PhysicsTools_TruthInfo_interface_BranchSelector_h diff --git a/PhysicsTools/TruthInfo/interface/LogicalGraphHitIndexBuilder.h b/PhysicsTools/TruthInfo/interface/LogicalGraphHitIndexBuilder.h index 1eb55d2345258..edc95539f5ce5 100644 --- a/PhysicsTools/TruthInfo/interface/LogicalGraphHitIndexBuilder.h +++ b/PhysicsTools/TruthInfo/interface/LogicalGraphHitIndexBuilder.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #ifndef PhysicsTools_TruthInfo_LogicalGraphHitIndexBuilder_h #define PhysicsTools_TruthInfo_LogicalGraphHitIndexBuilder_h diff --git a/PhysicsTools/TruthInfo/interface/RecoHitAdapters.h b/PhysicsTools/TruthInfo/interface/RecoHitAdapters.h index 39e79160d0328..3966f3809758b 100644 --- a/PhysicsTools/TruthInfo/interface/RecoHitAdapters.h +++ b/PhysicsTools/TruthInfo/interface/RecoHitAdapters.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #ifndef PhysicsTools_TruthInfo_interface_RecoHitAdapters_h #define PhysicsTools_TruthInfo_interface_RecoHitAdapters_h diff --git a/PhysicsTools/TruthInfo/interface/SubgraphHitView.h b/PhysicsTools/TruthInfo/interface/SubgraphHitView.h index 7ec2b74f0cc6d..3d9bd8a6d7a06 100644 --- a/PhysicsTools/TruthInfo/interface/SubgraphHitView.h +++ b/PhysicsTools/TruthInfo/interface/SubgraphHitView.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #ifndef PhysicsTools_TruthInfo_SubgraphHitView_h #define PhysicsTools_TruthInfo_SubgraphHitView_h diff --git a/PhysicsTools/TruthInfo/interface/TruthLevels.h b/PhysicsTools/TruthInfo/interface/TruthLevels.h new file mode 100644 index 0000000000000..1c67edc0b90a0 --- /dev/null +++ b/PhysicsTools/TruthInfo/interface/TruthLevels.h @@ -0,0 +1,641 @@ +// Original author: Felice Pantaleo (CERN) +// +// Levels of the truth graph: the a-priori definition of WHAT a truth object is, for the +// truth-driven direction of the association. +// +// A level must be an ANTICHAIN: no member may be an ancestor of another. A nested pair +// makes the denominator ask for a tau AND its decay products as separate objects, out of +// the same hits, so the efficiency stops meaning anything. A kinematic cut alone does not +// give an antichain. +// +// HardProcess is the OUTGOING LEGS of the hard scatter, not the resonance: the +// deepest-element rule keeps b, b~ and the W decay products on ttbar rather than the +// tops. The resonance itself is the SIGNAL selection, seeded on its PDG ids. Each +// level answers a different question about the same event; none is more correct. + +#ifndef PhysicsTools_TruthInfo_interface_TruthLevels_h +#define PhysicsTools_TruthInfo_interface_TruthLevels_h + +#include +#include +#include +#include +#include +#include +#include + +#include "FWCore/Utilities/interface/Exception.h" +#include "SimDataFormats/TruthInfo/interface/Graph.h" +#include "SimDataFormats/TruthInfo/interface/Particle.h" +#include "SimDataFormats/TruthInfo/interface/Vertex.h" + +namespace truth { + + enum class Level { + StableLegsFromUpstream, + HardProcess, + StableDecayProducts, + CaloBoundary, + ReconstructableFromSignal, + UnderlyingEvent, + PartonJets, + BHadrons, + CHadrons, + ReconstructableFinalState, + VisibleTau + }; + + // One row per level: the enum value, the bit it stamps on the graph, and the name a + // configuration selects it by. THE place a level is declared: the name and flag + // lookups, kAllLevels and the mask fillLevelFlags clears are all derived from it, so + // adding a level is one row instead of five edits that have to agree. LevelFlag::Signal + // is deliberately not a row: the selection post-processing owns that bit, not the + // level machinery. + struct LevelRow { + Level level; + LevelFlag flag; + char const* name; + }; + + inline constexpr std::array kLevelTable = { + {{Level::StableLegsFromUpstream, LevelFlag::StableLegsFromUpstream, "stableLegsFromUpstream"}, + {Level::HardProcess, LevelFlag::HardProcess, "hardProcess"}, + {Level::StableDecayProducts, LevelFlag::StableDecayProducts, "stableDecayProducts"}, + {Level::CaloBoundary, LevelFlag::CaloBoundary, "caloBoundary"}, + {Level::ReconstructableFromSignal, LevelFlag::ReconstructableFromSignal, "reconstructableFromSignal"}, + {Level::UnderlyingEvent, LevelFlag::UnderlyingEvent, "underlyingEvent"}, + {Level::PartonJets, LevelFlag::PartonJets, "partonJets"}, + {Level::BHadrons, LevelFlag::BHadrons, "bHadrons"}, + {Level::CHadrons, LevelFlag::CHadrons, "cHadrons"}, + {Level::ReconstructableFinalState, LevelFlag::ReconstructableFinalState, "reconstructableFinalState"}, + {Level::VisibleTau, LevelFlag::VisibleTau, "visibleTau"}}}; + + inline constexpr std::array kAllLevels = [] { + std::array levels{}; + for (std::size_t i = 0; i < kLevelTable.size(); ++i) { + levels[i] = kLevelTable[i].level; + } + return levels; + }(); + + // Every bit the level machinery stamps, so fillLevelFlags clears exactly what it owns + // and a new row cannot leave a stale bit behind. + inline constexpr uint32_t kOwnedLevelFlags = [] { + uint32_t mask = 0; + for (auto const& row : kLevelTable) { + mask |= static_cast(row.flag); + } + return mask; + }(); + + [[nodiscard]] inline Level levelFromName(std::string const& name) { + for (auto const& row : kLevelTable) { + if (name == row.name) { + return row.level; + } + } + cms::Exception ex("TruthLevels"); + ex << "unknown truth level '" << name << "', expected one of:"; + for (auto const& row : kLevelTable) { + ex << " " << row.name; + } + throw ex; + } + + // Inverse of levelFromName, so a log line and a configuration string use one spelling. + [[nodiscard]] inline const char* levelName(Level level) { + for (auto const& row : kLevelTable) { + if (row.level == level) { + return row.name; + } + } + return "unknown"; + } + + namespace detail { + // reco::GenStatusFlags bit positions, as packed into ParticleData::statusFlags. + constexpr uint16_t kIsHardProcess = 1u << 7; + constexpr uint16_t kIsLastCopy = 1u << 13; + } // namespace detail + + // Quarks and gluons. Strings, clusters and diquarks are collapsed away by + // truth::collapseGenShower before the graph is built, so they cannot appear here. + [[nodiscard]] inline bool isParton(int32_t pdgId) { + const int64_t a = std::abs(static_cast(pdgId)); + return (a >= 1 && a <= 6) || a == 21; + } + + // Ordinary hadron whose quark content includes `flavor` (5 = b, 4 = c), read off the + // PDG hadron-numbering digits. Nuclei and generator-internal codes are not hadrons here. + [[nodiscard]] inline bool hadronHasQuark(int32_t pdgId, int32_t flavor) { + const int64_t id = std::abs(static_cast(pdgId)); + if (id < 100 || id >= 1000000000) + return false; + // A diquark is nq1 nq2 0 nJ, so its third quark digit is zero. It carries the + // flavour digits of the hadron it fragments into and would otherwise be flagged + // AND, being that hadron's ancestor, cover it in the earliest-element antichain. + if (id >= 1000 && id <= 9999 && (id / 10) % 10 == 0 && (id / 100) % 10 != 0) + return false; + const int64_t nq1 = (id / 1000) % 10; + const int64_t nq2 = (id / 100) % 10; + const int64_t nq3 = (id / 10) % 10; + return nq1 == flavor || nq2 == flavor || nq3 == flavor; + } + + // Whether a seed pdgId list names a RESONANCE to look for. + // + // Two spellings mean "no selection" and both must be read that way: an EMPTY list, which + // is what a production with no preset configures, and {0}, the full-graph escape hatch, + // since no real particle carries pdgId 0. Neither may be read as "the resonance is + // missing", and neither may be read as "everything is signal": on such a sample the + // signal level is NOT ANSWERABLE, so it is not offered at all. + // + // Templated because the seed list is std::vector in the module parameters and + // std::vector on the Graph. One definition, so every consumer decides + // whether a sample has a resonance by the same rule. + template + [[nodiscard]] inline bool seedsNameAResonance(Seeds const& seeds) { + return !seeds.empty() && std::find(seeds.begin(), seeds.end(), 0) == seeds.end(); + } + + // A selection also names a signal when it seeds on heavy-flavour hadron content + // rather than on pdg ids: the heavyflavor preset carries an empty pdg id list and a + // flavour list instead, and both spellings must be read the same way everywhere. + template + [[nodiscard]] inline bool seedsNameAResonance(Seeds const& seeds, Flavors const& flavors) { + return seedsNameAResonance(seeds) || !flavors.empty(); + } + + // One entry per physical hadronically decaying tau: the LAST tau of each radiative + // chain, so a tau radiating a photon counts once, the same last-copy rule the b and c + // hadron levels use. Requires a GEN decay record, because a tau with no recorded decay + // cannot be classified, and rejects a decay with an electron or a muon among the + // children, which is what tau identification measures efficiency against + // (TauGenJetProducer applies the same rule). Membership alone is an antichain: a tau + // with a tau child is not a member, so no member can be an ancestor of another member + // through the only chain taus form. + [[nodiscard]] inline bool isVisibleTau(Graph const& graph, uint32_t id) { + auto const& data = graph.particles()[id]; + if (std::abs(static_cast(data.pdgId)) != 15 || data.isSynthetic()) { + return false; + } + bool hasGenDecay = false; + for (const uint32_t vertexId : graph.decayVertices(id)) { + if (vertexId >= graph.nVertices() || !graph.vertices()[vertexId].hasGen()) { + continue; + } + hasGenDecay = true; + for (const uint32_t child : graph.outgoingParticles(vertexId)) { + if (child >= graph.nParticles() || child == id) { + continue; + } + const int64_t a = std::abs(static_cast(graph.particles()[child].pdgId)); + if (a == 15 || a == 11 || a == 13) { + return false; + } + } + } + return hasGenDecay; + } + + // Whether one particle belongs to a level, before the antichain check. + [[nodiscard]] inline bool atLevel(Graph const& graph, uint32_t id, Level level) { + auto const& data = graph.particles()[id]; + switch (level) { + case Level::StableLegsFromUpstream: + // Not a per-particle predicate: it is reachability from the Upstream node, so + // it is answered by stableLegsFromUpstream and never reaches here. + return false; + case Level::HardProcess: + // The hard-scatter legs, not the resonance: see the header note. + // isHardProcess alone: isHardProcess and isLastCopy are never set on the same + // copy (0.00 per event on the generator record of ttbar, DYToLL and VBFHZZ4Nu), + // so repeated copies are removed by the deepest-element antichain below instead. + return (data.statusFlags & detail::kIsHardProcess) != 0; + case Level::StableDecayProducts: + // Final-state generator particles. Stable at GEN means no GEN descendant, so + // these cannot contain one another. + return data.hasGen() && data.status == 1; + case Level::UnderlyingEvent: + // Reachability from the artificial UnderlyingEvent vertex, answered by + // stableLegsFromUnderlyingEvent. + return false; + case Level::ReconstructableFromSignal: + // Not a per-particle predicate either: it is a walk down from the signal roots, + // so it is answered by reconstructableFromSignal and never reaches here. + return false; + case Level::PartonJets: + // Derived from the HardProcess antichain, so it needs that level's result rather + // than a per-particle rule, and is answered by partonJets(). + return false; + case Level::BHadrons: + // The earliest-element antichain then keeps the B* and drops the B below it. + return hadronHasQuark(data.pdgId, 5); + case Level::ReconstructableFinalState: + // A walk from the GEN roots, answered by reconstructableFinalState, so it never + // reaches here. + return false; + case Level::VisibleTau: + return isVisibleTau(graph, id); + case Level::CHadrons: + // A c hadron from a B decay is a legitimate member: the nesting that matters is + // within one flavour, and beauty and charm are deliberately different levels. + return hadronHasQuark(data.pdgId, 4); + case Level::CaloBoundary: + // Recorded crossing the tracker-calorimeter boundary outward. Back-scattered + // tracks crossed it inward and are the same particle coming back. + return !data.backscattered && Particle(&graph, id).checkpoint(0).has_value(); + } + return false; + } + + // Stable legs hanging off every artificial vertex of one role. Upstream collects the + // ISR and upstream side of the interaction, UnderlyingEvent the spectators; the walk is + // identical, so it is written once. A leg is a particle that produced nothing further, + // which makes the result an antichain by construction. + [[nodiscard]] inline std::vector stableLegsFromRole(Graph const& graph, VertexRole role) { + std::vector legs; + std::vector seen(graph.nParticles(), false); + std::vector stack; + + const uint32_t nVertices = graph.nVertices(); + for (uint32_t v = 0; v < nVertices; ++v) { + auto const& vertexData = graph.vertices()[v]; + if (vertexData.vertexRole() != role) { + continue; + } + // Depth-first from each outgoing particle over the raw CSR spans; a particle + // the GENERATOR gave nothing further is a leg. Only GEN decay vertices count + // and are descended: a SIM continuation is transport, so a stable ISR photon + // that converts in the tracker stays the leg instead of dissolving into its + // conversion products. + for (const uint32_t outgoing : graph.outgoingParticles(v)) { + stack.push_back(outgoing); + } + while (!stack.empty()) { + const uint32_t id = stack.back(); + stack.pop_back(); + if (id >= seen.size() || seen[id]) { + continue; + } + seen[id] = true; + bool isLeg = true; + for (const uint32_t vertexId : graph.decayVertices(id)) { + if (vertexId >= nVertices || !graph.vertices()[vertexId].hasGen()) { + continue; + } + for (const uint32_t child : graph.outgoingParticles(vertexId)) { + if (child == id) { + continue; + } + isLeg = false; + if (child < seen.size() && !seen[child]) { + stack.push_back(child); + } + } + } + if (isLeg) { + legs.push_back(id); + } + } + } + std::sort(legs.begin(), legs.end()); + return legs; + } + + [[nodiscard]] inline std::vector stableLegsFromUpstream(Graph const& graph) { + return stableLegsFromRole(graph, VertexRole::Upstream); + } + + [[nodiscard]] inline std::vector stableLegsFromUnderlyingEvent(Graph const& graph) { + return stableLegsFromRole(graph, VertexRole::UnderlyingEvent); + } + + // Species a detector cannot reconstruct at all, so they are not part of the visible + // final state. Only the neutrinos today; anything else invisible would belong here. + [[nodiscard]] inline bool isInvisible(int32_t pdgId) { + const int64_t a = std::abs(static_cast(pdgId)); + return a == 12 || a == 14 || a == 16; + } + + namespace detail { + // The reconstructable-final-state walk from a caller-chosen seed set. The seed + // predicate is the ONLY difference between the signal-seeded level and the + // event-wide one, so the walk and its termination rules live here once. + template + [[nodiscard]] inline std::vector reconstructableLegsFrom(Graph const& graph, SeedPredicate isSeed) { + const uint32_t nParticles = graph.nParticles(); + std::vector legs; + std::vector seen(nParticles, false); + std::vector stack; + auto const& terminating = graph.reconstructablePdgIds(); + + for (uint32_t p = 0; p < nParticles; ++p) { + if (isSeed(p)) { + seen[p] = true; + stack.push_back(p); + } + } + + while (!stack.empty()) { + const uint32_t p = stack.back(); + stack.pop_back(); + auto const& data = graph.particles()[p]; + + // Terminal three ways: the detector reconstructs this species as an object even + // though it decays (pi0), the generator called it stable, or the generator wrote + // nothing below it. Anything else is an intermediate the detector never sees as + // an object, an a1 or a rho, and the walk goes through it without labelling it. + // The walk descends through GEN decay vertices ONLY: this level is the visible + // final state of the GENERATOR, and a SIM continuation is transport, not decay. + // A K0S the generator decayed but Geant4 also interacted in material must yield + // its GEN pions, never the nuclear secondaries of the SIM vertex. + // The seen mask makes this terminate on a graph with a cycle. + const bool reconstructableSpecies = + std::find(terminating.begin(), terminating.end(), data.pdgId) != terminating.end(); + const bool genStable = data.hasGen() && data.status == 1; + bool hasGenDecay = false; + for (const uint32_t vertexId : graph.decayVertices(p)) { + if (vertexId < graph.nVertices() && graph.vertices()[vertexId].hasGen()) { + hasGenDecay = true; + break; + } + } + if (reconstructableSpecies || genStable || !hasGenDecay) { + // A synthetic particle is an accounting object with no hits, so it can never be + // reconstructed and must not become a leg: the signal stand-in is Signal-flagged + // and vertex-less, and both terminals above are true for it. + if (!isInvisible(data.pdgId) && !data.isSynthetic()) { + legs.push_back(p); + } + continue; + } + + for (const uint32_t vertexId : graph.decayVertices(p)) { + if (vertexId >= graph.nVertices() || !graph.vertices()[vertexId].hasGen()) { + continue; + } + for (const uint32_t child : graph.outgoingParticles(vertexId)) { + if (child < nParticles && !seen[child]) { + seen[child] = true; + stack.push_back(child); + } + } + } + } + + std::sort(legs.begin(), legs.end()); + return legs; + } + } // namespace detail + + // The first stable, reconstructable particles the signal produced. + // + // Walk down from every Signal root and stop at the first generator-stable descendant, + // which is where the decay chain ends and the detector's job begins. GEN-stable + // terminates the walk on purpose: a stable pion still has a SIM continuation as it + // showers, and descending into that would return shower fragments instead of the + // particle the resonance actually produced. + // + // Neutrinos are dropped rather than walked through, so the result is the VISIBLE final + // state of the resonance. A signal root that is itself stable, a gun electron say, is + // its own leg. + // + // An antichain by construction: the walk stops at each leg, so no leg can be an + // ancestor of another. Empty when nothing carries the Signal flag. + [[nodiscard]] inline std::vector reconstructableFromSignal(Graph const& graph) { + return detail::reconstructableLegsFrom( + graph, [&graph](uint32_t p) { return graph.particles()[p].isAtLevel(LevelFlag::Signal); }); + } + + // The same walk seeded from every GEN root, the particles with a GEN record and no GEN + // parent, so the level exists on every sample: a pi0 is one object inside a QCD jet, + // the underlying event and each pileup interaction, none of which has a resonance to + // seed from. reconstructableFromSignal answers "what did the resonance produce"; this + // level answers "what could the detector see", event-wide. + [[nodiscard]] inline std::vector reconstructableFinalState(Graph const& graph) { + auto const isGenRoot = [&graph](uint32_t p) { + if (!graph.particles()[p].hasGen()) { + return false; + } + for (const uint32_t vertexId : graph.productionVertices(p)) { + if (vertexId >= graph.nVertices()) { + continue; + } + for (const uint32_t parent : graph.incomingParticles(vertexId)) { + if (parent != p && graph.particles()[parent].hasGen()) { + return false; + } + } + } + return true; + }; + return detail::reconstructableLegsFrom(graph, isGenRoot); + } + + // PartonJets is defined in terms of the HardProcess antichain and levelAntichain + // dispatches back to it, so one of the two has to be declared ahead of the other. + [[nodiscard]] inline std::vector levelAntichain(Graph const& graph, Level level); + + // One root per parton-initiated jet: the hard-scatter legs that are partons, each + // standing for its descendant subgraph. No clustering and no cone; the flavour is the + // parton's own PDG id. The deepest-element rule of HardProcess keeps a top's b rather + // than the top and keeps the incoming beam partons out. EMPTY, not wrong, when + // statusFlags are unavailable, which is the HepMC3 path. The flag-driven levels are + // NOT restricted to the signal interaction: they hold whatever carries isHardProcess, + // and a consumer that needs a signal-only set filters on eventId as the denominator + // producer does. Measured on 10 PU200 ttbar events with the standard pile-up library, + // no overlaid interaction carries the flag, so the level is signal-only in practice. + // The ROOTS are an + // antichain but the SUBGRAPHS may overlap: two colour-connected quarks fragment + // through one string, and assigning each hadron to exactly one jet is what a + // clustering algorithm is for. + [[nodiscard]] inline std::vector partonJets(Graph const& graph) { + std::vector roots = levelAntichain(graph, Level::HardProcess); + roots.erase( + std::remove_if( + roots.begin(), roots.end(), [&graph](uint32_t id) { return !isParton(graph.particles()[id].pdgId); }), + roots.end()); + return roots; + } + + // The level as an antichain. Candidates that have another candidate as an ancestor are + // dropped, so what remains is one entry per physical object at that level. The + // membership rules above are already antichains in a well-formed graph; the check is + // kept because a denominator that silently contains a particle and its own parent is + // the failure this class exists to prevent. + // Drop every member that another member covers. With keepDeepest false a member that + // has a member ANCESTOR goes, which leaves the earliest of each chain; keepDeepest + // reverses the direction. THIS is what makes a level an antichain, so every level runs + // it. A membership rule that looks like an antichain is not enough: on a re-convergent + // history a walk that stops at a pi0 on one path still reaches that pi0's photon on + // another, and the level ends up holding both. + inline void dropCoveredMembers(Graph const& graph, std::vector& members, bool keepDeepest) { + const uint32_t nParticles = graph.nParticles(); + std::vector covered(nParticles, 0); + std::vector stack; + stack.reserve(members.size()); + // Seed with the members' immediate neighbours in the chosen direction, so a + // member itself is only marked when REACHED from another member. + auto pushNeighbours = [&](uint32_t id) { + if (keepDeepest) { + for (const uint32_t vertexId : graph.productionVertices(id)) { + if (vertexId >= graph.nVertices()) { + continue; + } + for (const uint32_t parent : graph.incomingParticles(vertexId)) { + // A particle that is its own neighbour would cover itself and drop out of + // its own level; the graph navigation guards self-loops the same way. + if (parent != id && parent < nParticles && covered[parent] == 0) { + covered[parent] = 1; + stack.push_back(parent); + } + } + } + } else { + for (const uint32_t vertexId : graph.decayVertices(id)) { + if (vertexId >= graph.nVertices()) { + continue; + } + for (const uint32_t child : graph.outgoingParticles(vertexId)) { + if (child != id && child < nParticles && covered[child] == 0) { + covered[child] = 1; + stack.push_back(child); + } + } + } + } + }; + for (uint32_t id : members) { + pushNeighbours(id); + } + while (!stack.empty()) { + const uint32_t id = stack.back(); + stack.pop_back(); + pushNeighbours(id); + } + + std::erase_if(members, [&covered](uint32_t id) { return covered[id] != 0; }); + } + + [[nodiscard]] inline std::vector levelAntichain(Graph const& graph, Level level) { + if (level == Level::StableLegsFromUpstream) { + std::vector legs = stableLegsFromUpstream(graph); + dropCoveredMembers(graph, legs, false); + return legs; + } + if (level == Level::ReconstructableFromSignal) { + std::vector legs = reconstructableFromSignal(graph); + dropCoveredMembers(graph, legs, false); + return legs; + } + if (level == Level::ReconstructableFinalState) { + std::vector legs = reconstructableFinalState(graph); + dropCoveredMembers(graph, legs, false); + return legs; + } + if (level == Level::UnderlyingEvent) { + std::vector legs = stableLegsFromUnderlyingEvent(graph); + dropCoveredMembers(graph, legs, false); + return legs; + } + if (level == Level::PartonJets) { + // Filtered from HardProcess, which dropCoveredMembers already reduced. + return partonJets(graph); + } + std::vector candidates; + const uint32_t nParticles = graph.nParticles(); + for (uint32_t id = 0; id < nParticles; ++id) { + if (atLevel(graph, id, level)) { + candidates.push_back(id); + } + } + // Which end of a chain of candidates to keep, per level. + // + // Earliest, the default: the members are final states, and a candidate with a + // candidate ANCESTOR is a duplicate of it. + // + // Deepest for HardProcess: the incoming partons and the outgoing particles both carry + // the flag, the incoming ones are ancestors of the outgoing ones, and it is the + // outgoing ones that the level is about. Keeping the earliest there would return the + // beam partons, which sit at pt 0 and enormous eta and are then dropped by any + // kinematic selector, leaving the level empty. + // + // Deepest for BHadrons and CHadrons: the object is the hadron that DECAYS WEAKLY, so + // the level names the same particle CMS names. A B* radiating to a B is not a + // duplicate of it: the two carry different momenta and, decisively, different decay + // vertices, because the B* decays electromagnetically at the production point while + // the B travels. Measured on 200 ttbar and 300 QCD generator events: the count is the + // same either way, 68.9% and 61.8% of chains hold a different particle, and the + // median decay displacement goes from 0.000 cm to 0.46 cm. + const bool keepDeepest = level == Level::HardProcess || level == Level::BHadrons || level == Level::CHadrons; + dropCoveredMembers(graph, candidates, keepDeepest); + return candidates; + } + + // The persisted bit for a level. Kept next to the Level enum so adding a level forces + // the author past this switch, which has no default for that reason. + [[nodiscard]] inline LevelFlag levelFlagOf(Level level) { + for (auto const& row : kLevelTable) { + if (row.level == level) { + return row.flag; + } + } + throw cms::Exception("TruthLevels") << "level " << static_cast(level) << " has no row in kLevelTable"; + } + + // Stamp every particle with the levels it belongs to. Call once, on the COMPLETE graph: + // levelAntichain walks ancestors and descendants, so a graph still being assembled + // gives an antichain of whatever existed at the time. + // + // Clears first, so calling it twice is the same as calling it once. That matters + // because a stale flag is indistinguishable from a fresh one by inspection, and the + // only defence is that the operation is reproducible and idempotent. + inline void fillLevelFlags(Graph& graph) { + // Preconditions, because the walks below index the CSR arrays directly: the graph + // must be shaped (Graph::isConsistent) and acyclic. A short offset array aborts the + // job with a bare std::out_of_range and no module context. A cycle makes every member + // of a level cover every other, which empties that level with no diagnostic. + // TruthGraphTopologyChecker counts cycles in a job. + if (graph.nParticles() == 0) { + return; + } + if (graph.particleToDecayVertexOffsets().size() != static_cast(graph.nParticles()) + 1 || + graph.particleToProductionVertexOffsets().size() != static_cast(graph.nParticles()) + 1) { + throw cms::Exception("TruthLevels") + << "fillLevelFlags needs CSR offsets of size nParticles + 1 (" << graph.nParticles() + 1 << "), found " + << graph.particleToDecayVertexOffsets().size() << " and " << graph.particleToProductionVertexOffsets().size(); + } + // Clear only the bits this function owns. LevelFlag::Signal is set upstream, by the + // selection post-processing that knows the seed species, and clearing it here would + // silently erase the resonance. + for (auto& particle : graph.particles()) { + particle.levelFlags &= ~kOwnedLevelFlags; + } + // The HardProcess antichain feeds two levels, itself and (filtered to partons) + // the parton jets, so it is computed once. + const std::vector hardProcess = levelAntichain(graph, Level::HardProcess); + for (const Level level : kAllLevels) { + const LevelFlag flag = levelFlagOf(level); + std::vector ids; + if (level == Level::HardProcess) { + ids = hardProcess; + } else if (level == Level::PartonJets) { + ids = hardProcess; + ids.erase(std::remove_if( + ids.begin(), ids.end(), [&graph](uint32_t id) { return !isParton(graph.particles()[id].pdgId); }), + ids.end()); + } else { + ids = levelAntichain(graph, level); + } + for (const uint32_t id : ids) { + if (id < graph.nParticles()) { + graph.particles()[id].setLevel(flag); + } + } + } + } + +} // namespace truth + +#endif diff --git a/PhysicsTools/TruthInfo/interface/TruthLogicalGraphPostProcessor.h b/PhysicsTools/TruthInfo/interface/TruthLogicalGraphPostProcessor.h index 3d89416ee855b..f10640a351033 100644 --- a/PhysicsTools/TruthInfo/interface/TruthLogicalGraphPostProcessor.h +++ b/PhysicsTools/TruthInfo/interface/TruthLogicalGraphPostProcessor.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #ifndef PhysicsTools_TruthInfo_interface_TruthLogicalGraphPostProcessor_h #define PhysicsTools_TruthInfo_interface_TruthLogicalGraphPostProcessor_h @@ -43,6 +41,14 @@ namespace truth { // every B species. OR-ed with seedPdgIds. std::vector seedHadronFlavors; + // Species a detector reconstructs as an OBJECT although they decay, so the walk from + // the signal down to its reconstructable products stops there and labels them. pi0 is + // the case that motivates it: it decays to two photons at once, but the analysis + // reconstructs the pi0. An intermediate resonance the detector never sees as an + // object, an a1 or a rho, is deliberately absent so the walk passes through it; add + // its pdg id here to label it instead. + std::vector reconstructablePdgIds; + // For each selected root, keep this many generations of ancestors above it // as context only: the ancestors and connecting vertices are kept, but not // their other descendants. @@ -121,6 +127,10 @@ namespace truth { static edm::ParameterSetDescription psetDescription(); static LogicalGraphPostProcessingConfig configFromPSet(edm::ParameterSet const& pset); + // The configuration this instance runs with, so a caller can record the seed + // species on the graph and keep LevelFlag::Signal re-derivable. + [[nodiscard]] LogicalGraphPostProcessingConfig const& config() const { return config_; } + // particleDirectHit[i] != 0 marks logical particle i as carrying at least one // positive-energy calorimeter or tracker sim-hit on its own SimTrack. It must // be aligned to the input graph's particle ids; an empty vector disables the diff --git a/PhysicsTools/TruthInfo/plugins/BranchHGCalValidator.cc b/PhysicsTools/TruthInfo/plugins/BranchHGCalValidator.cc index 13944273777e1..c414e4b83a49e 100644 --- a/PhysicsTools/TruthInfo/plugins/BranchHGCalValidator.cc +++ b/PhysicsTools/TruthInfo/plugins/BranchHGCalValidator.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // DQM performance plots for the truth::Branch graph as a replacement for the // legacy HGCAL truth objects (CaloParticle, SimCluster / SimTracksters). For each diff --git a/PhysicsTools/TruthInfo/plugins/BranchRecoValidator.cc b/PhysicsTools/TruthInfo/plugins/BranchRecoValidator.cc index 683387818329b..5063bbb1f9556 100644 --- a/PhysicsTools/TruthInfo/plugins/BranchRecoValidator.cc +++ b/PhysicsTools/TruthInfo/plugins/BranchRecoValidator.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // Generic reco-side DQM validator: matches any reco collection to the truth Branch // graph through shared detector hits and books MultiTrackValidator / HGCalValidator diff --git a/PhysicsTools/TruthInfo/plugins/BranchTrackerReplacementValidator.cc b/PhysicsTools/TruthInfo/plugins/BranchTrackerReplacementValidator.cc index cd43148d47a14..833c94b8dc70c 100644 --- a/PhysicsTools/TruthInfo/plugins/BranchTrackerReplacementValidator.cc +++ b/PhysicsTools/TruthInfo/plugins/BranchTrackerReplacementValidator.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // Validates that a truth::Branch can stand in for a TrackingParticle for // track<->truth association. For each reco track it (a) matches the track to a diff --git a/PhysicsTools/TruthInfo/plugins/BranchTrackingValidator.cc b/PhysicsTools/TruthInfo/plugins/BranchTrackingValidator.cc index d1f8309281453..846e8e998c3ec 100644 --- a/PhysicsTools/TruthInfo/plugins/BranchTrackingValidator.cc +++ b/PhysicsTools/TruthInfo/plugins/BranchTrackingValidator.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // DQM performance plots for the truth::Branch graph as a replacement for the // TrackingParticle in track->truth association - the tracker counterpart of diff --git a/PhysicsTools/TruthInfo/plugins/BranchTruthReplacementValidator.cc b/PhysicsTools/TruthInfo/plugins/BranchTruthReplacementValidator.cc index 5f413b5a02aba..b714e0cbbe50c 100644 --- a/PhysicsTools/TruthInfo/plugins/BranchTruthReplacementValidator.cc +++ b/PhysicsTools/TruthInfo/plugins/BranchTruthReplacementValidator.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // Validates that a truth::Branch can stand in for the legacy calo truth objects // (CaloParticle, SimCluster): for each legacy object it maps the object to its diff --git a/PhysicsTools/TruthInfo/plugins/LogicalGraphHitIndexProducer.cc b/PhysicsTools/TruthInfo/plugins/LogicalGraphHitIndexProducer.cc index 58894e5fa5c34..f9bd4308ce6b2 100644 --- a/PhysicsTools/TruthInfo/plugins/LogicalGraphHitIndexProducer.cc +++ b/PhysicsTools/TruthInfo/plugins/LogicalGraphHitIndexProducer.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include #include diff --git a/PhysicsTools/TruthInfo/plugins/PFRecHitFlatTableProducer.cc b/PhysicsTools/TruthInfo/plugins/PFRecHitFlatTableProducer.cc index 74666c7cb3672..afe87aa7ea1b7 100644 --- a/PhysicsTools/TruthInfo/plugins/PFRecHitFlatTableProducer.cc +++ b/PhysicsTools/TruthInfo/plugins/PFRecHitFlatTableProducer.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // Author: Felice Pantaleo - CERN // Flat-table dump of reco::PFRecHit collections (barrel/forward calorimeters: diff --git a/PhysicsTools/TruthInfo/plugins/RecHitFlatTableProducer.cc b/PhysicsTools/TruthInfo/plugins/RecHitFlatTableProducer.cc index c29ba0ba50b8a..6efb611a9d224 100644 --- a/PhysicsTools/TruthInfo/plugins/RecHitFlatTableProducer.cc +++ b/PhysicsTools/TruthInfo/plugins/RecHitFlatTableProducer.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include "FWCore/Framework/interface/stream/EDProducer.h" #include "FWCore/Framework/interface/Event.h" diff --git a/PhysicsTools/TruthInfo/plugins/TrackerSimHitFlatTableProducer.cc b/PhysicsTools/TruthInfo/plugins/TrackerSimHitFlatTableProducer.cc index e9905046a6f4c..e42c158ef641f 100644 --- a/PhysicsTools/TruthInfo/plugins/TrackerSimHitFlatTableProducer.cc +++ b/PhysicsTools/TruthInfo/plugins/TrackerSimHitFlatTableProducer.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // Author: Felice Pantaleo - CERN // Flat-table dump of tracker PSimHit collections (g4SimHits TrackerHits*). diff --git a/PhysicsTools/TruthInfo/plugins/TruthBranchCaloAssociationProducer.cc b/PhysicsTools/TruthInfo/plugins/TruthBranchCaloAssociationProducer.cc index 7ce7967a7c031..8e0094e51a386 100644 --- a/PhysicsTools/TruthInfo/plugins/TruthBranchCaloAssociationProducer.cc +++ b/PhysicsTools/TruthInfo/plugins/TruthBranchCaloAssociationProducer.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // Builds TICL-style AssociationMaps between the truth::Branch graph and the legacy // HGCAL calo truth objects (CaloParticle, SimCluster), in the same fashion as the diff --git a/PhysicsTools/TruthInfo/plugins/TruthBranchTrackingAssociationProducer.cc b/PhysicsTools/TruthInfo/plugins/TruthBranchTrackingAssociationProducer.cc index eb6626d43b621..3715580834757 100644 --- a/PhysicsTools/TruthInfo/plugins/TruthBranchTrackingAssociationProducer.cc +++ b/PhysicsTools/TruthInfo/plugins/TruthBranchTrackingAssociationProducer.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // Builds TICL-style AssociationMaps between reco tracks and the truth::Branch // graph, the tracker counterpart of TruthBranchCaloAssociationProducer. Unlike the diff --git a/PhysicsTools/TruthInfo/plugins/TruthGraphAccumulator.cc b/PhysicsTools/TruthInfo/plugins/TruthGraphAccumulator.cc index ca8fb3b7065dd..47c4804b2d97e 100644 --- a/PhysicsTools/TruthInfo/plugins/TruthGraphAccumulator.cc +++ b/PhysicsTools/TruthInfo/plugins/TruthGraphAccumulator.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // Phase-B (B1): build the mixed (signal + pileup) raw TruthGraph as a // DigiAccumulatorMixMod, like TrackingTruthAccumulator / CaloTruthAccumulator. diff --git a/PhysicsTools/TruthInfo/plugins/TruthGraphDumper.cc b/PhysicsTools/TruthInfo/plugins/TruthGraphDumper.cc index a112e3f179e30..673b45cede6f9 100644 --- a/PhysicsTools/TruthInfo/plugins/TruthGraphDumper.cc +++ b/PhysicsTools/TruthInfo/plugins/TruthGraphDumper.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include #include @@ -300,8 +298,7 @@ class TruthGraphDumper : public edm::one::EDAnalyzer<> { explicit TruthGraphDumper(const edm::ParameterSet& cfg) : token_(consumes(cfg.getParameter("src"))), dotFile_(cfg.getParameter("dotFile")), - maxNodes_(cfg.getParameter("maxNodes")), - maxEdgesPerNode_(cfg.getParameter("maxEdgesPerNode")), + simTracksToken_(mayConsume(cfg.getParameter("simTracks"))), simVerticesToken_(mayConsume(cfg.getParameter("simVertices"))), hepmc2Token_(mayConsume(cfg.getParameter("genEventHepMC"))), @@ -311,8 +308,6 @@ class TruthGraphDumper : public edm::one::EDAnalyzer<> { edm::ParameterSetDescription desc; desc.add("src", edm::InputTag("truthGraphProducer")); desc.add("dotFile", "truthgraph.dot"); - desc.add("maxNodes", 5000)->setComment("Truncate to keep DOT manageable"); - desc.add("maxEdgesPerNode", 200)->setComment("Truncate fanout per node"); desc.add("simTracks", edm::InputTag("g4SimHits")) ->setComment("SimTrackContainer (optional, used to enrich SimTrack nodes)"); @@ -403,7 +398,7 @@ class TruthGraphDumper : public edm::one::EDAnalyzer<> { os << " rankdir=LR;\n"; os << " node [fontsize=10];\n"; - const uint32_t n = std::min(g.nNodes(), maxNodes_); + const uint32_t n = g.nNodes(); // nodes for (uint32_t i = 0; i < n; ++i) { @@ -629,16 +624,12 @@ class TruthGraphDumper : public edm::one::EDAnalyzer<> { for (uint32_t src = 0; src < n; ++src) { const uint32_t b = g.edgeBegin(src); const uint32_t e = g.edgeEnd(src); - - unsigned kept = 0; for (uint32_t pos = b; pos < e; ++pos) { const uint32_t dst = g.edges()[pos]; if (dst >= n) continue; os << " n" << src << " -> n" << dst << edgeAttrs(static_cast(g.edgeKind()[pos])) << ";\n"; - if (++kept >= maxEdgesPerNode_) - break; } } @@ -649,8 +640,6 @@ class TruthGraphDumper : public edm::one::EDAnalyzer<> { private: edm::EDGetTokenT token_; std::string dotFile_; - unsigned maxNodes_; - unsigned maxEdgesPerNode_; edm::EDGetTokenT simTracksToken_; edm::EDGetTokenT simVerticesToken_; diff --git a/PhysicsTools/TruthInfo/plugins/TruthGraphMixedProducer.cc b/PhysicsTools/TruthInfo/plugins/TruthGraphMixedProducer.cc index bd7b4644e0c74..d6e3343ebb761 100644 --- a/PhysicsTools/TruthInfo/plugins/TruthGraphMixedProducer.cc +++ b/PhysicsTools/TruthInfo/plugins/TruthGraphMixedProducer.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // Phase-A pileup prototype: build a raw TruthGraph from the MixingModule // crossing frames (signal + pileup) instead of the signal-only g4SimHits. diff --git a/PhysicsTools/TruthInfo/plugins/TruthGraphProducer.cc b/PhysicsTools/TruthInfo/plugins/TruthGraphProducer.cc index 9b8c90d3aa447..66a3a3985eb3a 100644 --- a/PhysicsTools/TruthInfo/plugins/TruthGraphProducer.cc +++ b/PhysicsTools/TruthInfo/plugins/TruthGraphProducer.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // Author: Felice Pantaleo - CERN // Date: 03/2026 diff --git a/PhysicsTools/TruthInfo/plugins/TruthGraphTopologyChecker.cc b/PhysicsTools/TruthInfo/plugins/TruthGraphTopologyChecker.cc index eb6db28940830..6a5d6638111e7 100644 --- a/PhysicsTools/TruthInfo/plugins/TruthGraphTopologyChecker.cc +++ b/PhysicsTools/TruthInfo/plugins/TruthGraphTopologyChecker.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // Diagnostic analyzer that audits the raw TruthGraph and the logical truth::Graph // for "strange" topologies and reports their structural provenance: @@ -26,7 +24,9 @@ #include "FWCore/Framework/interface/MakerMacros.h" #include "FWCore/Framework/interface/one/EDAnalyzer.h" #include "FWCore/MessageLogger/interface/MessageLogger.h" +#include "FWCore/ParameterSet/interface/ConfigurationDescriptions.h" #include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" #include "SimDataFormats/EncodedEventId/interface/EncodedEventId.h" @@ -117,6 +117,7 @@ class TruthGraphTopologyChecker : public edm::one::EDAnalyzer<> { explicit TruthGraphTopologyChecker(edm::ParameterSet const&); void analyze(edm::Event const&, edm::EventSetup const&) override; void endJob() override; + static void fillDescriptions(edm::ConfigurationDescriptions&); private: void analyzeRaw(TruthGraph const&); @@ -151,7 +152,15 @@ class TruthGraphTopologyChecker : public edm::one::EDAnalyzer<> { TruthGraphTopologyChecker::TruthGraphTopologyChecker(edm::ParameterSet const& cfg) : rawToken_(consumes(cfg.getParameter("rawSrc"))), logicalToken_(consumes(cfg.getParameter("src"))), - failOnViolations_(cfg.getUntrackedParameter("failOnViolations", false)) {} + failOnViolations_(cfg.getUntrackedParameter("failOnViolations")) {} + +void TruthGraphTopologyChecker::fillDescriptions(edm::ConfigurationDescriptions& descriptions) { + edm::ParameterSetDescription desc; + desc.add("rawSrc", edm::InputTag("truthGraphProducer")); + desc.add("src", edm::InputTag("truthLogicalGraphProducer")); + desc.addUntracked("failOnViolations", false); + descriptions.addWithDefaultLabel(desc); +} void TruthGraphTopologyChecker::analyze(edm::Event const& event, edm::EventSetup const&) { ++nEvents_; diff --git a/PhysicsTools/TruthInfo/plugins/TruthLogicalGraphDumper.cc b/PhysicsTools/TruthInfo/plugins/TruthLogicalGraphDumper.cc index 9f06f8c7b611e..7bf2f8ea3758f 100644 --- a/PhysicsTools/TruthInfo/plugins/TruthLogicalGraphDumper.cc +++ b/PhysicsTools/TruthInfo/plugins/TruthLogicalGraphDumper.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include #include @@ -27,10 +25,41 @@ #include "SimDataFormats/TruthInfo/interface/Graph.h" #include "SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h" #include "PhysicsTools/TruthInfo/interface/SubgraphHitView.h" +#include "PhysicsTools/TruthInfo/interface/TruthLevels.h" #include "SimDataFormats/TruthInfo/interface/TruthGraph.h" namespace { + // One colour per level, shared by the node labels and the legend so the two cannot + // drift. Pale on purpose: these are backgrounds behind black text. + std::string levelColor(truth::Level level) { + switch (level) { + case truth::Level::StableLegsFromUpstream: + return "#cfe8ff"; + case truth::Level::HardProcess: + return "#ffd6a5"; + case truth::Level::StableDecayProducts: + return "#d7f9d7"; + case truth::Level::CaloBoundary: + return "#ffc9c9"; + case truth::Level::ReconstructableFromSignal: + return "#e8d5ff"; + case truth::Level::UnderlyingEvent: + return "#e8e8e8"; + case truth::Level::PartonJets: + return "#ffe9a8"; + case truth::Level::BHadrons: + return "#c9e4d6"; + case truth::Level::CHadrons: + return "#f2d5e0"; + case truth::Level::ReconstructableFinalState: + return "#d5f0f2"; + case truth::Level::VisibleTau: + return "#f9e9c9"; + } + return "#ffffff"; + } + std::string pdgNameUtf8(int pdgId) { const int ap = std::abs(pdgId); @@ -367,13 +396,8 @@ class TruthLogicalGraphDumper : public edm::one::EDAnalyzer<> { useHitIndex_(!hitIndexTag_.label().empty()), dotFile_(cfg.getParameter("dotFile")), layout_(cfg.getParameter("layout")), - maxParticles_(cfg.getParameter("maxParticles")), - maxVertices_(cfg.getParameter("maxVertices")), - maxEdgesPerNode_(cfg.getParameter("maxEdgesPerNode")), - hideLargeSimSourceVertices_(cfg.getParameter("hideLargeSimSourceVertices")), - dumpSimHits_(cfg.getParameter("dumpSimHits")), - largeSimSourceVertexMinOutgoing_(cfg.getParameter("largeSimSourceVertexMinOutgoing")), - hideZeroSimHitSubgraphs_(cfg.getParameter("hideZeroSimHitSubgraphs")) { + + dumpSimHits_(cfg.getParameter("dumpSimHits")) { const auto hgcalRecHitTags = cfg.getParameter>("hgcalRecHits"); hgcalRecHitTags_.reserve(hgcalRecHitTags.size()); hgcalRecHitTokens_.reserve(hgcalRecHitTags.size()); @@ -426,22 +450,8 @@ class TruthLogicalGraphDumper : public edm::one::EDAnalyzer<> { "DOT layout: 'dot' (default, hierarchical left-to-right ranks) or a force-directed engine " "name ('sfdp'/'fdp'/'neato') for node repulsion + spring edges toward parents (no forced ranks)"); - desc.add("maxParticles", 5000)->setComment("Truncate logical particle nodes"); - desc.add("maxVertices", 5000)->setComment("Truncate logical vertex nodes"); - desc.add("maxEdgesPerNode", 200)->setComment("Truncate fanout per node"); - - desc.add("hideLargeSimSourceVertices", true) - ->setComment("If true, do not print large SIM-only source vertices in the DOT output"); desc.add("dumpSimHits", false)->setComment("If true, dump all simhits"); - desc.add("largeSimSourceVertexMinOutgoing", 50) - ->setComment("Minimum outgoing multiplicity for hiding a SIM-only source vertex"); - - desc.add("hideZeroSimHitSubgraphs", false) - ->setComment( - "If true, hide every SIM-backed particle whose subgraph has zero SimHits, together with its descendant " - "subgraph. Requires hitIndex to be configured."); - descriptions.addWithDefaultLabel(desc); } @@ -482,63 +492,13 @@ class TruthLogicalGraphDumper : public edm::one::EDAnalyzer<> { } os << " node [fontsize=10];\n"; - const uint32_t nParticles = std::min(g.nParticles(), maxParticles_); - const uint32_t nVertices = std::min(g.nVertices(), maxVertices_); + const uint32_t nParticles = g.nParticles(); + const uint32_t nVertices = g.nVertices(); std::vector hideVertex(nVertices, 0); - if (hideLargeSimSourceVertices_) { - for (uint32_t i = 0; i < nVertices; ++i) { - auto v = g.vertex(i); - auto const& d = v.data(); - - const auto incoming = v.incomingParticles(); - const auto outgoing = v.outgoingParticles(); - - if (!d.hasGen() && d.hasSim() && incoming.empty() && outgoing.size() >= largeSimSourceVertexMinOutgoing_) { - hideVertex[i] = 1; - } - } - } - std::vector hideParticle(nParticles, 0); - if (hideZeroSimHitSubgraphs_) { - if (hitIndex == nullptr) { - edm::LogWarning("TruthLogicalGraphDumper") - << "hideZeroSimHitSubgraphs is enabled, but no valid LogicalGraphHitIndex was provided. " - << "No zero-hit subgraphs will be hidden."; - } else { - for (uint32_t i = 0; i < nParticles; ++i) { - if (!g.particle(i).data().hasSim()) - continue; - - if (i >= hitIndex->nParticles()) - continue; - - // "Hitless" means no detectable hit in ANY channel: a particle that - // deposits only in the tracker / MTD / muon system (but never reaches - // HGCal) must not be treated as hitless and hidden. - bool hasAnySubgraphHit = false; - for (std::size_t ch = 0; ch < truth::kNumHitChannels; ++ch) { - if (!hitIndex->subgraphHits(static_cast(ch), i).empty()) { - hasAnySubgraphHit = true; - break; - } - } - if (hasAnySubgraphHit) - continue; - - hideParticle[i] = 1; - - forEachDescendantParticle(g, i, [&](uint32_t childId) { - if (childId < hideParticle.size()) - hideParticle[childId] = 1; - }); - } - } - } - for (uint32_t i = 0; i < nVertices; ++i) { if (hideVertex[i]) continue; @@ -548,45 +508,115 @@ class TruthLogicalGraphDumper : public edm::one::EDAnalyzer<> { } } - // Suppress particles that would render with no edge at all (floating "Particle - // N" boxes): the leaf tail of a vertex whose outgoing edges are truncated past - // maxEdgesPerNode_, or particles whose only incident vertices are hidden. They - // are not graph orphans (their production vertex exists) - the crowded DOT view - // just cannot draw the edge. Mirror the emission loops' per-node cap so the same - // edges are considered drawn. (No-op in --showAll: maxEdgesPerNode_ is huge.) + // ------------------------------------------------------------------ + // Particle nodes + // ------------------------------------------------------------------ + + // AUDIT the persisted flags against a fresh computation on the very graph being + // dumped. A stored flag is only trustworthy if it still agrees with the definition + // that produced it, and a file written before a level definition changed is + // indistinguishable from a fresh one by inspection. Logged, never thrown: a stale + // file should still be readable and should say loudly that it is stale. { - std::vector hasVisibleEdge(nParticles, 0); - for (uint32_t v = 0; v < nVertices; ++v) { - if (hideVertex[v]) - continue; - unsigned kept = 0; - for (uint32_t p : g.outgoingParticles(v)) { - if (p >= nParticles || hideParticle[p]) + std::size_t disagreements = 0; + std::ostringstream perLevel; + for (const truth::Level level : truth::kAllLevels) { + const std::vector antichain = truth::levelAntichain(g, level); + std::vector expected(g.nParticles(), false); + for (const uint32_t id : antichain) { + if (id < g.nParticles()) { + expected[id] = true; + } + } + const truth::LevelFlag flag = truth::levelFlagOf(level); + std::size_t stored = 0, bad = 0; + for (uint32_t id = 0; id < g.nParticles(); ++id) { + const bool has = g.particles()[id].isAtLevel(flag); + stored += has ? 1 : 0; + bad += (has != expected[id]) ? 1 : 0; + } + disagreements += bad; + perLevel << " " << truth::levelName(level) << " stored=" << stored << " recomputed=" << antichain.size() + << " disagree=" << bad << "\n"; + } + // Signal cannot be recomputed from the graph alone, so it is checked against the + // RECORDED seed species instead: every flagged particle must either match a seed or + // be the synthetic stand-in, and no flagged particle may have a seed-matching + // ancestor, which is what "most upstream match" means. + std::size_t signalFlagged = 0, signalBad = 0, syntheticSignal = 0; + auto const& seeds = g.signalSeedPdgIds(); + auto const& flavors = g.seedHadronFlavors(); + // A particle is a legitimate seed either by pdg id or by heavy-flavour hadron + // content, matching the two ways a selection preset can name its signal. + auto isSeed = [&](int32_t pdgId) { + if (std::find(seeds.begin(), seeds.end(), pdgId) != seeds.end()) { + return true; + } + for (const int32_t flavor : flavors) { + if (truth::hadronHasQuark(pdgId, flavor)) { + return true; + } + } + return false; + }; + // A particle has a seed-matching ancestor exactly when it is a descendant of one, + // so one multi-source walk answers it for the whole graph. A scan per flagged + // particle would allocate and traverse the graph once each. + std::vector belowSeed(g.nParticles(), 0); + std::vector seedStack; + for (uint32_t id = 0; id < g.nParticles(); ++id) { + if (isSeed(g.particles()[id].pdgId)) { + seedStack.push_back(id); + } + } + while (!seedStack.empty()) { + const uint32_t id = seedStack.back(); + seedStack.pop_back(); + for (const uint32_t vertexId : g.decayVertices(id)) { + if (vertexId >= g.nVertices()) { continue; - hasVisibleEdge[p] = 1; // a production edge v -> p will be drawn - if (++kept >= maxEdgesPerNode_) - break; + } + for (const uint32_t child : g.outgoingParticles(vertexId)) { + if (child != id && child < g.nParticles() && belowSeed[child] == 0) { + belowSeed[child] = 1; + seedStack.push_back(child); + } + } } } - for (uint32_t i = 0; i < nParticles; ++i) { - if (hideParticle[i] || hasVisibleEdge[i]) + + for (uint32_t id = 0; id < g.nParticles(); ++id) { + auto const& d = g.particles()[id]; + if (!d.isAtLevel(truth::LevelFlag::Signal)) { continue; - for (uint32_t v : g.decayVertices(i)) { // any visible decay vertex gives a drawn p -> v edge - if (v < nVertices && !hideVertex[v]) { - hasVisibleEdge[i] = 1; - break; - } + } + ++signalFlagged; + const bool synthetic = d.particleRole() == truth::ParticleRole::SignalStandIn; + if (synthetic) { + ++syntheticSignal; + continue; + } + const bool matchesSeed = isSeed(d.pdgId); + if (!matchesSeed || belowSeed[id] != 0) { + ++signalBad; } } - for (uint32_t i = 0; i < nParticles; ++i) { - if (!hideParticle[i] && !hasVisibleEdge[i]) - hideParticle[i] = 1; + perLevel << " signal stored=" << signalFlagged << " synthetic=" << syntheticSignal << " disagree=" << signalBad + << " recordedSeeds=" << seeds.size() << " recordedFlavors=" << flavors.size() << "\n"; + disagreements += signalBad; + + if (disagreements == 0) { + edm::LogVerbatim("TruthLogicalGraphDumper") << "levelFlags audit OK, " << g.nParticles() << " particles\n" + << perLevel.str(); + } else { + edm::LogWarning("TruthLogicalGraphDumper") + << "levelFlags DISAGREE with a fresh computation on " << disagreements + << " particle/level pairs. This graph was probably written before a level definition " + "changed; re-produce it rather than trusting the flags.\n" + << perLevel.str(); } } - // ------------------------------------------------------------------ - // Particle nodes - // ------------------------------------------------------------------ for (uint32_t i = 0; i < nParticles; ++i) { if (hideParticle[i]) continue; @@ -642,6 +672,20 @@ class TruthLogicalGraphDumper : public edm::one::EDAnalyzer<> { os << " p" << i << " [shape=ellipse, hasCheckpoints=" << p.hasCheckpoints() << ", hasGen=" << p.hasGen() << ", hasSim=" << d.hasSim(); + // Level membership, straight off the persisted flags: the point of storing them is + // that a reader needs no knowledge of how a level is defined. Emitted BOTH as a dot + // attribute a graphviz filter can select on AND, below, as a coloured row in the + // label, because graphviz silently ignores attributes it does not know and an + // attribute alone renders to nothing at all. + std::string levels; + for (const truth::Level level : truth::kAllLevels) { + if (d.isAtLevel(truth::levelFlagOf(level))) { + levels += levels.empty() ? "" : ","; + levels += truth::levelName(level); + } + } + os << ", levels=\"" << levels << "\""; + if (p.hasCheckpoints()) { os << ", color=\"red\", penwidth=2"; } else if (d.hasGen() && d.hasSim()) { @@ -708,12 +752,25 @@ class TruthLogicalGraphDumper : public edm::one::EDAnalyzer<> { // Big, immediately-legible particle name + PDG id as the table's title row // (HTML-like labels cannot mix free text and a TABLE), with the details below. - const std::string bigName = (!p.hasGen() && !d.hasSim()) ? std::string("connector") : pdgLabel(d.pdgId); + const std::string bigName = (d.particleRole() == truth::ParticleRole::Connector) ? std::string("connector") + : (d.particleRole() == truth::ParticleRole::SignalStandIn) + ? std::string("signal stand-in") + : (!p.hasGen() && !d.hasSim()) ? std::string("connector") + : pdgLabel(d.pdgId); os << ", label=<\n"; os << " \n"; os << " \n"; os << " \n"; + // One coloured row per level the particle belongs to. Colours match the legend + // node emitted once per graph, so the levels are readable without a key. + for (const truth::Level level : truth::kAllLevels) { + if (d.isAtLevel(truth::levelFlagOf(level))) { + os << " \n"; + } + } + if (d.pdgId != 0) os << " \n"; @@ -897,8 +954,6 @@ class TruthLogicalGraphDumper : public edm::one::EDAnalyzer<> { if (hideParticle[i]) continue; - unsigned kept = 0; - for (uint32_t v : g.decayVertices(i)) { if (v >= nVertices) continue; @@ -907,9 +962,6 @@ class TruthLogicalGraphDumper : public edm::one::EDAnalyzer<> { continue; os << " p" << i << " -> v" << v << ";\n"; - - if (++kept >= maxEdgesPerNode_) - break; } } @@ -917,8 +969,6 @@ class TruthLogicalGraphDumper : public edm::one::EDAnalyzer<> { if (hideVertex[i]) continue; - unsigned kept = 0; - for (uint32_t p : g.outgoingParticles(i)) { if (p >= nParticles) continue; @@ -927,9 +977,6 @@ class TruthLogicalGraphDumper : public edm::one::EDAnalyzer<> { continue; os << " v" << i << " -> p" << p << ";\n"; - - if (++kept >= maxEdgesPerNode_) - break; } } @@ -991,13 +1038,7 @@ class TruthLogicalGraphDumper : public edm::one::EDAnalyzer<> { std::string dotFile_; std::string layout_; - unsigned maxParticles_; - unsigned maxVertices_; - unsigned maxEdgesPerNode_; - bool hideLargeSimSourceVertices_; bool dumpSimHits_; - unsigned largeSimSourceVertexMinOutgoing_; - bool hideZeroSimHitSubgraphs_; }; DEFINE_FWK_MODULE(TruthLogicalGraphDumper); diff --git a/PhysicsTools/TruthInfo/plugins/TruthLogicalGraphProducer.cc b/PhysicsTools/TruthInfo/plugins/TruthLogicalGraphProducer.cc index 88553178228df..7509d02a3d5f8 100644 --- a/PhysicsTools/TruthInfo/plugins/TruthLogicalGraphProducer.cc +++ b/PhysicsTools/TruthInfo/plugins/TruthLogicalGraphProducer.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // Author: Felice Pantaleo - CERN // Date: 03/2026 @@ -24,6 +22,7 @@ #include #include #include +#include #include #include "FWCore/Framework/interface/Event.h" @@ -39,6 +38,8 @@ #include "DataFormats/Math/interface/LorentzVector.h" +#include "PhysicsTools/TruthInfo/interface/TruthLevels.h" + #include "SimDataFormats/Track/interface/SimTrackContainer.h" #include "SimDataFormats/Vertex/interface/SimVertexContainer.h" #include "SimDataFormats/CaloHit/interface/PCaloHit.h" @@ -62,6 +63,41 @@ namespace { + // Append a standalone particle carrying LevelFlag::Signal, for a sample whose generator + // never wrote the resonance. It has no edges, so the CSR offset arrays only need one + // more entry each, repeating the final offset. Its momentum is the sum of the + // hard-process legs, which is the closest thing to the resonance the record still has. + void addSyntheticSignalNode(truth::Graph& graph) { + truth::ParticleData synthetic; + // The ROLE is the marker. Empty genNode/simNode would not do: connector particles + // have exactly the same empty fields and the same status 0, so inferring "synthetic" + // from them cannot tell a stand-in from a connector. + synthetic.role = static_cast(truth::ParticleRole::SignalStandIn); + synthetic.genNode = -1; + synthetic.simNode = -1; + synthetic.status = 0; + synthetic.pdgId = 0; + // The hard-process legs, computed from levelAntichain directly: LevelFlag::HardProcess + // is not stamped yet at this point, since fillLevelFlags runs only on the finished + // graph. + math::XYZTLorentzVectorD sum(0., 0., 0., 0.); + for (const uint32_t id : truth::levelAntichain(graph, truth::Level::HardProcess)) { + if (id < graph.nParticles()) { + sum += graph.particles()[id].momentum; + } + } + synthetic.momentum = sum; + synthetic.setLevel(truth::LevelFlag::Signal); + + graph.particles().push_back(synthetic); + // A particle with no production and no decay vertex: both offset arrays grow by one + // entry repeating the last, which is what "empty range" means in this CSR layout. + auto& decayOff = graph.particleToDecayVertexOffsets(); + auto& prodOff = graph.particleToProductionVertexOffsets(); + decayOff.push_back(decayOff.empty() ? 0 : decayOff.back()); + prodOff.push_back(prodOff.empty() ? 0 : prodOff.back()); + } + struct DSU { std::vector parent; std::vector rank; @@ -319,6 +355,7 @@ class TruthLogicalGraphProducer : public edm::stream::EDProducer<> { hepmc3Token_(mayConsume(cfg.getParameter("genEventHepMC3"))), hepmc2Token_(mayConsume(cfg.getParameter("genEventHepMC"))), mergeGenSimVertices_(cfg.getParameter("mergeGenSimVertices")), + verbosity_(cfg.getUntrackedParameter("verbosity")), dropHitlessSimSubgraphs_( cfg.getParameter("postProcessing").getParameter("dropHitlessSimSubgraphs")), postProcessor_(truth::TruthLogicalGraphPostProcessor::configFromPSet( @@ -347,6 +384,9 @@ class TruthLogicalGraphProducer : public edm::stream::EDProducer<> { desc.add("genEventHepMC3", edm::InputTag("generatorSmeared")); desc.add("genEventHepMC", edm::InputTag("generatorSmeared")); + desc.addUntracked("verbosity", 0) + ->setComment( + "Above 0, log the particle and vertex counts and the size of each level antichain, once per event"); desc.add("mergeGenSimVertices", true) ->setComment( "If true, merge production GenVertex and SimVertex only for locally one-to-one matches induced by " @@ -974,6 +1014,61 @@ class TruthLogicalGraphProducer : public edm::stream::EDProducer<> { *out = postProcessor_.process(std::move(*out), particleDirectHit); + // Record the seed species the selection ran with, so LevelFlag::Signal stays + // re-derivable by a reader that has only the graph. + out->signalSeedPdgIds() = postProcessor_.config().seedPdgIds; + out->reconstructablePdgIds() = postProcessor_.config().reconstructablePdgIds; + out->seedHadronFlavors() = postProcessor_.config().seedHadronFlavors; + + // If the generator never wrote the resonance, stand one in for it so the signal level + // is answerable for every sample rather than only the resonant ones. Marked + // synthetic: no GEN and no SIM back-reference, and status 0, which no generator + // particle carries. It is an accounting object, not truth, and nothing may read its + // four-momentum as a generator quantity. + // + // The full-graph preset is spelled seedPdgIds = {0}, and that means NO SELECTION, not + // "a resonance the generator failed to write". No real particle carries pdgId 0, so + // nothing can ever match it and the fallback would stand a resonance in on EVERY + // event of every unselected sample. Treat {0} exactly as filterGraphBySelection + // already treats it, as the escape hatch that asks for the whole graph. + if (truth::seedsNameAResonance(out->signalSeedPdgIds(), out->seedHadronFlavors())) { + const bool haveSignal = std::any_of(out->particles().begin(), out->particles().end(), [](auto const& p) { + return p.isAtLevel(truth::LevelFlag::Signal); + }); + if (!haveSignal) { + addSyntheticSignalNode(*out); + edm::LogWarning("TruthLogicalGraphProducer") + << "no particle matched the configured signal seeds; added a synthetic signal node standing for the " + "hard-process legs, pt " + << out->particles().back().momentum.pt() + << ". Its momentum is their sum and is NOT a generator quantity; a pt of exactly 0 means the sample has " + "no hard-process legs either."; + } + } + + // Stamp level membership last, on the finished graph: the antichain reduction walks + // ancestors and descendants, so anything earlier would classify a graph that the + // post-processor is still rewriting. + truth::fillLevelFlags(*out); + + if (verbosity_ > 0) { + std::ostringstream levels; + for (const truth::Level level : truth::kAllLevels) { + const truth::LevelFlag flag = truth::levelFlagOf(level); + std::size_t n = 0; + for (auto const& particle : out->particles()) { + n += particle.isAtLevel(flag) ? 1 : 0; + } + levels << " " << truth::levelName(level) << " " << n << "\n"; + } + edm::LogVerbatim("TruthLogicalGraphProducer") + << "truth graph: " << out->nParticles() << " particles, " << out->nVertices() + << " vertices\nlevel membership (antichain sizes):\n" + << levels.str(); + } + + // Checked on the graph that is actually produced, after the synthetic node and the + // level stamping, so a future mutation between here and the put cannot slip past it. if (!out->isConsistent()) { throw cms::Exception("TruthLogicalGraphProducer") << "Produced truth::Graph is not consistent"; } @@ -991,6 +1086,7 @@ class TruthLogicalGraphProducer : public edm::stream::EDProducer<> { std::vector> trackerSimHitTokens_; bool mergeGenSimVertices_; + unsigned verbosity_ = 0; bool dropHitlessSimSubgraphs_; truth::TruthLogicalGraphPostProcessor postProcessor_; }; diff --git a/PhysicsTools/TruthInfo/python/ST_tch_top_14TeV_TuneCP5_cfi.py b/PhysicsTools/TruthInfo/python/ST_tch_top_14TeV_TuneCP5_cfi.py index 6f5867096f0d9..fce60c56da952 100644 --- a/PhysicsTools/TruthInfo/python/ST_tch_top_14TeV_TuneCP5_cfi.py +++ b/PhysicsTools/TruthInfo/python/ST_tch_top_14TeV_TuneCP5_cfi.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # # t-channel single-top GEN fragment (q q' -> t q'' via t-channel W). There is no # single-top sample in the standard relval matrix, so this minimal fragment is used diff --git a/PhysicsTools/TruthInfo/python/TTto2L2Nu_Powheg_Pythia8_cfi.py b/PhysicsTools/TruthInfo/python/TTto2L2Nu_Powheg_Pythia8_cfi.py index 821d315debf9b..c52fb197804ae 100644 --- a/PhysicsTools/TruthInfo/python/TTto2L2Nu_Powheg_Pythia8_cfi.py +++ b/PhysicsTools/TruthInfo/python/TTto2L2Nu_Powheg_Pythia8_cfi.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # # ttbar -> dilepton (TTto2L2Nu) at NLO with POWHEG (hvq) showered by Pythia8, used # to produce a more realistic ttbar gallery/library example than the LO Pythia8 diff --git a/PhysicsTools/TruthInfo/python/WWTo2L2Nu_14TeV_TuneCP5_cfi.py b/PhysicsTools/TruthInfo/python/WWTo2L2Nu_14TeV_TuneCP5_cfi.py index 1e5b912f24b1b..c43bd3abaf715 100644 --- a/PhysicsTools/TruthInfo/python/WWTo2L2Nu_14TeV_TuneCP5_cfi.py +++ b/PhysicsTools/TruthInfo/python/WWTo2L2Nu_14TeV_TuneCP5_cfi.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # # Diboson WW GEN fragment (q qbar -> W+ W-, both W -> leptons). There is no WW # sample in the standard relval matrix, so this minimal fragment produces a diff --git a/PhysicsTools/TruthInfo/python/ZHToBB_ZToLL_14TeV_TuneCP5_cfi.py b/PhysicsTools/TruthInfo/python/ZHToBB_ZToLL_14TeV_TuneCP5_cfi.py index a3066496a3b56..d0fcfa8966871 100644 --- a/PhysicsTools/TruthInfo/python/ZHToBB_ZToLL_14TeV_TuneCP5_cfi.py +++ b/PhysicsTools/TruthInfo/python/ZHToBB_ZToLL_14TeV_TuneCP5_cfi.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # # Associated-Higgs (VH) GEN fragment: Higgs-strahlung q qbar -> Z H, with H -> b b # and Z -> leptons. There is no VH sample in the standard relval matrix, so this diff --git a/PhysicsTools/TruthInfo/python/customiseTruthMixedReco.py b/PhysicsTools/TruthInfo/python/customiseTruthMixedReco.py index 09fd12b209a56..ff215a71c3a41 100644 --- a/PhysicsTools/TruthInfo/python/customiseTruthMixedReco.py +++ b/PhysicsTools/TruthInfo/python/customiseTruthMixedReco.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). """RECO-step customise for the pileup truth chain in a split production. diff --git a/PhysicsTools/TruthInfo/python/mixedTruthGraphCustomize.py b/PhysicsTools/TruthInfo/python/mixedTruthGraphCustomize.py index dfa2184a5db1b..f60cf01ba355d 100644 --- a/PhysicsTools/TruthInfo/python/mixedTruthGraphCustomize.py +++ b/PhysicsTools/TruthInfo/python/mixedTruthGraphCustomize.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # Phase-A pileup customise: enable the SimTrack/SimVertex crossing frames and run # TruthGraphMixedProducer in the DIGI step (the only place the transient diff --git a/PhysicsTools/TruthInfo/python/truthEventContent_cff.py b/PhysicsTools/TruthInfo/python/truthEventContent_cff.py index 335585f77f490..0afe9c336c453 100644 --- a/PhysicsTools/TruthInfo/python/truthEventContent_cff.py +++ b/PhysicsTools/TruthInfo/python/truthEventContent_cff.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # Event content for the MC-truth graph, with two verbosity levels. # diff --git a/PhysicsTools/TruthInfo/python/truthGraphDQMHarvester_cff.py b/PhysicsTools/TruthInfo/python/truthGraphDQMHarvester_cff.py index 506b112c36bba..1da71ac077520 100644 --- a/PhysicsTools/TruthInfo/python/truthGraphDQMHarvester_cff.py +++ b/PhysicsTools/TruthInfo/python/truthGraphDQMHarvester_cff.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # DQM harvesting for the Branch performance-plot validators: turns the booked # numerator/denominator histograms into reproduction-efficiency plots vs diff --git a/PhysicsTools/TruthInfo/python/truthGraphMixedDigi_cff.py b/PhysicsTools/TruthInfo/python/truthGraphMixedDigi_cff.py index fe945819f8db9..ec942e990342e 100644 --- a/PhysicsTools/TruthInfo/python/truthGraphMixedDigi_cff.py +++ b/PhysicsTools/TruthInfo/python/truthGraphMixedDigi_cff.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # DIGI-step pileup-aware truth, wired under the enableTruth modifier: the # TruthGraphAccumulator (registered in the MixingModule digitizers) builds the diff --git a/PhysicsTools/TruthInfo/python/truthGraphSelections.py b/PhysicsTools/TruthInfo/python/truthGraphSelections.py index 23808534df2be..db77838d8c789 100644 --- a/PhysicsTools/TruthInfo/python/truthGraphSelections.py +++ b/PhysicsTools/TruthInfo/python/truthGraphSelections.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). """Per-process logical-graph selection presets for the enableTruth relval samples. @@ -105,6 +103,12 @@ def _selection(seedPdgIds=(0,), ("gamma", [22]), ("photon", [22]), ("muon", [13, -13]), + # Neutral pion before the charged-pion tokens, or SinglePi0 would seed 211 and + # stamp no Signal. The pi0 seed composes with reconstructablePdgIds (111 by + # default, for every template): the pi0 is Signal and its own reconstructable + # leg, one object rather than two photons, the same rule the generator + # templates follow. + ("pi0", [111]), ("pion", [211, -211]), ("proton", [2212]), ("tau", [15, -15]), @@ -186,6 +190,18 @@ def selectionForFragment(name=None, template=None, **overrides): return selection +def seedPdgIdsForPreset(name=None, template=None, **overrides): + """The preset's seed pdgIds as a plain list, for the associators' signalSeedPdgIds. + + Feeding the associators the SAME values the preset seeds with makes the signalSeeds + product (the _signal efficiency denominator) exactly the preset's signal objects. + [0] is the full-graph escape hatch, not a species: it maps to an empty list, which + the associators read as "this sample names no resonance" and publish empty signal + seed products for.""" + s = selectionForFragment(name=name, template=template, **overrides) + return [p for p in s["seedPdgIds"] if p != 0] + + def postProcessingPSet(name=None, template=None, **overrides): """``selectionForFragment`` wrapped as a complete ``cms.PSet`` (build-side defaults included), ready to drop into a producer's ``postProcessing``.""" @@ -195,6 +211,9 @@ def postProcessingPSet(name=None, template=None, **overrides): return cms.PSet( collapseIntermediateGenParticles=cms.bool(overrides.get("collapseIntermediateGenParticles", True)), seedPdgIds=cms.vint32(*s["seedPdgIds"]), + # Terminates the walk from the signal to its reconstructable products. pi0 by + # default: it decays at once to two photons but is what the analysis reconstructs. + reconstructablePdgIds=cms.vint32(*overrides.get("reconstructablePdgIds", [111])), seedHadronFlavors=cms.vint32(*s["seedHadronFlavors"]), seedParentDepth=cms.uint32(s["seedParentDepth"]), keepStableSpectators=cms.bool(s["keepStableSpectators"]), diff --git a/PhysicsTools/TruthInfo/python/truthGraphValidation_cff.py b/PhysicsTools/TruthInfo/python/truthGraphValidation_cff.py index 6e0459a810208..959c4b809b663 100644 --- a/PhysicsTools/TruthInfo/python/truthGraphValidation_cff.py +++ b/PhysicsTools/TruthInfo/python/truthGraphValidation_cff.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # Branch performance-plot validation: the truth-graph producers, the Branch<->reco # association maps, and the DQM analyzers that turn them into plots comparing the diff --git a/PhysicsTools/TruthInfo/scripts/makeTruthGraphValidationPlots.py b/PhysicsTools/TruthInfo/scripts/makeTruthGraphValidationPlots.py index 9c7fe72239957..e98064745d0e4 100755 --- a/PhysicsTools/TruthInfo/scripts/makeTruthGraphValidationPlots.py +++ b/PhysicsTools/TruthInfo/scripts/makeTruthGraphValidationPlots.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). """Render the truth-Branch DQM validation plots, inspired by Validation/HGCalValidation/scripts/makeHGCalValidationPlots.py but self-contained diff --git a/PhysicsTools/TruthInfo/src/Branch.cc b/PhysicsTools/TruthInfo/src/Branch.cc index a5f9ac041cda4..74e31d0ed329d 100644 --- a/PhysicsTools/TruthInfo/src/Branch.cc +++ b/PhysicsTools/TruthInfo/src/Branch.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include "PhysicsTools/TruthInfo/interface/Branch.h" @@ -9,25 +7,14 @@ #include #include +#include "PhysicsTools/TruthInfo/interface/TruthLevels.h" #include "SimDataFormats/EncodedEventId/interface/EncodedEventId.h" namespace { - bool isNeutrino(int32_t pdgId) { - const int32_t id = std::abs(pdgId); - return id == 12 || id == 14 || id == 16; - } + using truth::isInvisible; - // Ordinary hadron whose quark content includes `flavor` (5 = b, 4 = c). - bool hadronHasQuark(int32_t pdgId, int32_t flavor) { - const int32_t id = std::abs(pdgId); - if (id < 100 || id >= 1000000000) - return false; - const int32_t nq1 = (id / 1000) % 10; - const int32_t nq2 = (id / 100) % 10; - const int32_t nq3 = (id / 10) % 10; - return nq1 == flavor || nq2 == flavor || nq3 == flavor; - } + using truth::hadronHasQuark; // Mirror of TruthGraphProducer::packEventId, which memcpys the EncodedEventId // bytes into the low word of a uint64_t. Decode into a trivial uint32_t and @@ -159,7 +146,7 @@ namespace truth { math::XYZTLorentzVectorD Branch::visibleP4() const { math::XYZTLorentzVectorD sum; for (auto const& leaf : stableLeaves()) { - if (!isNeutrino(leaf.pdgId())) + if (!isInvisible(leaf.pdgId())) sum += leaf.momentum(); } return sum; diff --git a/PhysicsTools/TruthInfo/src/BranchHitAssociator.cc b/PhysicsTools/TruthInfo/src/BranchHitAssociator.cc index b6db055c729d5..ffbed43d3a932 100644 --- a/PhysicsTools/TruthInfo/src/BranchHitAssociator.cc +++ b/PhysicsTools/TruthInfo/src/BranchHitAssociator.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include "PhysicsTools/TruthInfo/interface/BranchHitAssociator.h" diff --git a/PhysicsTools/TruthInfo/src/BranchSelector.cc b/PhysicsTools/TruthInfo/src/BranchSelector.cc index d2fd59bd0abf5..628da1107aa89 100644 --- a/PhysicsTools/TruthInfo/src/BranchSelector.cc +++ b/PhysicsTools/TruthInfo/src/BranchSelector.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include "PhysicsTools/TruthInfo/interface/BranchSelector.h" diff --git a/PhysicsTools/TruthInfo/src/LogicalGraphHitIndexBuilder.cc b/PhysicsTools/TruthInfo/src/LogicalGraphHitIndexBuilder.cc index a1ad687a9dc5a..bcecc4f511b33 100644 --- a/PhysicsTools/TruthInfo/src/LogicalGraphHitIndexBuilder.cc +++ b/PhysicsTools/TruthInfo/src/LogicalGraphHitIndexBuilder.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include "PhysicsTools/TruthInfo/interface/LogicalGraphHitIndexBuilder.h" diff --git a/PhysicsTools/TruthInfo/src/SubgraphHitView.cc b/PhysicsTools/TruthInfo/src/SubgraphHitView.cc index 8a0ff2fbf139b..e9dcb13e67238 100644 --- a/PhysicsTools/TruthInfo/src/SubgraphHitView.cc +++ b/PhysicsTools/TruthInfo/src/SubgraphHitView.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include "PhysicsTools/TruthInfo/interface/SubgraphHitView.h" diff --git a/PhysicsTools/TruthInfo/src/TruthLogicalGraphPostProcessor.cc b/PhysicsTools/TruthInfo/src/TruthLogicalGraphPostProcessor.cc index ae54dae0c9963..ba70166c951cc 100644 --- a/PhysicsTools/TruthInfo/src/TruthLogicalGraphPostProcessor.cc +++ b/PhysicsTools/TruthInfo/src/TruthLogicalGraphPostProcessor.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include "PhysicsTools/TruthInfo/interface/TruthLogicalGraphPostProcessor.h" @@ -16,6 +14,7 @@ #include #include "FWCore/MessageLogger/interface/MessageLogger.h" +#include "PhysicsTools/TruthInfo/interface/TruthLevels.h" #include "SimDataFormats/EncodedEventId/interface/EncodedEventId.h" namespace { @@ -62,17 +61,7 @@ namespace { return std::find(particleIds.begin(), particleIds.end(), particleId) != particleIds.end(); } - // True if pdgId is an ordinary hadron whose quark content includes the given - // flavor (5 = b, 4 = c, ...), using the PDG hadron-numbering digits. - bool hadronHasQuark(int32_t pdgId, int32_t flavor) { - const int32_t id = std::abs(pdgId); - if (id < 100 || id >= 1000000000) // leptons/bosons/diquark-free codes and nuclei are not hadrons here - return false; - const int32_t nq1 = (id / 1000) % 10; - const int32_t nq2 = (id / 100) % 10; - const int32_t nq3 = (id / 10) % 10; - return nq1 == flavor || nq2 == flavor || nq3 == flavor; - } + using truth::hadronHasQuark; bool matchesSeed(truth::Graph const& graph, uint32_t particleId, @@ -878,6 +867,7 @@ namespace { connector.simNode = -1; connector.pdgId = 0; connector.status = 0; + connector.role = static_cast(truth::ParticleRole::Connector); connector.genEvent = genEvent; connector.eventId = eventId; @@ -896,43 +886,47 @@ namespace { return output; } - truth::Graph filterGraphBySelection(truth::Graph const& input, - truth::LogicalGraphPostProcessingConfig const& config) { - if (input.empty()) - return input; - - // Skip empty groups: they would match every vertex. + // Decay groups with the empty ones dropped: an empty group would match every vertex. + std::vector> sortedDecayGroups(truth::LogicalGraphPostProcessingConfig const& config) { std::vector> sortedGroups; sortedGroups.reserve(config.decayPdgIdGroups.size()); - for (auto const& group : config.decayPdgIdGroups) { if (!group.empty()) { sortedGroups.push_back(group); std::sort(sortedGroups.back().begin(), sortedGroups.back().end()); } } + return sortedGroups; + } + // The roots a selection names: the most upstream seed matches, narrowed to those whose + // effective decay matches a configured group, or the direct decay-pattern search when + // the generator wrote no explicit resonance. ONE computation, so the Signal flag and + // the selection cannot disagree about what the signal of the event is. + // signalInteractionOnly keeps only roots that are GEN particles of the signal + // interaction, which is what the Signal flag means. The selection keeps every + // interaction, and the bunch-crossing filter removes pile-up. + // usePatternFallback allows the decay-pattern search. That search returns the particles + // of a matching decay, not the resonance above them. The selection wants those as + // roots. The Signal flag does not, because the flag marks the resonance. + std::vector selectionRoots(truth::Graph const& input, + truth::LogicalGraphPostProcessingConfig const& config, + std::vector> const& sortedGroups, + std::vector& patternVertices, + bool signalInteractionOnly, + bool usePatternFallback) { const bool haveSeeds = !config.seedPdgIds.empty() || !config.seedHadronFlavors.empty(); const bool haveGroups = !sortedGroups.empty(); - - if (!haveSeeds && !haveGroups) - return input; - - // Debug escape hatch: no real particle has PDG id 0, so seedPdgIds = {0} - // explicitly requests the full, unfiltered graph. - if (containsPdgId(config.seedPdgIds, 0)) - return input; - - const uint32_t nParticles = input.nParticles(); - const uint32_t nVertices = input.nVertices(); - std::vector roots; - std::vector patternVertices; if (haveSeeds) { std::vector matches; - - for (uint32_t particleId = 0; particleId < nParticles; ++particleId) { + for (uint32_t particleId = 0; particleId < input.nParticles(); ++particleId) { + if (signalInteractionOnly) { + auto const& particle = input.particles()[particleId]; + if (!particle.hasGen() || particle.eventId != 0) + continue; + } if (matchesSeed(input, particleId, config)) matches.push_back(particleId); } @@ -950,15 +944,51 @@ namespace { }); }); } - } else if (haveGroups) { + } else if (haveGroups && usePatternFallback) { // The generator did not write the requested resonance explicitly: // fall back to the direct decay-pattern search. findDecayPatternMatches(input, sortedGroups, roots, patternVertices); } - } else { + } else if (haveGroups && usePatternFallback) { findDecayPatternMatches(input, sortedGroups, roots, patternVertices); } + if (signalInteractionOnly) { + // The decay-pattern fallback scans every vertex of the event, so it can return a + // pile-up or SIM-only root. The seed path filters its matches before the upstream + // reduction; this filters whatever any path produced. + std::erase_if(roots, [&input](uint32_t root) { + auto const& particle = input.particles()[root]; + return !particle.hasGen() || particle.eventId != 0; + }); + } + return roots; + } + + truth::Graph filterGraphBySelection(truth::Graph const& input, + truth::LogicalGraphPostProcessingConfig const& config) { + if (input.empty()) + return input; + + const std::vector> sortedGroups = sortedDecayGroups(config); + + const bool haveSeeds = !config.seedPdgIds.empty() || !config.seedHadronFlavors.empty(); + const bool haveGroups = !sortedGroups.empty(); + + if (!haveSeeds && !haveGroups) + return input; + + // Debug escape hatch: no real particle has PDG id 0, so seedPdgIds = {0} + // explicitly requests the full, unfiltered graph. + if (containsPdgId(config.seedPdgIds, 0)) + return input; + + const uint32_t nParticles = input.nParticles(); + const uint32_t nVertices = input.nVertices(); + + std::vector patternVertices; + std::vector roots = selectionRoots(input, config, sortedGroups, patternVertices, false, true); + if (roots.empty()) { edm::LogWarning("TruthLogicalGraphPostProcessor") << "Configured truth graph selection (seedPdgIds and/or decayPdgIdGroups) matched nothing in this event; " @@ -1276,6 +1306,13 @@ namespace truth { "GEN skeleton outside removed SIM subtrees is preserved). Requires the producer to supply the " "per-particle sim-hit presence (it consumes the calo/tracker sim-hit collections); a no-op otherwise."); + desc.add>("reconstructablePdgIds", {111}) + ->setComment( + "Species the detector reconstructs as an object even though they decay, which therefore terminate the " + "walk from the signal down to its reconstructable products. Defaults to the pi0: it decays to two " + "photons immediately, but the analysis reconstructs the pi0, so the pi0 is what gets labelled. " + "Intermediate resonances the detector cannot see as objects (a1, rho) are absent on purpose, so the " + "walk passes through them; add a pdg id here to label it instead"); desc.add>("seedPdgIds", {}) ->setComment( "If non-empty, particles with these exact PDG ids seed the selection: the most upstream particle of " @@ -1359,6 +1396,7 @@ namespace truth { config.collapseIntermediateGenParticles = pset.getParameter("collapseIntermediateGenParticles"); config.dropHitlessSimSubgraphs = pset.getParameter("dropHitlessSimSubgraphs"); config.seedPdgIds = pset.getParameter>("seedPdgIds"); + config.reconstructablePdgIds = pset.getParameter>("reconstructablePdgIds"); config.seedHadronFlavors = pset.getParameter>("seedHadronFlavors"); config.seedParentDepth = pset.getParameter("seedParentDepth"); config.keepStableSpectators = pset.getParameter("keepStableSpectators"); @@ -1392,6 +1430,26 @@ namespace truth { // post-collapse indexing. input = filterGraphByBunchCrossing(input, config_); + // Mark the resonance BEFORE the selection rewrite. The members are the most upstream + // particles matching the preset's seed species, so the two tops rather than their + // decay products. The flag sits on the ParticleData, not on an index list, because + // the rewrite renumbers every particle and a flag on the struct survives that. This + // runs after the collapse and the pile-up steps, so it sees the indexing the + // selection sees. + // The same root computation the selection uses, so the flag and the selection agree + // on what the signal is. It applies the decay groups: a Z to mu+ mu- preset does not + // stamp a Z that decayed to e+ e-. It keeps only the signal interaction. It does not + // take the decay-pattern fallback, which returns decay products, not the resonance. + if (seedsNameAResonance(config_.seedPdgIds, config_.seedHadronFlavors)) { + const auto sortedGroups = sortedDecayGroups(config_); + std::vector patternVertices; + for (const uint32_t root : selectionRoots(input, config_, sortedGroups, patternVertices, true, false)) { + if (root < input.particles().size()) { + input.particles()[root].setLevel(truth::LevelFlag::Signal); + } + } + } + input = filterGraphBySelection(input, config_); if (!config_.ignoredPdgIds.empty() || !config_.ignoredParticleIds.empty()) { diff --git a/PhysicsTools/TruthInfo/test/BranchHitAssociator_t.cpp b/PhysicsTools/TruthInfo/test/BranchHitAssociator_t.cpp index 81cb450babb60..b5db3990171b1 100644 --- a/PhysicsTools/TruthInfo/test/BranchHitAssociator_t.cpp +++ b/PhysicsTools/TruthInfo/test/BranchHitAssociator_t.cpp @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include "Utilities/Testing/interface/CppUnit_testdriver.icpp" #include "cppunit/extensions/HelperMacros.h" diff --git a/PhysicsTools/TruthInfo/test/BranchSelector_t.cpp b/PhysicsTools/TruthInfo/test/BranchSelector_t.cpp index 32a4c4d833879..d2cdfaecc8898 100644 --- a/PhysicsTools/TruthInfo/test/BranchSelector_t.cpp +++ b/PhysicsTools/TruthInfo/test/BranchSelector_t.cpp @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include "Utilities/Testing/interface/CppUnit_testdriver.icpp" #include "cppunit/extensions/HelperMacros.h" diff --git a/PhysicsTools/TruthInfo/test/Branch_t.cpp b/PhysicsTools/TruthInfo/test/Branch_t.cpp index 899c556dac11c..20553b50011fb 100644 --- a/PhysicsTools/TruthInfo/test/Branch_t.cpp +++ b/PhysicsTools/TruthInfo/test/Branch_t.cpp @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include "Utilities/Testing/interface/CppUnit_testdriver.icpp" #include "cppunit/extensions/HelperMacros.h" diff --git a/PhysicsTools/TruthInfo/test/BuildFile.xml b/PhysicsTools/TruthInfo/test/BuildFile.xml index dd676a04d98c1..1a0091ec82d85 100644 --- a/PhysicsTools/TruthInfo/test/BuildFile.xml +++ b/PhysicsTools/TruthInfo/test/BuildFile.xml @@ -29,6 +29,12 @@ + + + + + + diff --git a/PhysicsTools/TruthInfo/test/GenGraphBuild_t.cpp b/PhysicsTools/TruthInfo/test/GenGraphBuild_t.cpp index 3a670d707de19..3b6036d2300a6 100644 --- a/PhysicsTools/TruthInfo/test/GenGraphBuild_t.cpp +++ b/PhysicsTools/TruthInfo/test/GenGraphBuild_t.cpp @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include "Utilities/Testing/interface/CppUnit_testdriver.icpp" #include "cppunit/extensions/HelperMacros.h" diff --git a/PhysicsTools/TruthInfo/test/LevelFlags_t.cpp b/PhysicsTools/TruthInfo/test/LevelFlags_t.cpp new file mode 100644 index 0000000000000..96113df61c576 --- /dev/null +++ b/PhysicsTools/TruthInfo/test/LevelFlags_t.cpp @@ -0,0 +1,781 @@ +// Original author: Felice Pantaleo (CERN) + +#include "Utilities/Testing/interface/CppUnit_testdriver.icpp" +#include "cppunit/extensions/HelperMacros.h" + +#include +#include +#include +#include +#include +#include + +#include "FWCore/Utilities/interface/Exception.h" +#include "PhysicsTools/TruthInfo/interface/TruthLevels.h" +#include "SimDataFormats/TruthInfo/interface/Graph.h" + +namespace { + + // Minimal CSR graph builder, the same one the other tests in this package use. + struct GraphBuilder { + explicit GraphBuilder(uint32_t nParticles, uint32_t nVertices) { + graph.particles().resize(nParticles); + graph.vertices().resize(nVertices); + // Vertices are GEN by default, as the decay vertices of GEN particles are in a + // production graph; a test exercising SIM continuation overrides genNode to -1. + for (uint32_t v = 0; v < nVertices; ++v) { + graph.vertices()[v].genNode = 200 + v; + } + } + void addDecay(uint32_t particleId, uint32_t vertexId) { + d2v.emplace_back(particleId, vertexId); + v2i.emplace_back(vertexId, particleId); + } + void addProduction(uint32_t vertexId, uint32_t particleId) { + v2o.emplace_back(vertexId, particleId); + p2v.emplace_back(particleId, vertexId); + } + static void csr(uint32_t n, + std::vector>& pairs, + std::vector& off, + std::vector& flat) { + std::sort(pairs.begin(), pairs.end()); + pairs.erase(std::unique(pairs.begin(), pairs.end()), pairs.end()); + off.assign(n + 1, 0); + for (auto const& pr : pairs) + ++off[pr.first + 1]; + for (uint32_t i = 1; i <= n; ++i) + off[i] += off[i - 1]; + flat.assign(pairs.size(), 0); + auto cur = off; + for (auto const& pr : pairs) + flat[cur[pr.first]++] = pr.second; + } + truth::Graph finish() { + csr(graph.nParticles(), d2v, graph.particleToDecayVertexOffsets(), graph.particleToDecayVertices()); + csr(graph.nParticles(), p2v, graph.particleToProductionVertexOffsets(), graph.particleToProductionVertices()); + csr(graph.nVertices(), v2o, graph.vertexToOutgoingParticleOffsets(), graph.vertexToOutgoingParticles()); + csr(graph.nVertices(), v2i, graph.vertexToIncomingParticleOffsets(), graph.vertexToIncomingParticles()); + CPPUNIT_ASSERT(graph.isConsistent()); + return graph; + } + truth::Graph graph; + std::vector> d2v, p2v, v2o, v2i; + }; + + // A tau decaying to a pion and a neutrino, the pion reaching the calorimeter. + // p0 tau, isHardProcess, decays at v0 + // p1 pi+, status 1, records a calorimeter boundary crossing + // p2 nu, status 1, no crossing + truth::Graph buildDecay() { + GraphBuilder b(3, 1); + + auto& tau = b.graph.particles()[0]; + tau.genNode = 100; + tau.pdgId = 15; + tau.status = 2; + tau.statusFlags = truth::detail::kIsHardProcess; + tau.momentum = math::XYZTLorentzVectorD(50., 0., 0., 60.); + + auto& pion = b.graph.particles()[1]; + pion.genNode = 101; + pion.simNode = 201; + pion.pdgId = 211; + pion.status = 1; + pion.momentum = math::XYZTLorentzVectorD(30., 0., 0., 35.); + pion.checkpoints.push_back(truth::Checkpoint{}); + + auto& nu = b.graph.particles()[2]; + nu.genNode = 102; + nu.pdgId = 16; + nu.status = 1; + nu.momentum = math::XYZTLorentzVectorD(5., 0., 0., 5.); + + b.addDecay(0, 0); + b.addProduction(0, 1); + b.addProduction(0, 2); + return b.finish(); + } + + // A semileptonic top decay chain, the shape the parton jet level has to get right. + // p0 g, incoming beam parton at beam rapidity, decays at v2 + // p1 t, decays at v0 + // p2 b, outgoing leg, no children + // p3 W+, decays at v1 + // p4 u, outgoing leg, no children + // p5 dbar, outgoing leg, no children + // Every one of them carries isHardProcess, which is what makes this the discriminating + // fixture: the level has to separate them on ancestry and species, not on the flag. + truth::Graph buildTopDecay() { + GraphBuilder b(6, 3); + + auto set = [&](uint32_t id, int32_t pdgId, int16_t status, math::XYZTLorentzVectorD p4) { + auto& particle = b.graph.particles()[id]; + particle.genNode = 100 + static_cast(id); + particle.pdgId = pdgId; + particle.status = status; + particle.statusFlags = truth::detail::kIsHardProcess; + particle.momentum = p4; + }; + + set(0, 21, 21, math::XYZTLorentzVectorD(0., 0., 857., 857.)); + set(1, 6, 22, math::XYZTLorentzVectorD(60., 20., 30., 190.)); + set(2, 5, 23, math::XYZTLorentzVectorD(10., 1., 18., 21.)); + set(3, 24, 22, math::XYZTLorentzVectorD(50., 19., 12., 100.)); + set(4, 2, 23, math::XYZTLorentzVectorD(-34., -6., -150., 154.)); + set(5, -1, 23, math::XYZTLorentzVectorD(-30., 70., -139., 159.)); + + b.addDecay(0, 2); + b.addProduction(2, 1); + b.addDecay(1, 0); + b.addProduction(0, 2); + b.addProduction(0, 3); + b.addDecay(3, 1); + b.addProduction(1, 4); + b.addProduction(1, 5); + return b.finish(); + } + + // A b quark fragmenting to a B* that radiates to a B, which decays to a D, which decays + // to a kaon and a pion. Both the generator-copy nesting (B* above B) and the + // cross-flavour nesting (B above D) are present, since those are the two things the + // heavy-flavour levels have to get right. + // p0 b -> v0 -> p1 B*+ -> v1 -> p2 B+ -> v2 -> p3 D0bar -> v3 -> p4 K+, p5 pi- + truth::Graph buildHeavyFlavour() { + GraphBuilder b(6, 4); + + auto set = [&](uint32_t id, int32_t pdgId, int16_t status) { + auto& particle = b.graph.particles()[id]; + particle.genNode = 300 + static_cast(id); + particle.pdgId = pdgId; + particle.status = status; + particle.momentum = math::XYZTLorentzVectorD(10., 2., 5., 12.); + }; + + set(0, 5, 23); + b.graph.particles()[0].statusFlags = truth::detail::kIsHardProcess; + set(1, 523, 2); + set(2, 521, 2); + set(3, -421, 2); + set(4, 321, 1); + set(5, -211, 1); + + b.addDecay(0, 0); + b.addProduction(0, 1); + b.addDecay(1, 1); + b.addProduction(1, 2); + b.addDecay(2, 2); + b.addProduction(2, 3); + b.addDecay(3, 3); + b.addProduction(3, 4); + b.addProduction(3, 5); + return b.finish(); + } + + // A radiating tau chain ending hadronically, a leptonic tau, and a pi0 with its two + // photons, all GEN, no Signal flag anywhere. Ids: + // 0 tau (radiates) 1 tau (last copy, hadronic) 2 photon (radiated) + // 3 pi+ 4 nu_tau 5 tau (leptonic) 6 mu 7 nu_tau 8 nu_mu + // 9 pi0 10 photon 11 photon + truth::Graph buildTausAndPi0() { + GraphBuilder b(12, 6); + auto set = [&](uint32_t id, int32_t pdgId, int16_t status) { + auto& particle = b.graph.particles()[id]; + particle.genNode = 400 + static_cast(id); + particle.pdgId = pdgId; + particle.status = status; + particle.momentum = math::XYZTLorentzVectorD(10., 1., 3., 11.); + }; + set(0, 15, 2); + set(1, 15, 2); + set(2, 22, 1); + set(3, 211, 1); + set(4, 16, 1); + set(5, -15, 2); + set(6, -13, 1); + set(7, 16, 1); + set(8, -14, 1); + set(9, 111, 2); + set(10, 22, 1); + set(11, 22, 1); + + b.addDecay(0, 0); + b.addProduction(0, 1); + b.addProduction(0, 2); + b.addDecay(1, 1); + b.addProduction(1, 3); + b.addProduction(1, 4); + b.addDecay(5, 2); + b.addProduction(2, 6); + b.addProduction(2, 7); + b.addProduction(2, 8); + b.addDecay(9, 3); + b.addProduction(3, 10); + b.addProduction(3, 11); + return b.finish(); + } + +} // namespace + +class LevelFlags_t : public CppUnit::TestFixture { + CPPUNIT_TEST_SUITE(LevelFlags_t); + CPPUNIT_TEST(testFitsInThePaddingHole); + CPPUNIT_TEST(testFlagsMatchTheAntichain); + CPPUNIT_TEST(testIdempotent); + CPPUNIT_TEST(testSignalSurvivesFillLevelFlags); + CPPUNIT_TEST(testSyntheticSignalNodeIsMarked); + CPPUNIT_TEST(testConnectorIsNotAStandIn); + CPPUNIT_TEST(testReconstructableFromSignalDropsNeutrinos); + CPPUNIT_TEST(testPi0IsLabelledNotItsPhotons); + CPPUNIT_TEST(testGunSeedThatIsItselfReconstructableIsItsOwnLeg); + CPPUNIT_TEST(testSimContinuationIsNotADecay); + CPPUNIT_TEST(testReconvergentHistoryStaysAnAntichain); + CPPUNIT_TEST(testStableLegsFromUpstreamAndUnderlyingEvent); + CPPUNIT_TEST(testDiquarksAreNotHeavyFlavourHadrons); + CPPUNIT_TEST(testThreeProngThroughAnIntermediateResonance); + CPPUNIT_TEST(testPartonJetsKeepTheQuarksNotTheTopOrTheBeam); + CPPUNIT_TEST(testPartonJetsExcludeLeptons); + CPPUNIT_TEST(testHeavyFlavourKeepsTheWeaklyDecayingHadron); + CPPUNIT_TEST(testVisibleTauIsTheLastHadronicCopy); + CPPUNIT_TEST(testReconstructableFinalStateNeedsNoSignal); + CPPUNIT_TEST(testBeautyAndCharmAreSeparateLevels); + CPPUNIT_TEST(testEmptyGraphStampsNothing); + CPPUNIT_TEST(testLevelTableIsTheSingleSource); + CPPUNIT_TEST_SUITE_END(); + +public: + // REQUIRED: the flags word occupies the alignment hole between genEvent and momentum, + // so carrying it costs no memory. It is not free ON DISK, where ROOT streams members + // and not padding. A change that grows ParticleData past 96 bytes has moved it out of + // the hole and needs to be justified, not absorbed silently. + void testFitsInThePaddingHole() { + CPPUNIT_ASSERT_EQUAL(std::size_t{96}, sizeof(truth::ParticleData)); + // sizeof alone does not pin the members down: six bytes of tail padding follow + // role, so widening role or dropping a member keeps sizeof at 96. Pin the two + // offsets that carry the claim: levelFlags fills the hole after genEvent, and role + // sits in the tail after backscattered. + truth::ParticleData d; + auto const* base = reinterpret_cast(&d); + CPPUNIT_ASSERT_EQUAL(std::ptrdiff_t{28}, reinterpret_cast(&d.levelFlags) - base); + CPPUNIT_ASSERT_EQUAL(std::ptrdiff_t{89}, reinterpret_cast(&d.role) - base); + } + + // REQUIRED: a stored flag says exactly what levelAntichain() would say. This is the + // defence against a graph written before a level definition changed, which is + // indistinguishable from a fresh one by inspection. + void testFlagsMatchTheAntichain() { + truth::Graph g = buildDecay(); + truth::fillLevelFlags(g); + + for (const truth::Level level : truth::kAllLevels) { + std::vector expected(g.nParticles(), false); + for (const uint32_t id : truth::levelAntichain(g, level)) { + expected[id] = true; + } + const truth::LevelFlag flag = truth::levelFlagOf(level); + for (uint32_t id = 0; id < g.nParticles(); ++id) { + CPPUNIT_ASSERT_EQUAL(static_cast(expected[id]), g.particles()[id].isAtLevel(flag)); + } + } + + // The physics the sample encodes, so a passing test means the right thing and not + // merely a self-consistent one: the tau is the hard process, the pion and the + // neutrino are the stable decay products, only the pion reaches the calorimeter. + CPPUNIT_ASSERT(g.particles()[0].isAtLevel(truth::LevelFlag::HardProcess)); + CPPUNIT_ASSERT(!g.particles()[1].isAtLevel(truth::LevelFlag::HardProcess)); + CPPUNIT_ASSERT(g.particles()[1].isAtLevel(truth::LevelFlag::StableDecayProducts)); + CPPUNIT_ASSERT(g.particles()[2].isAtLevel(truth::LevelFlag::StableDecayProducts)); + CPPUNIT_ASSERT(g.particles()[1].isAtLevel(truth::LevelFlag::CaloBoundary)); + CPPUNIT_ASSERT(!g.particles()[2].isAtLevel(truth::LevelFlag::CaloBoundary)); + } + + // REQUIRED: filling twice leaves the same answer as filling once, so a graph that + // passes through the stamp again cannot accumulate membership it no longer has. + void testIdempotent() { + truth::Graph g = buildDecay(); + truth::fillLevelFlags(g); + std::vector once; + for (auto const& p : g.particles()) { + once.push_back(p.levelFlags); + } + truth::fillLevelFlags(g); + for (uint32_t id = 0; id < g.nParticles(); ++id) { + CPPUNIT_ASSERT_EQUAL(once[id], g.particles()[id].levelFlags); + } + } + + // REQUIRED: Signal is set by the selection post-processing, not by fillLevelFlags, so + // fillLevelFlags must clear only its own four bits. Clearing everything would erase the + // resonance and nothing downstream would notice. + void testSignalSurvivesFillLevelFlags() { + truth::Graph g = buildDecay(); + g.particles()[0].setLevel(truth::LevelFlag::Signal); + truth::fillLevelFlags(g); + CPPUNIT_ASSERT(g.particles()[0].isAtLevel(truth::LevelFlag::Signal)); + // and the levels it does own are still correct alongside it + CPPUNIT_ASSERT(g.particles()[0].isAtLevel(truth::LevelFlag::HardProcess)); + CPPUNIT_ASSERT(!g.particles()[1].isAtLevel(truth::LevelFlag::Signal)); + } + + // REQUIRED: the synthetic stand-in is distinguishable from every real particle, since + // nothing may read its momentum as a generator quantity. No GEN, no SIM, status 0. + // This path fires on no sample in the current set, so this test is the only thing + // exercising it. + void testSyntheticSignalNodeIsMarked() { + truth::Graph g = buildDecay(); + const uint32_t before = g.nParticles(); + + truth::ParticleData synthetic; + synthetic.role = static_cast(truth::ParticleRole::SignalStandIn); + synthetic.genNode = -1; + synthetic.simNode = -1; + synthetic.status = 0; + synthetic.setLevel(truth::LevelFlag::Signal); + g.particles().push_back(synthetic); + g.particleToDecayVertexOffsets().push_back(g.particleToDecayVertexOffsets().back()); + g.particleToProductionVertexOffsets().push_back(g.particleToProductionVertexOffsets().back()); + + CPPUNIT_ASSERT_EQUAL(before + 1, g.nParticles()); + CPPUNIT_ASSERT(g.isConsistent()); + auto const& s = g.particles()[before]; + CPPUNIT_ASSERT(s.isAtLevel(truth::LevelFlag::Signal)); + CPPUNIT_ASSERT(s.isSynthetic()); + CPPUNIT_ASSERT(truth::ParticleRole::SignalStandIn == s.particleRole()); + // Every real particle in the fixture is distinguishable from it. + for (uint32_t id = 0; id < before; ++id) { + CPPUNIT_ASSERT(!g.particles()[id].isSynthetic()); + } + // fillLevelFlags must leave a standalone synthetic node alone apart from its own bits. + truth::fillLevelFlags(g); + CPPUNIT_ASSERT(g.particles()[before].isAtLevel(truth::LevelFlag::Signal)); + } + + // REQUIRED: a connector and a signal stand-in must be distinguishable. Both are + // synthetic and both carry genNode = simNode = -1, pdgId 0 and status 0, so any test + // that infers the kind from those empty fields cannot tell them apart. That is exactly + // the bug this case exists to prevent. + void testConnectorIsNotAStandIn() { + truth::ParticleData connector; + connector.genNode = -1; + connector.simNode = -1; + connector.pdgId = 0; + connector.status = 0; + connector.role = static_cast(truth::ParticleRole::Connector); + + truth::ParticleData standIn; + standIn.genNode = -1; + standIn.simNode = -1; + standIn.pdgId = 0; + standIn.status = 0; + standIn.role = static_cast(truth::ParticleRole::SignalStandIn); + + // Indistinguishable on the fields alone, which is the point. + CPPUNIT_ASSERT_EQUAL(connector.genNode, standIn.genNode); + CPPUNIT_ASSERT_EQUAL(connector.simNode, standIn.simNode); + CPPUNIT_ASSERT_EQUAL(connector.status, standIn.status); + // Distinguishable on the role, which is why the role exists. + CPPUNIT_ASSERT(connector.isSynthetic()); + CPPUNIT_ASSERT(standIn.isSynthetic()); + CPPUNIT_ASSERT(connector.role != standIn.role); + + truth::ParticleData real; + real.genNode = 7; + CPPUNIT_ASSERT(!real.isSynthetic()); + } + + // REQUIRED: from a signal root, the first GEN-stable descendants, with neutrinos + // dropped because they cannot be reconstructed. The fixture tau decays to a pion and a + // neutrino, so the visible final state is the pion alone. Empty when nothing is Signal. + void testReconstructableFromSignalDropsNeutrinos() { + truth::Graph g = buildDecay(); + + // No Signal flag anywhere: the level is empty, not the whole event. + CPPUNIT_ASSERT(truth::reconstructableFromSignal(g).empty()); + + g.particles()[0].setLevel(truth::LevelFlag::Signal); + truth::fillLevelFlags(g); + + const auto legs = truth::reconstructableFromSignal(g); + CPPUNIT_ASSERT_EQUAL(std::size_t{1}, legs.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t{1}, legs[0]); // the pion + CPPUNIT_ASSERT(g.particles()[1].isAtLevel(truth::LevelFlag::ReconstructableFromSignal)); + // the neutrino is stable but invisible, so it is not a leg + CPPUNIT_ASSERT(!g.particles()[2].isAtLevel(truth::LevelFlag::ReconstructableFromSignal)); + // the tau decayed, so it is not its own leg + CPPUNIT_ASSERT(!g.particles()[0].isAtLevel(truth::LevelFlag::ReconstructableFromSignal)); + } + + // REQUIRED: a pi0 decays to two photons at once, but it is the pi0 the analysis + // reconstructs, so the pi0 is labelled and its photons are not. + // p0 tau (signal) -> v0 -> p1 pi0 -> v1 -> p2 gamma, p3 gamma + void testPi0IsLabelledNotItsPhotons() { + GraphBuilder b(4, 2); + auto set = [&](uint32_t i, int32_t pdg, int16_t st) { + auto& d = b.graph.particles()[i]; + d.genNode = 100 + i; + d.pdgId = pdg; + d.status = st; + d.momentum = math::XYZTLorentzVectorD(10., 0., 0., 10.); + }; + set(0, 15, 2); // tau, decays + set(1, 111, 2); // pi0, decays, but reconstructable as an object + set(2, 22, 1); // gamma + set(3, 22, 1); // gamma + b.addDecay(0, 0); + b.addProduction(0, 1); + b.addDecay(1, 1); + b.addProduction(1, 2); + b.addProduction(1, 3); + truth::Graph g = b.finish(); + g.reconstructablePdgIds() = {111}; + g.particles()[0].setLevel(truth::LevelFlag::Signal); + truth::fillLevelFlags(g); + + const auto legs = truth::reconstructableFromSignal(g); + CPPUNIT_ASSERT_EQUAL(std::size_t{1}, legs.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t{1}, legs[0]); + CPPUNIT_ASSERT(g.particles()[1].isAtLevel(truth::LevelFlag::ReconstructableFromSignal)); + CPPUNIT_ASSERT(!g.particles()[2].isAtLevel(truth::LevelFlag::ReconstructableFromSignal)); + CPPUNIT_ASSERT(!g.particles()[3].isAtLevel(truth::LevelFlag::ReconstructableFromSignal)); + + // With the pi0 NOT declared reconstructable the walk goes through it to the photons, + // which is the control showing the configuration is what decides, not the code. + g.reconstructablePdgIds().clear(); + const auto through = truth::reconstructableFromSignal(g); + CPPUNIT_ASSERT_EQUAL(std::size_t{2}, through.size()); + } + + // The gun shape: the Signal root IS the reconstructable species (a pi0 gun seeds + // 111). The walk terminates on the root itself, so the gun particle is one object + // and its photons are never labelled, the same rule a pi0 inside a tau decay gets. + void testGunSeedThatIsItselfReconstructableIsItsOwnLeg() { + GraphBuilder b(3, 1); + auto set = [&](uint32_t i, int32_t pdg, int16_t st) { + auto& d = b.graph.particles()[i]; + d.genNode = 100 + i; + d.pdgId = pdg; + d.status = st; + d.momentum = math::XYZTLorentzVectorD(10., 0., 0., 10.); + }; + set(0, 111, 2); // the gun pi0, decays + set(1, 22, 1); // gamma + set(2, 22, 1); // gamma + b.addDecay(0, 0); + b.addProduction(0, 1); + b.addProduction(0, 2); + truth::Graph g = b.finish(); + g.reconstructablePdgIds() = {111}; + g.particles()[0].setLevel(truth::LevelFlag::Signal); + truth::fillLevelFlags(g); + + const auto legs = truth::reconstructableFromSignal(g); + CPPUNIT_ASSERT_EQUAL(std::size_t{1}, legs.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t{0}, legs[0]); + CPPUNIT_ASSERT(g.particles()[0].isAtLevel(truth::LevelFlag::ReconstructableFromSignal)); + CPPUNIT_ASSERT(!g.particles()[1].isAtLevel(truth::LevelFlag::ReconstructableFromSignal)); + CPPUNIT_ASSERT(!g.particles()[2].isAtLevel(truth::LevelFlag::ReconstructableFromSignal)); + } + + // The level table is what every lookup is derived from, so a row added to it must + // carry its name, its bit and its place in the owned mask without a second edit. + void testLevelTableIsTheSingleSource() { + CPPUNIT_ASSERT_EQUAL(truth::kLevelTable.size(), truth::kAllLevels.size()); + uint32_t mask = 0; + for (auto const& row : truth::kLevelTable) { + // name and flag round trip through the lookups + CPPUNIT_ASSERT(row.level == truth::levelFromName(truth::levelName(row.level))); + CPPUNIT_ASSERT(row.flag == truth::levelFlagOf(row.level)); + CPPUNIT_ASSERT(std::string(row.name) == std::string(truth::levelName(row.level))); + // every bit is distinct + CPPUNIT_ASSERT_EQUAL(uint32_t{0}, mask & static_cast(row.flag)); + mask |= static_cast(row.flag); + } + // the mask fillLevelFlags clears is exactly the table, and never the Signal bit + CPPUNIT_ASSERT_EQUAL(mask, truth::kOwnedLevelFlags); + CPPUNIT_ASSERT_EQUAL(uint32_t{0}, truth::kOwnedLevelFlags & static_cast(truth::LevelFlag::Signal)); + CPPUNIT_ASSERT_THROW(std::ignore = truth::levelFromName("noSuchLevel"), cms::Exception); + } + + // A re-convergent history: the tau gives a pi0 and a photon, and the pi0 gives the + // same photon. The walk stops at the pi0 on one path and still reaches the photon on + // the other, so the level must drop the photon. A level holding both would put a + // particle and its own parent in one denominator. + void testReconvergentHistoryStaysAnAntichain() { + GraphBuilder b(3, 2); + auto set = [&](uint32_t i, int32_t pdg, int16_t st) { + auto& d = b.graph.particles()[i]; + d.genNode = 100 + int32_t(i); + d.pdgId = pdg; + d.status = st; + d.momentum = math::XYZTLorentzVectorD(10., 0., 0., 10.); + }; + set(0, 15, 2); // tau + set(1, 111, 2); // pi0, reconstructable, so the walk stops here + set(2, 22, 1); // photon, reached from the tau AND from the pi0 + b.addDecay(0, 0); + b.addProduction(0, 1); + b.addProduction(0, 2); + b.addDecay(1, 1); + b.addProduction(1, 2); + truth::Graph g = b.finish(); + g.reconstructablePdgIds() = {111}; + g.particles()[0].setLevel(truth::LevelFlag::Signal); + + const auto legs = truth::levelAntichain(g, truth::Level::ReconstructableFromSignal); + CPPUNIT_ASSERT_EQUAL(std::size_t{1}, legs.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t{1}, legs[0]); + } + + // The two levels that hang off the artificial source vertices: Upstream collects the + // ISR side, UnderlyingEvent the spectators, and each keeps the legs that produced + // nothing further. A leg's own role vertex decides which level it lands in. + void testStableLegsFromUpstreamAndUnderlyingEvent() { + GraphBuilder b(4, 2); + auto set = [&](uint32_t i, int32_t pdg, int16_t st) { + auto& d = b.graph.particles()[i]; + d.genNode = 100 + int32_t(i); + d.pdgId = pdg; + d.status = st; + d.momentum = math::XYZTLorentzVectorD(5., 0., 0., 5.); + }; + set(0, 22, 1); // ISR photon off the Upstream vertex + set(1, 211, 1); // spectator off the UnderlyingEvent vertex + set(2, 111, 2); // spectator that decays, so it is not a leg + set(3, 22, 1); // its daughter, which is + b.graph.vertices()[0].role = static_cast(truth::VertexRole::Upstream); + b.graph.vertices()[1].role = static_cast(truth::VertexRole::UnderlyingEvent); + b.addProduction(0, 0); + b.addProduction(1, 1); + b.addProduction(1, 2); + truth::Graph g = b.finish(); + + const auto upstream = truth::levelAntichain(g, truth::Level::StableLegsFromUpstream); + CPPUNIT_ASSERT_EQUAL(std::size_t{1}, upstream.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t{0}, upstream[0]); + + const auto ue = truth::levelAntichain(g, truth::Level::UnderlyingEvent); + CPPUNIT_ASSERT_EQUAL(std::size_t{2}, ue.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t{1}, ue[0]); + CPPUNIT_ASSERT_EQUAL(uint32_t{2}, ue[1]); + } + + // A diquark carries the flavour digits of the hadron it fragments into and is that + // hadron's ancestor, so accepting it would cover and drop the real hadron. + void testDiquarksAreNotHeavyFlavourHadrons() { + CPPUNIT_ASSERT(!truth::hadronHasQuark(5101, 5)); + CPPUNIT_ASSERT(!truth::hadronHasQuark(5103, 5)); + CPPUNIT_ASSERT(!truth::hadronHasQuark(4101, 4)); + CPPUNIT_ASSERT(!truth::hadronHasQuark(5503, 5)); + CPPUNIT_ASSERT(truth::hadronHasQuark(521, 5)); + CPPUNIT_ASSERT(truth::hadronHasQuark(5122, 5)); + CPPUNIT_ASSERT(truth::hadronHasQuark(421, 4)); + CPPUNIT_ASSERT(truth::hadronHasQuark(4122, 4)); + } + + // Every level answers on a graph with no particles, and stamping one is a no-op. + void testEmptyGraphStampsNothing() { + truth::Graph g; + for (const truth::Level level : truth::kAllLevels) { + CPPUNIT_ASSERT(truth::levelAntichain(g, level).empty()); + } + truth::fillLevelFlags(g); + CPPUNIT_ASSERT_EQUAL(uint32_t{0}, g.nParticles()); + } + + // A SIM continuation is transport, not decay. The TenTau topology that exposed it: + // tau -> K0S, where the generator decays the K0S to two pions AND Geant4 interacts + // it in material, producing neutrons at a SIM vertex. The visible final state is + // the GEN pions; the nuclear secondaries must never be labelled. + void testSimContinuationIsNotADecay() { + GraphBuilder b(6, 3); + auto set = [&](uint32_t i, int32_t pdg, int16_t st, bool gen) { + auto& d = b.graph.particles()[i]; + d.genNode = gen ? 100 + int32_t(i) : -1; + d.simNode = gen ? -1 : 500 + int32_t(i); + d.pdgId = pdg; + d.status = st; + d.momentum = math::XYZTLorentzVectorD(10., 0., 0., 10.); + }; + set(0, 15, 2, true); // tau, decays + set(1, 310, 2, true); // K0S, GEN-decayed AND SIM-interacted + set(2, 211, 1, true); // pi+ + set(3, -211, 1, true); // pi- + set(4, 2112, 0, false); // SIM neutron from the nuclear interaction + set(5, 2112, 0, false); // SIM neutron + b.addDecay(0, 0); // tau -> K0S (GEN vertex) + b.addProduction(0, 1); + b.addDecay(1, 1); // K0S -> pi pi (GEN vertex) + b.addProduction(1, 2); + b.addProduction(1, 3); + b.addDecay(1, 2); // K0S SIM interaction vertex + b.addProduction(2, 4); + b.addProduction(2, 5); + truth::Graph g = b.finish(); + g.vertices()[2].genNode = -1; // the interaction vertex is SIM-only + g.vertices()[2].simNode = 900; + g.reconstructablePdgIds() = {111}; + g.particles()[0].setLevel(truth::LevelFlag::Signal); + + const auto legs = truth::reconstructableFromSignal(g); + CPPUNIT_ASSERT_EQUAL(std::size_t{2}, legs.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t{2}, legs[0]); + CPPUNIT_ASSERT_EQUAL(uint32_t{3}, legs[1]); + } + + // REQUIRED: a three-prong tau decay labels the three charged pions, and the + // intermediate resonance they came from is walked THROUGH, never labelled. + // p0 tau (signal) -> v0 -> p1 a1 -> v1 -> p2,p3,p4 pi+/- + void testThreeProngThroughAnIntermediateResonance() { + GraphBuilder b(5, 2); + auto set = [&](uint32_t i, int32_t pdg, int16_t st) { + auto& d = b.graph.particles()[i]; + d.genNode = 100 + i; + d.pdgId = pdg; + d.status = st; + d.momentum = math::XYZTLorentzVectorD(10., 0., 0., 10.); + }; + set(0, 15, 2); // tau + set(1, 20213, 2); // a1, an intermediate the detector never sees as an object + set(2, 211, 1); + set(3, -211, 1); + set(4, 211, 1); + b.addDecay(0, 0); + b.addProduction(0, 1); + b.addDecay(1, 1); + b.addProduction(1, 2); + b.addProduction(1, 3); + b.addProduction(1, 4); + truth::Graph g = b.finish(); + g.reconstructablePdgIds() = {111}; + g.particles()[0].setLevel(truth::LevelFlag::Signal); + truth::fillLevelFlags(g); + + const auto legs = truth::reconstructableFromSignal(g); + CPPUNIT_ASSERT_EQUAL(std::size_t{3}, legs.size()); + CPPUNIT_ASSERT(!g.particles()[1].isAtLevel(truth::LevelFlag::ReconstructableFromSignal)); + for (uint32_t id : {2u, 3u, 4u}) { + CPPUNIT_ASSERT(g.particles()[id].isAtLevel(truth::LevelFlag::ReconstructableFromSignal)); + } + + // Adding the a1 to the configuration labels it instead of its prongs, which is the + // documented escape hatch. + g.reconstructablePdgIds() = {111, 20213}; + const auto stopped = truth::reconstructableFromSignal(g); + CPPUNIT_ASSERT_EQUAL(std::size_t{1}, stopped.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t{1}, stopped[0]); + } + + // REQUIRED: the jet roots are the hard-scatter partons and nothing else. The top is + // excluded because its b is deeper, which is also the physics: the top decays before it + // hadronises. The incoming beam gluon is excluded for the same structural reason, and + // that matters because it sits at beam rapidity with the whole event below it. + void testPartonJetsKeepTheQuarksNotTheTopOrTheBeam() { + truth::Graph g = buildTopDecay(); + const auto jets = truth::partonJets(g); + + const std::vector expected = {2u, 4u, 5u}; // b, u, dbar + CPPUNIT_ASSERT_EQUAL(expected.size(), jets.size()); + CPPUNIT_ASSERT(std::is_permutation(expected.begin(), expected.end(), jets.begin())); + + CPPUNIT_ASSERT(std::find(jets.begin(), jets.end(), 0u) == jets.end()); // incoming gluon + CPPUNIT_ASSERT(std::find(jets.begin(), jets.end(), 1u) == jets.end()); // top + CPPUNIT_ASSERT(std::find(jets.begin(), jets.end(), 3u) == jets.end()); // W + + // No member may be an ancestor of another, the property every level owes its + // denominator. + for (uint32_t id : jets) { + for (auto const& descendant : truth::Particle(&g, id).descendants()) { + CPPUNIT_ASSERT(std::find(jets.begin(), jets.end(), descendant.id()) == jets.end()); + } + } + + truth::fillLevelFlags(g); + for (uint32_t id : expected) { + CPPUNIT_ASSERT(g.particles()[id].isAtLevel(truth::LevelFlag::PartonJets)); + } + CPPUNIT_ASSERT(!g.particles()[1].isAtLevel(truth::LevelFlag::PartonJets)); + } + + // REQUIRED: a hard-process leg that is a lepton is not a jet. The tau fixture is a + // hard-process particle and the level must still come out empty rather than adopting it. + void testPartonJetsExcludeLeptons() { + truth::Graph g = buildDecay(); + CPPUNIT_ASSERT(truth::partonJets(g).empty()); + } + + // REQUIRED: one member per physical heavy-flavour decay, not one per generator copy, + // and the member is the hadron that DECAYS WEAKLY. A B* radiates down to a B and both + // are b hadrons; the level keeps the B alone, which is the particle CMS ghost + // association names and the only one of the two with a displaced decay vertex. + void testHeavyFlavourKeepsTheWeaklyDecayingHadron() { + truth::Graph g = buildHeavyFlavour(); + + // Both are b hadrons by species, which is what makes the antichain the load-bearing part. + CPPUNIT_ASSERT(truth::atLevel(g, 1u, truth::Level::BHadrons)); + CPPUNIT_ASSERT(truth::atLevel(g, 2u, truth::Level::BHadrons)); + + const auto bees = truth::levelAntichain(g, truth::Level::BHadrons); + CPPUNIT_ASSERT_EQUAL(std::size_t{1}, bees.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t{2}, bees[0]); + } + + // REQUIRED: beauty and charm stay separate levels. The D descends from the B, so one + // combined level would keep the B and drop the D, and charm would silently vanish. + void testBeautyAndCharmAreSeparateLevels() { + truth::Graph g = buildHeavyFlavour(); + + const auto cees = truth::levelAntichain(g, truth::Level::CHadrons); + CPPUNIT_ASSERT_EQUAL(std::size_t{1}, cees.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t{3}, cees[0]); + + // The nesting that makes a combined level wrong. + bool bIsAncestorOfC = false; + for (auto const& ancestor : truth::Particle(&g, 3u).ancestors()) { + bIsAncestorOfC = bIsAncestorOfC || ancestor.id() == 1u; + } + CPPUNIT_ASSERT(bIsAncestorOfC); + + truth::fillLevelFlags(g); + CPPUNIT_ASSERT(g.particles()[2].isAtLevel(truth::LevelFlag::BHadrons)); + CPPUNIT_ASSERT(!g.particles()[1].isAtLevel(truth::LevelFlag::BHadrons)); + CPPUNIT_ASSERT(g.particles()[3].isAtLevel(truth::LevelFlag::CHadrons)); + CPPUNIT_ASSERT(!g.particles()[3].isAtLevel(truth::LevelFlag::BHadrons)); + } + + // REQUIRED: one entry per physical hadronically decaying tau. The last copy of a + // radiative chain is the member; the radiating copy and a leptonically decaying tau + // are not. + void testVisibleTauIsTheLastHadronicCopy() { + truth::Graph g = buildTausAndPi0(); + const auto taus = truth::levelAntichain(g, truth::Level::VisibleTau); + CPPUNIT_ASSERT_EQUAL(std::size_t{1}, taus.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t{1}, taus[0]); + truth::fillLevelFlags(g); + CPPUNIT_ASSERT(g.particles()[1].isAtLevel(truth::LevelFlag::VisibleTau)); + CPPUNIT_ASSERT(!g.particles()[0].isAtLevel(truth::LevelFlag::VisibleTau)); + CPPUNIT_ASSERT(!g.particles()[5].isAtLevel(truth::LevelFlag::VisibleTau)); + } + + // REQUIRED: the event-wide reconstructable final state exists WITHOUT a Signal flag, + // and a pi0 is one object: the pi0 is a member, its photons are not, and neutrinos + // are dropped. + void testReconstructableFinalStateNeedsNoSignal() { + truth::Graph g = buildTausAndPi0(); + g.reconstructablePdgIds() = {111}; + const auto legs = truth::levelAntichain(g, truth::Level::ReconstructableFinalState); + CPPUNIT_ASSERT(!legs.empty()); + auto has = [&legs](uint32_t id) { return std::find(legs.begin(), legs.end(), id) != legs.end(); }; + CPPUNIT_ASSERT(has(9)); + CPPUNIT_ASSERT(!has(10)); + CPPUNIT_ASSERT(!has(11)); + CPPUNIT_ASSERT(has(3)); + CPPUNIT_ASSERT(has(2)); + CPPUNIT_ASSERT(!has(4)); + CPPUNIT_ASSERT(!has(7)); + // The radiating tau is walked through, never labelled. + CPPUNIT_ASSERT(!has(0)); + CPPUNIT_ASSERT(!has(1)); + } +}; + +CPPUNIT_TEST_SUITE_REGISTRATION(LevelFlags_t); diff --git a/PhysicsTools/TruthInfo/test/LogicalGraphHitIndexBuilder_t.cpp b/PhysicsTools/TruthInfo/test/LogicalGraphHitIndexBuilder_t.cpp index 6cffb54681187..a08c1debbd1e5 100644 --- a/PhysicsTools/TruthInfo/test/LogicalGraphHitIndexBuilder_t.cpp +++ b/PhysicsTools/TruthInfo/test/LogicalGraphHitIndexBuilder_t.cpp @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include "Utilities/Testing/interface/CppUnit_testdriver.icpp" #include "cppunit/extensions/HelperMacros.h" diff --git a/PhysicsTools/TruthInfo/test/TruthLogicalGraphPostProcessor_t.cpp b/PhysicsTools/TruthInfo/test/TruthLogicalGraphPostProcessor_t.cpp index e0069000b1af9..c3dc82e44f6bd 100644 --- a/PhysicsTools/TruthInfo/test/TruthLogicalGraphPostProcessor_t.cpp +++ b/PhysicsTools/TruthInfo/test/TruthLogicalGraphPostProcessor_t.cpp @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include "Utilities/Testing/interface/CppUnit_testdriver.icpp" #include "cppunit/extensions/HelperMacros.h" @@ -192,6 +190,17 @@ namespace { return count; } + uint32_t countSignalStamped(truth::Graph const& graph) { + uint32_t count = 0; + + for (auto const& particle : graph.particles()) { + if (particle.isAtLevel(truth::LevelFlag::Signal)) + ++count; + } + + return count; + } + bool hasGenSimParticleWithPdgId(truth::Graph const& graph, int32_t pdgId) { return std::any_of(graph.particles().begin(), graph.particles().end(), [pdgId](auto const& particle) { return particle.pdgId == pdgId && particle.hasGen() && particle.hasSim(); @@ -330,6 +339,9 @@ class TestTruthLogicalGraphPostProcessor : public CppUnit::TestFixture { CPPUNIT_TEST(testEventIdKeyingSplitsInteractions); CPPUNIT_TEST(testSignalOnlyAndBunchCrossingFilterDropPileup); CPPUNIT_TEST(testEveryParticleDescendsFromArtificialSource); + CPPUNIT_TEST(testSignalStampRequiresGenProvenance); + CPPUNIT_TEST(testSeedPdgIdZeroStampsNothing); + CPPUNIT_TEST(testSignalStampSkipsPileup); CPPUNIT_TEST_SUITE_END(); public: @@ -356,6 +368,9 @@ class TestTruthLogicalGraphPostProcessor : public CppUnit::TestFixture { void testEventIdKeyingSplitsInteractions(); void testSignalOnlyAndBunchCrossingFilterDropPileup(); void testEveryParticleDescendsFromArtificialSource(); + void testSignalStampRequiresGenProvenance(); + void testSeedPdgIdZeroStampsNothing(); + void testSignalStampSkipsPileup(); }; CPPUNIT_TEST_SUITE_REGISTRATION(TestTruthLogicalGraphPostProcessor); @@ -1800,3 +1815,82 @@ void TestTruthLogicalGraphPostProcessor::testEveryParticleDescendsFromArtificial CPPUNIT_ASSERT(false); } } + +void TestTruthLogicalGraphPostProcessor::testSignalStampRequiresGenProvenance() { + try { + // Gun preset: a GEN electron plus an unrelated SIM-only electron (a delta + // ray). Only a GEN particle of the signal interaction may carry the Signal + // flag, so exactly one particle is stamped. + GraphBuilder builder(2, 0); + + builder.setGenParticle(0, 11, 1, 100); + builder.setSimParticle(1, 11, 1001); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {11, -11}; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countSignalStamped(output)); + for (auto const& particle : output.particles()) { + if (particle.isAtLevel(truth::LevelFlag::Signal)) + CPPUNIT_ASSERT(particle.hasGen()); + } + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testSeedPdgIdZeroStampsNothing() { + try { + // seedPdgIds = {0} is the full-graph debug escape hatch: it names no + // resonance, so nothing is stamped, not even a pdgId-0 SIM-only particle. + GraphBuilder builder(2, 0); + + builder.setGenParticle(0, 11, 1, 100); + builder.setSimParticle(1, 0, 1001); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {0}; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countSignalStamped(output)); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testSignalStampSkipsPileup() { + try { + // Two Z bosons of the same species, one in the signal interaction and one + // tagged with a pile-up EncodedEventId. Only the signal Z is stamped. + GraphBuilder builder(2, 0); + + builder.setGenParticle(0, 23, 2, 100); + builder.setGenParticle(1, 23, 2, 101); + builder.graph.particles()[1].eventId = 0x2a; // a distinct pile-up EncodedEventId + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {23}; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countSignalStamped(output)); + for (auto const& particle : output.particles()) { + if (particle.isAtLevel(truth::LevelFlag::Signal)) + CPPUNIT_ASSERT_EQUAL(uint64_t(0), uint64_t(particle.eventId)); + } + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} diff --git a/PhysicsTools/TruthInfo/test/checkMixedTruthGraph_cfg.py b/PhysicsTools/TruthInfo/test/checkMixedTruthGraph_cfg.py index c3b9d13753cbe..c7bdb8bff07f6 100644 --- a/PhysicsTools/TruthInfo/test/checkMixedTruthGraph_cfg.py +++ b/PhysicsTools/TruthInfo/test/checkMixedTruthGraph_cfg.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # Phase-A pileup prototype: build the mixed (signal+pileup) raw TruthGraph from # the MixingModule crossing frames, then the logical graph, then audit topology + diff --git a/PhysicsTools/TruthInfo/test/checkTruthGraphTopology_cfg.py b/PhysicsTools/TruthInfo/test/checkTruthGraphTopology_cfg.py index 4388812129a8c..3e41a8888a715 100644 --- a/PhysicsTools/TruthInfo/test/checkTruthGraphTopology_cfg.py +++ b/PhysicsTools/TruthInfo/test/checkTruthGraphTopology_cfg.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # Audits the raw TruthGraph and the logical truth::Graph for strange topologies # (many-particle vertices, multi-parent / multi-production particles, cycles, diff --git a/PhysicsTools/TruthInfo/test/dumpTruthGraphsFromGENSIMRECO_cfg.py b/PhysicsTools/TruthInfo/test/dumpTruthGraphsFromGENSIMRECO_cfg.py index 21814148fc038..bf5668064737b 100644 --- a/PhysicsTools/TruthInfo/test/dumpTruthGraphsFromGENSIMRECO_cfg.py +++ b/PhysicsTools/TruthInfo/test/dumpTruthGraphsFromGENSIMRECO_cfg.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). import FWCore.ParameterSet.Config as cms diff --git a/PhysicsTools/TruthInfo/test/harvestBranchDQM_cfg.py b/PhysicsTools/TruthInfo/test/harvestBranchDQM_cfg.py index 700f5b5c46b1f..c2975c20097e6 100644 --- a/PhysicsTools/TruthInfo/test/harvestBranchDQM_cfg.py +++ b/PhysicsTools/TruthInfo/test/harvestBranchDQM_cfg.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # Harvests a Branch-DQM file (output of validateBranchDQM_cfg.py) into efficiency # plots via the truthGraphDQMHarvesting sequence (DQMGenericClient). diff --git a/PhysicsTools/TruthInfo/test/produceBranchAssociations_cfg.py b/PhysicsTools/TruthInfo/test/produceBranchAssociations_cfg.py index 086d20b37fd72..226f00ad7a24d 100644 --- a/PhysicsTools/TruthInfo/test/produceBranchAssociations_cfg.py +++ b/PhysicsTools/TruthInfo/test/produceBranchAssociations_cfg.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # Smoke test for the Branch<->calo-truth AssociationMap producer: rebuilds the # truth-graph chain and runs truthBranchCaloAssociationProducer, writing the four diff --git a/PhysicsTools/TruthInfo/test/produceBranchTrackingAssociations_cfg.py b/PhysicsTools/TruthInfo/test/produceBranchTrackingAssociations_cfg.py index fa9ff6fe188cf..0201cd02db994 100644 --- a/PhysicsTools/TruthInfo/test/produceBranchTrackingAssociations_cfg.py +++ b/PhysicsTools/TruthInfo/test/produceBranchTrackingAssociations_cfg.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # Smoke test for the reco-track<->Branch AssociationMap producer: rebuilds the # truth-graph chain and runs truthBranchTrackingAssociationProducer, writing the two diff --git a/PhysicsTools/TruthInfo/test/truthGraphConnectivity.py b/PhysicsTools/TruthInfo/test/truthGraphConnectivity.py index 003b4746ca602..9f968bca31a58 100644 --- a/PhysicsTools/TruthInfo/test/truthGraphConnectivity.py +++ b/PhysicsTools/TruthInfo/test/truthGraphConnectivity.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). """Diagnose connectivity of the SimTrack/SimVertex graph in a SIM/RECO file. diff --git a/PhysicsTools/TruthInfo/test/truthGraphSelections_t.py b/PhysicsTools/TruthInfo/test/truthGraphSelections_t.py index 746507712a43a..cafa075de0edf 100644 --- a/PhysicsTools/TruthInfo/test/truthGraphSelections_t.py +++ b/PhysicsTools/TruthInfo/test/truthGraphSelections_t.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). """Unit tests for the per-process truth-graph selection presets.""" @@ -34,6 +32,10 @@ def test_gun_species(self): "SingleMuPt10_Eta2p85": [13, -13], "SingleGammaPt35": [22], "SinglePiE50HCAL": [211, -211], + # The neutral pion must not fall through to the charged-pion token: a + # pi0 gun seeds 111, so the pi0 is Signal and, being reconstructable, + # its own one-object leg. + "SinglePi0E10_pythia8_cfi": [111], "FourMuPt_1_200": [13, -13], "CloseByParticle_Photon": [22], "CE_E_Front_300um": [0], # configurable species -> full graph fallback diff --git a/PhysicsTools/TruthInfo/test/validateBranchDQM_cfg.py b/PhysicsTools/TruthInfo/test/validateBranchDQM_cfg.py index 8875006515963..4d72c39660ff9 100644 --- a/PhysicsTools/TruthInfo/test/validateBranchDQM_cfg.py +++ b/PhysicsTools/TruthInfo/test/validateBranchDQM_cfg.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # Standalone single-file driver for the Branch calo DQM validator. The modules are # the canonical ones from PhysicsTools.TruthInfo.truthGraphValidation_cff (the same diff --git a/PhysicsTools/TruthInfo/test/validateBranchRecoDQM_cfg.py b/PhysicsTools/TruthInfo/test/validateBranchRecoDQM_cfg.py index d9949cad889bb..f30ab6ccfbfc7 100644 --- a/PhysicsTools/TruthInfo/test/validateBranchRecoDQM_cfg.py +++ b/PhysicsTools/TruthInfo/test/validateBranchRecoDQM_cfg.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # Standalone single-file driver for the generic reco-side Branch validators. The # modules are the canonical ones from PhysicsTools.TruthInfo.truthGraphValidation_cff diff --git a/PhysicsTools/TruthInfo/test/validateBranchReplacement_cfg.py b/PhysicsTools/TruthInfo/test/validateBranchReplacement_cfg.py index 0a2b3dab02f5a..455c6ad2cd38c 100644 --- a/PhysicsTools/TruthInfo/test/validateBranchReplacement_cfg.py +++ b/PhysicsTools/TruthInfo/test/validateBranchReplacement_cfg.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). import FWCore.ParameterSet.Config as cms from argparse import ArgumentParser diff --git a/PhysicsTools/TruthInfo/test/validateBranchTrackingDQM_cfg.py b/PhysicsTools/TruthInfo/test/validateBranchTrackingDQM_cfg.py index ca88d03c49622..6bbfe296e3522 100644 --- a/PhysicsTools/TruthInfo/test/validateBranchTrackingDQM_cfg.py +++ b/PhysicsTools/TruthInfo/test/validateBranchTrackingDQM_cfg.py @@ -1,6 +1,4 @@ # Original author: Felice Pantaleo (CERN) -# Part of the MC-truth-graph prototype - under heavy development, not yet open -# to external contributions (see PhysicsTools/TruthInfo/README.md). # Standalone single-file driver for the Branch tracking DQM validator. The modules # are the canonical ones from PhysicsTools.TruthInfo.truthGraphValidation_cff (the diff --git a/SimDataFormats/TruthInfo/interface/Checkpoint.h b/SimDataFormats/TruthInfo/interface/Checkpoint.h index 0e4da533ed6aa..e1eb9eb037711 100644 --- a/SimDataFormats/TruthInfo/interface/Checkpoint.h +++ b/SimDataFormats/TruthInfo/interface/Checkpoint.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #ifndef SimDataFormats_TruthInfo_interface_Checkpoint_h #define SimDataFormats_TruthInfo_interface_Checkpoint_h diff --git a/SimDataFormats/TruthInfo/interface/Graph.h b/SimDataFormats/TruthInfo/interface/Graph.h index 446ffefe571f7..b4063417882f8 100644 --- a/SimDataFormats/TruthInfo/interface/Graph.h +++ b/SimDataFormats/TruthInfo/interface/Graph.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #ifndef SimDataFormats_TruthInfo_interface_Graph_h #define SimDataFormats_TruthInfo_interface_Graph_h @@ -124,6 +122,28 @@ namespace truth { [[nodiscard]] bool isConsistent() const; + // The selection preset's seed PDG ids, recorded so LevelFlag::Signal stays + // RE-DERIVABLE. Every other level bit can be recomputed from the graph alone, which + // is what lets a reader detect a graph written before a definition changed; Signal + // cannot, unless the seeds that produced it travel with it. Empty means no selection + // ran, and then no particle carries the Signal bit. + [[nodiscard]] std::vector const& signalSeedPdgIds() const { return signalSeedPdgIds_; } + [[nodiscard]] std::vector& signalSeedPdgIds() { return signalSeedPdgIds_; } + + // Species that a detector reconstructs as an object even though they decay, so the + // walk down from the signal stops at them: pi0 is the motivating case. Recorded for + // the same reason as the seeds, to keep ReconstructableFromSignal re-derivable. + // Empty means only generator-stable particles terminate the walk. + [[nodiscard]] std::vector const& reconstructablePdgIds() const { return reconstructablePdgIds_; } + [[nodiscard]] std::vector& reconstructablePdgIds() { return reconstructablePdgIds_; } + + // Heavy-flavour seed content (5 = b, 4 = c) of the selection that stamped Signal. + // A flavour-seeded preset carries no seed pdg ids at all, so without this the + // Signal bit would not be re-derivable and the audit could not tell a fresh + // heavy-flavour file from a stale one. + [[nodiscard]] std::vector const& seedHadronFlavors() const { return seedHadronFlavors_; } + [[nodiscard]] std::vector& seedHadronFlavors() { return seedHadronFlavors_; } + private: friend class Particle; friend class Vertex; @@ -150,6 +170,9 @@ namespace truth { [[nodiscard]] std::vector outgoingParticlesOf(size_type vertexId) const; std::vector particles_; + std::vector signalSeedPdgIds_; + std::vector reconstructablePdgIds_; + std::vector seedHadronFlavors_; std::vector vertices_; // Particle -> decay vertices diff --git a/SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h b/SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h index 9e531430b5a54..cb280b25ef078 100644 --- a/SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h +++ b/SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #ifndef SimDataFormats_TruthInfo_LogicalGraphHitIndex_h #define SimDataFormats_TruthInfo_LogicalGraphHitIndex_h diff --git a/SimDataFormats/TruthInfo/interface/Particle.h b/SimDataFormats/TruthInfo/interface/Particle.h index 16b6d445378c7..b951dbb815f47 100644 --- a/SimDataFormats/TruthInfo/interface/Particle.h +++ b/SimDataFormats/TruthInfo/interface/Particle.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #ifndef SimDataFormats_TruthInfo_interface_Particle_h #define SimDataFormats_TruthInfo_interface_Particle_h diff --git a/SimDataFormats/TruthInfo/interface/ParticleData.h b/SimDataFormats/TruthInfo/interface/ParticleData.h index ed9c3e8b021ad..fdd6a9f87a552 100644 --- a/SimDataFormats/TruthInfo/interface/ParticleData.h +++ b/SimDataFormats/TruthInfo/interface/ParticleData.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #ifndef SimDataFormats_TruthInfo_interface_ParticleData_h #define SimDataFormats_TruthInfo_interface_ParticleData_h @@ -14,6 +12,66 @@ namespace truth { + // Membership of the graph levels, so a graph is self-describing wherever it is read. + // Only levels re-derivable from what the graph stores belong here; that is the rule + // for adding one. BranchSelector-dependent sets stay per-event products downstream, + // so a threshold change never forces a re-production. Every bit can be recomputed + // with levelAntichain() (Signal and ReconstructableFromSignal from the seed lists + // recorded on the Graph), which is what the dumper audit and LevelFlags_t check. + enum class LevelFlag : uint32_t { + StableLegsFromUpstream = 1u << 0, + HardProcess = 1u << 1, + StableDecayProducts = 1u << 2, + CaloBoundary = 1u << 3, + // The resonance the preset's seed species name: the most upstream matching GEN + // particles of the signal interaction. Empty recorded seeds mean no Signal bits. + Signal = 1u << 4, + // First reconstructable decay products of the signal: the walk from each Signal + // root stops at the graph's reconstructablePdgIds (a pi0 is one object, not two + // photons) or at a generator-stable particle, passes through intermediates the + // detector cannot see as objects, and drops invisible species. + ReconstructableFromSignal = 1u << 5, + // Stable legs of the artificial UnderlyingEvent vertex, the spectator counterpart + // of StableLegsFromUpstream. Empty without a selection preset, not wrong. + UnderlyingEvent = 1u << 6, + // One root per parton-initiated jet: the hard-process legs that are partons, each + // standing for its descendant subgraph; no clustering, flavour = the parton's own + // PDG id. A subset of HardProcess, so a top contributes its b, never itself. + PartonJets = 1u << 7, + // The hadron of each heavy-flavour chain that DECAYS WEAKLY, which is the one CMS + // ghost association names: "the generated b and c hadrons that do not have b and c + // hadrons as daughters respectively" (CMS-BTV-16-002). A B* radiating to a B counts + // once, as the B. Beauty and charm are separate levels because a B decays to a D, and + // a combined level would silently drop every charm member. + BHadrons = 1u << 8, + CHadrons = 1u << 9, + // The visible final state of the whole GENERATOR record: the reconstructableFromSignal + // walk seeded from every GEN root instead of from the Signal roots, so a pi0 is one + // object inside a QCD jet, the underlying event and every pileup interaction, where no + // resonance exists to seed from. + ReconstructableFinalState = 1u << 10, + // Hadronically decaying taus, one per physical tau: the last tau of each radiative + // chain, with a GEN decay record and no electron and no muon among its decay children. + // The object tau identification measures efficiency against. + VisibleTau = 1u << 11, + }; + + // What a particle IS, mirroring VertexRole on the vertex side. Absence of a GEN and a + // SIM back-reference does NOT identify a synthetic particle: connectors have neither, + // and so would anything else artificial, so the kind has to be stated rather than + // inferred. Guessing it from empty fields silently conflated the two. + enum class ParticleRole : uint8_t { + // A generator or Geant4 particle. + Normal = 0, + // Artificial: produced at an Interaction vertex and decaying at the Upstream or + // UnderlyingEvent sub-vertex, so those descend from one interaction root. + Connector = 1, + // Artificial: stands in for a resonance the generator never wrote, so the signal + // level is answerable on a non-resonant sample. Its momentum is an ACCOUNTING sum + // over the hard-process legs and is not a generator quantity. + SignalStandIn = 2, + }; + struct ParticleData { // Optional provenance/debug back-references to the raw TruthGraph nodes. // -1 means "not available". @@ -34,6 +92,14 @@ namespace truth { // GEN connected component id from the raw TruthGraph, -1 if not applicable. int32_t genEvent = -1; + // Bitwise OR of the LevelFlag values this particle belongs to. fillLevelFlags owns + // every bit except Signal, which the selection post-processing sets from the seed + // species and which travels on the particle so it survives the graph rewrite. + // Occupies the alignment hole between genEvent and momentum, so sizeof stays 96 + // (asserted in LevelFlags_t). Zero is ambiguous, "belongs to no level" or "written + // before this member existed", so a reader re-derives with levelAntichain(). + uint32_t levelFlags = 0; + // Standalone payload. // Nominal physics four-momentum. // For GEN+SIM particles, this is the GEN four-momentum. @@ -48,9 +114,23 @@ namespace truth { // Scattering(); always false for GEN-only particles. bool backscattered = false; + // Real particle, connector, or synthetic stand-in, stored as its underlying type + // for dictionary simplicity as VertexData::role is. Sits in the tail padding after + // backscattered, so carrying it keeps sizeof(ParticleData) at 96. + uint8_t role = static_cast(ParticleRole::Normal); + + [[nodiscard]] ParticleRole particleRole() const { return static_cast(role); } + [[nodiscard]] bool hasGen() const { return genNode >= 0; } [[nodiscard]] bool hasSim() const { return simNode >= 0; } [[nodiscard]] bool valid() const { return hasGen() || hasSim(); } + + // True for anything the graph invented. Never read the momentum of such a particle + // as a generator quantity. + [[nodiscard]] bool isSynthetic() const { return particleRole() != ParticleRole::Normal; } + + [[nodiscard]] bool isAtLevel(LevelFlag flag) const { return (levelFlags & static_cast(flag)) != 0; } + void setLevel(LevelFlag flag) { levelFlags |= static_cast(flag); } }; } // namespace truth diff --git a/SimDataFormats/TruthInfo/interface/TruthGraph.h b/SimDataFormats/TruthInfo/interface/TruthGraph.h index 96373be23df31..83ce213bf0916 100644 --- a/SimDataFormats/TruthInfo/interface/TruthGraph.h +++ b/SimDataFormats/TruthInfo/interface/TruthGraph.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // Author: Felice Pantaleo - CERN // Date: 03/2026 diff --git a/SimDataFormats/TruthInfo/interface/Vertex.h b/SimDataFormats/TruthInfo/interface/Vertex.h index 99293147e2922..dd0a5c650a6e1 100644 --- a/SimDataFormats/TruthInfo/interface/Vertex.h +++ b/SimDataFormats/TruthInfo/interface/Vertex.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #ifndef SimDataFormats_TruthInfo_interface_Vertex_h #define SimDataFormats_TruthInfo_interface_Vertex_h diff --git a/SimDataFormats/TruthInfo/interface/VertexData.h b/SimDataFormats/TruthInfo/interface/VertexData.h index b9729c334966d..d5d10dc60c8c7 100644 --- a/SimDataFormats/TruthInfo/interface/VertexData.h +++ b/SimDataFormats/TruthInfo/interface/VertexData.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #ifndef SimDataFormats_TruthInfo_interface_VertexData_h #define SimDataFormats_TruthInfo_interface_VertexData_h diff --git a/SimDataFormats/TruthInfo/src/Graph.cc b/SimDataFormats/TruthInfo/src/Graph.cc index 1140144f0514b..848be8330b51e 100644 --- a/SimDataFormats/TruthInfo/src/Graph.cc +++ b/SimDataFormats/TruthInfo/src/Graph.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #include "SimDataFormats/TruthInfo/interface/Graph.h" diff --git a/SimDataFormats/TruthInfo/src/TruthGraph.cc b/SimDataFormats/TruthInfo/src/TruthGraph.cc index 418d00f3f4af9..25e2d322854f8 100644 --- a/SimDataFormats/TruthInfo/src/TruthGraph.cc +++ b/SimDataFormats/TruthInfo/src/TruthGraph.cc @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). // Author: Felice Pantaleo - CERN // Date: 03/2026 diff --git a/SimDataFormats/TruthInfo/src/classes.h b/SimDataFormats/TruthInfo/src/classes.h index 1f612694cfe1b..3693d1e1c86d4 100644 --- a/SimDataFormats/TruthInfo/src/classes.h +++ b/SimDataFormats/TruthInfo/src/classes.h @@ -1,6 +1,4 @@ // Original author: Felice Pantaleo (CERN) -// Part of the MC-truth-graph prototype - under heavy development, not yet open -// to external contributions (see PhysicsTools/TruthInfo/README.md). #ifndef SimDataFormats_TruthInfo_src_classes_h #define SimDataFormats_TruthInfo_src_classes_h diff --git a/SimDataFormats/TruthInfo/src/classes_def.xml b/SimDataFormats/TruthInfo/src/classes_def.xml index e35b05327a62e..dcca4efbede99 100644 --- a/SimDataFormats/TruthInfo/src/classes_def.xml +++ b/SimDataFormats/TruthInfo/src/classes_def.xml @@ -9,15 +9,17 @@ - + + - + +
" << bigName << "
Particle " << i << "
" << truth::levelName(level) + << "
pid: " << pdgLabel(d.pdgId) << "