Skip to content

Commit 367e25b

Browse files
sawenzelclaude
andcommitted
AODBcRewriter: re-sort tables stored sorted by a reordered reference
Second member of the O2-7098 family. Stage 1b reorders O2track_iu/ O2mfttrack/O2fwdtrack; several other tables (O2fwdtrkcl, O2ambiguoustrack, O2trackqa_003, O2v0_002, O2cascade_001, O2decay3body) are stored SORTED BY a reference into one of those (or into O2collision). Their index values were remapped correctly, but their rows stayed put, so a sorted column came out unsorted -- the same defect as the split "-1" group that once caused [FATAL] Table Tracks_IU index fIndexCollisions has a group with index -1 that is split by 776 Confirmed on real files: O2fwdtrkcl and O2trackqa_003 came out unsorted in every DF of two of three anchored-MC samples tested. Fix: findGroupingColumn() + resortByGroupingColumn(), derived from the data rather than another hardcoded table list -- "if T.B is non-decreasing on input and B's referent moved, re-sort T by the remapped B". Leaves O2mfttrackcov alone (not sorted on input). Iterated to a fixed point, since O2cascade_001 is sorted by fIndexV0s and O2v0_002 may itself have just moved. AODBcRewriterCheckLinks() now also asserts sortedness is preserved. Fixture extended with O2fwdtrkcl and a second cascade to exercise the O2v0->O2cascade dependency; against the previous commit the suite fails 3 assertions and 6 link checks, and passes with this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ba8a6b2 commit 367e25b

4 files changed

Lines changed: 260 additions & 23 deletions

File tree

MC/utils/AODBcRewriter.C

Lines changed: 176 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,15 +1075,155 @@ static void stage1b_reorderTrackTables(
10751075
}
10761076
}
10771077

1078+
// ----------------------------------------------------------------------------
1079+
// Re-sorting tables that are STORED SORTED BY a reference
1080+
//
1081+
// Stage 1b exists because a track table grouped by fIndexCollisions stops being
1082+
// sliceable once the collision table is reordered. Exactly the same is true of
1083+
// every other table stored sorted by a reference into a table this tool
1084+
// reorders — O2v0_002 and O2cascade_001 by fIndexCollisions, O2fwdtrkcl by
1085+
// fIndexFwdTracks, O2ambiguoustrack and O2trackqa_003 by fIndexTracks.
1086+
// Remapping their values while leaving their rows in place turns a sorted
1087+
// column into an unsorted one, which is the same defect that produced
1088+
// "Table ... index fIndexCollisions has a group with index -1 that is split".
1089+
//
1090+
// Rather than hardcode which tables are grouped by what — the enumeration habit
1091+
// that caused O2-7098 — the decision is derived from the data: IF a column is
1092+
// non-decreasing in the input, THEN it is an ordering the file carries and the
1093+
// output must preserve it. That is self-maintaining across schema changes, and
1094+
// it correctly leaves O2mfttrackcov alone: its fIndexMFTTracks is not sorted in
1095+
// the input, so there is no ordering to preserve.
1096+
1097+
struct ResortCandidate {
1098+
size_t planIdx = 0;
1099+
std::string tableName;
1100+
std::string keyBranch;
1101+
std::vector<Long64_t> keyValues; // raw input values of keyBranch
1102+
std::vector<std::string> refPrefixes; // referent of keyBranch, from kIndexRefs
1103+
};
1104+
1105+
// Read a scalar Int_t-like index column. Returns false for VLA / fixed-array
1106+
// columns, which are not row orderings.
1107+
static bool readScalarIndexColumn(TTree *t, const char *branch,
1108+
std::vector<Long64_t> &out) {
1109+
TBranch *br = t->GetBranch(branch);
1110+
if (!br) return false;
1111+
TLeaf *leaf = static_cast<TLeaf *>(br->GetListOfLeaves()->At(0));
1112+
if (!leaf || leaf->GetLeafCount() || leaf->GetLen() != 1) return false;
1113+
ScalarTag tag = tagOf(leaf);
1114+
size_t sz = byteSize(tag);
1115+
if (sz == 0) return false;
1116+
std::vector<unsigned char> buf(sz, 0);
1117+
br->SetAddress(buf.data());
1118+
out.clear();
1119+
out.reserve(t->GetEntries());
1120+
for (Long64_t i = 0; i < t->GetEntries(); ++i) {
1121+
br->GetEntry(i);
1122+
out.push_back(readAsInt(buf.data(), tag));
1123+
}
1124+
br->ResetAddress();
1125+
return true;
1126+
}
1127+
1128+
// The convention this tool writes (and Stage 1b establishes): valid values
1129+
// ascending, the -1 "ambiguous" group as one contiguous run at the end.
1130+
static bool isOrderedWithNullsLast(const std::vector<Long64_t> &v) {
1131+
Long64_t prev = -1;
1132+
bool seenNull = false;
1133+
for (auto x : v) {
1134+
if (x < 0) { seenNull = true; continue; }
1135+
if (seenNull) return false; // a valid value after a null: not this convention
1136+
if (x < prev) return false;
1137+
prev = x;
1138+
}
1139+
return true;
1140+
}
1141+
1142+
// Pick the column a table is stored sorted by, if any. fIndexCollisions wins
1143+
// when several qualify, since that is the grouping O2's slicing cache checks.
1144+
static bool findGroupingColumn(TTree *src, std::string &keyBranch,
1145+
std::vector<Long64_t> &keyValues,
1146+
std::vector<std::string> &refPrefixes) {
1147+
if (!src || src->GetEntries() < 2) return false;
1148+
bool found = false;
1149+
for (auto &[branchName, prefixes] : kIndexRefs) {
1150+
std::vector<Long64_t> vals;
1151+
if (!readScalarIndexColumn(src, branchName.c_str(), vals)) continue;
1152+
if (!isOrderedWithNullsLast(vals)) continue;
1153+
bool preferred = (branchName == "fIndexCollisions");
1154+
if (found && !preferred) continue;
1155+
keyBranch = branchName;
1156+
keyValues = std::move(vals);
1157+
refPrefixes = prefixes;
1158+
found = true;
1159+
if (preferred) break;
1160+
}
1161+
return found;
1162+
}
1163+
1164+
// Re-sort each candidate by its remapped grouping column. Iterated to a fixed
1165+
// point because these tables reference each other: O2cascade_001 is sorted by
1166+
// fIndexV0s, and O2v0_002 may itself have just been re-sorted.
1167+
static void resortByGroupingColumn(
1168+
std::vector<TablePlan> &plans,
1169+
std::unordered_map<std::string, PermMap> &allPerms,
1170+
const std::vector<ResortCandidate> &candidates) {
1171+
1172+
const int kMaxPasses = 8;
1173+
int pass = 0;
1174+
for (; pass < kMaxPasses; ++pass) {
1175+
bool changed = false;
1176+
for (auto &cand : candidates) {
1177+
const PermMap *refPerm = findPermFor(allPerms, cand.refPrefixes);
1178+
if (!refPerm) continue; // referent absent from this DF
1179+
1180+
Long64_t n = (Long64_t)cand.keyValues.size();
1181+
struct SortEntry { Long64_t key; Long64_t srcRow; };
1182+
std::vector<SortEntry> entries;
1183+
entries.reserve(n);
1184+
for (Long64_t i = 0; i < n; ++i) {
1185+
Long64_t old = cand.keyValues[i];
1186+
Long64_t nw = (old >= 0 && old < (Long64_t)refPerm->size()) ? (*refPerm)[old] : -1;
1187+
entries.push_back({nw, i});
1188+
}
1189+
// Same ordering convention as Stage 1: -1 sinks to a contiguous tail.
1190+
std::stable_sort(entries.begin(), entries.end(),
1191+
[](const SortEntry &a, const SortEntry &b) {
1192+
if (a.key < 0 && b.key >= 0) return false;
1193+
if (a.key >= 0 && b.key < 0) return true;
1194+
return a.key < b.key;
1195+
});
1196+
std::vector<Long64_t> rowOrder;
1197+
rowOrder.reserve(n);
1198+
for (auto &e : entries) rowOrder.push_back(e.srcRow);
1199+
1200+
if (rowOrder == plans[cand.planIdx].rowOrder) continue;
1201+
plans[cand.planIdx].rowOrder = rowOrder;
1202+
allPerms[cand.tableName] = permFromRowOrder(n, rowOrder);
1203+
changed = true;
1204+
std::cout << " Re-sort: " << cand.tableName << " by remapped "
1205+
<< cand.keyBranch << " (was sorted by it on input)\n";
1206+
}
1207+
if (!changed) break;
1208+
}
1209+
if (pass == kMaxPasses)
1210+
std::cerr << " [warn] re-sort did not reach a fixed point after "
1211+
<< kMaxPasses << " passes — check for a reference cycle\n";
1212+
}
1213+
10781214
// Plan every table not yet claimed by an earlier stage: paste-join children
1079-
// follow their parent's row order, everything else keeps its own. The index
1080-
// columns they carry are NOT enumerated here any more — buildRemaps() derives
1081-
// them from kIndexRefs when processDF writes.
1215+
// follow their parent's row order, everything else keeps its own — unless it is
1216+
// stored sorted by a reference, in which case resortByGroupingColumn() below
1217+
// re-establishes that ordering. The index columns these tables carry are NOT
1218+
// enumerated here — buildRemaps() derives them from kIndexRefs when processDF
1219+
// writes.
10821220
static void planRemainingTables(
10831221
TDirectory *dirIn, std::vector<TablePlan> &plans,
10841222
std::unordered_map<std::string, PermMap> &allPerms,
10851223
std::unordered_set<std::string> &planned) {
10861224

1225+
std::vector<ResortCandidate> resortCandidates;
1226+
10871227
TIter it(dirIn->GetListOfKeys());
10881228
while (TKey *key = static_cast<TKey *>(it())) {
10891229
if (TString(key->GetClassName()) != "TTree") continue;
@@ -1139,10 +1279,26 @@ static void planRemainingTables(
11391279
std::iota(rowOrder.begin(), rowOrder.end(), 0LL);
11401280
}
11411281

1282+
// A table that arrives here with its own row order may still be STORED
1283+
// SORTED BY one of its index columns; if that column's referent gets
1284+
// reordered, the sortedness has to be re-established. Record what is
1285+
// needed for that; the decision is made below, once every table in this
1286+
// stage has a permutation.
1287+
if (!parentPerm) {
1288+
ResortCandidate cand;
1289+
if (findGroupingColumn(src, cand.keyBranch, cand.keyValues, cand.refPrefixes)) {
1290+
cand.planIdx = plans.size();
1291+
cand.tableName = tname;
1292+
resortCandidates.push_back(std::move(cand));
1293+
}
1294+
}
1295+
11421296
allPerms[tname] = permFromRowOrder(nSrc, rowOrder);
11431297
plans.push_back({tname, std::move(rowOrder)});
11441298
planned.insert(tname);
11451299
}
1300+
1301+
resortByGroupingColumn(plans, allPerms, resortCandidates);
11461302
}
11471303

11481304
// ============================================================================
@@ -1826,6 +1982,23 @@ static bool checkLinksDF(TDirectory *din, TDirectory *dout, const char *dfName)
18261982
linksOut.push_back({branchName, &fpOut[refName], nullptr});
18271983
}
18281984

1985+
// Ordering preservation: a column that is sorted on input describes a
1986+
// grouping the file carries, and O2's slicing cache relies on it. Remapping
1987+
// the values while leaving the rows in place silently destroys it — the same
1988+
// defect as the split "-1" group, just in a different table.
1989+
for (auto &[branchName, prefixes] : kIndexRefs) {
1990+
std::vector<Long64_t> vIn, vOut;
1991+
if (!readScalarIndexColumn(tIn, branchName.c_str(), vIn)) continue;
1992+
if (!isOrderedWithNullsLast(vIn)) continue; // no ordering to preserve
1993+
if (!readScalarIndexColumn(tOut, branchName.c_str(), vOut)) continue;
1994+
if (!isOrderedWithNullsLast(vOut)) {
1995+
std::cerr << " [FAIL] " << dfName << ": " << tn << "." << branchName
1996+
<< " was sorted on input but is not on output"
1997+
" (grouping destroyed — slicing will misbehave)\n";
1998+
ok = false;
1999+
}
2000+
}
2001+
18292002
auto cIn = linkTupleCounts(tIn, fpIn[tn], linksIn);
18302003
auto cOut = linkTupleCounts(tOut, fpOut[tn], linksOut);
18312004

MC/utils/CLAUDE.md

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ practice **every merged MC AO2D since 4 Jun 2026 is affected**.
4949
test instead of silently mis-linking data.
5050
5. **`AODBcRewriterCheckLinks()` (Section 11b)** — the check that can actually
5151
see this bug class; see "Testing" below.
52+
6. **Re-sort tables stored sorted by a reference** (`findGroupingColumn` /
53+
`resortByGroupingColumn`, Section 8). The same family: `O2fwdtrkcl` and
54+
`O2trackqa_003` were coming out unsorted in every DF of two of the three
55+
sample files, which breaks O2's slicing the same way the split `-1` group
56+
did. Derived from the data, not from a list — see resolved gap 1.
5257

5358
Maurice's #2418 has the same shape (defer `O2fwdtrack`, remap via the MFT/Fwd
5459
perms) and is correct; this generalises it from one table to all of them.
@@ -331,13 +336,12 @@ row whose `fIndexMcCollisions` pointed to a dropped row is also dropped.
331336
332337
These were identified during the refactor but not yet implemented:
333338
334-
### 1. Tables SORTED BY a reference into a reordered table are not re-sorted
339+
### ~~1. Tables SORTED BY a reference into a reordered table are not re-sorted~~ (RESOLVED)
335340
336-
**This is the remaining member of the O2-7098 family — open, and the natural
337-
follow-up PR.** Stage 1b reorders `O2track_iu` / `O2mfttrack` / `O2fwdtrack`.
338-
Several other tables are *stored sorted by* a reference into one of those (or
339-
into `O2collision`). Their values are now correctly remapped, but their rows are
340-
left where they were, so the ordering is destroyed:
341+
Stage 1b reorders `O2track_iu` / `O2mfttrack` / `O2fwdtrack`. Several other
342+
tables are *stored sorted by* a reference into one of those (or into
343+
`O2collision`). Their values were remapped but their rows left where they were,
344+
so the ordering was destroyed:
341345
342346
| table | key | sorted in input? |
343347
|---|---|---|
@@ -347,16 +351,22 @@ left where they were, so the ordering is destroyed:
347351
| `O2v0_002`, `O2cascade_001`, `O2decay3body` | `fIndexCollisions` | yes |
348352
| `O2mfttrackcov` | `fIndexMFTTracks` | **no** — so not an invariant there |
349353
350-
(measured on `example_AOD/AO2D_pre.root`.) This is the same failure mode as the
351-
split `-1` group that produced the `ArrowTableSlicingCache::validateOrder`
352-
FATAL — not yet observed in the wild, but structurally identical.
353-
354-
**Suggested fix** — data-derived rather than another hardcoded list: *if `T.B`
355-
was sorted in the input and `B`'s referent was reordered, re-sort `T` by the
356-
remapped `B`.* That rule reproduces Stage 1b's behaviour and correctly leaves
357-
`O2mfttrackcov` alone. It is deliberately **not** in the O2-7098 fix: it changes
358-
the row order of tables that are currently untouched, so it deserves its own
359-
review. `AODBcRewriterCheckLinks` is already in place to police it.
354+
(measured on `example_AOD/AO2D_pre.root`.) Same failure mode as the split `-1`
355+
group that produced the `ArrowTableSlicingCache::validateOrder` FATAL. **Not
356+
theoretical**: on `example_AOD/AO2D_pre.root` and `bigger2/`, `O2fwdtrkcl` and
357+
`O2trackqa_003` came out unsorted in *every* DF.
358+
359+
**Fix**: `findGroupingColumn` + `resortByGroupingColumn` (Section 8), driven by
360+
the data rather than by another hardcoded list — *if `T.B` is non-decreasing in
361+
the input and `B`'s referent gets reordered, re-sort `T` by the remapped `B`*.
362+
Self-maintaining across schema changes, and it correctly leaves `O2mfttrackcov`
363+
alone (its `fIndexMFTTracks` is not sorted on input, so there is no ordering to
364+
preserve). Iterated to a fixed point, because these tables reference each other:
365+
`O2cascade_001` is sorted by `fIndexV0s` and `O2v0_002` may itself have just
366+
been re-sorted.
367+
368+
Policed by a new check in `AODBcRewriterCheckLinks`: a column sorted on input
369+
must be sorted on output.
360370
361371
### ~~2. `fIndexCollisions` inside `O2mccollision` is not remapped~~ (MOOT)
362372

MC/utils/tests/makeTestAOD.C

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,21 +110,31 @@ const int kFwdMatch[] = {-1, 0, -1, -1, 3, -1};// fwd -> fwd self-reference
110110
// exactly the metric O2-7098 was reported with.
111111
const int kFwdMcPart[] = {0, 3, -1, 6, 9, -1};
112112

113+
// V0s and cascades are stored SORTED BY fIndexCollisions, as reconstruction
114+
// writes them. The collision table gets reordered, so these have to be
115+
// re-sorted or the grouping is destroyed. The cascades are additionally sorted
116+
// by fIndexV0s, whose referent is itself one of these re-sorted tables — a
117+
// two-level dependency that a single pass would not resolve.
113118
const int kNV0 = 2;
114119
const int kV0Coll[] = {0, 2};
115120
const int kV0Pos[] = {0, 4};
116121
const int kV0Neg[] = {1, 5};
117122

118-
const int kNCasc = 1;
119-
const int kCascColl[] = {0};
120-
const int kCascV0[] = {0};
121-
const int kCascBach[] = {2};
123+
const int kNCasc = 2;
124+
const int kCascColl[] = {0, 2};
125+
const int kCascV0[] = {0, 1};
126+
const int kCascBach[] = {2, 4};
122127

128+
// Sorted by fIndexTracks / fIndexFwdTracks respectively — both referents are
129+
// reordered by Stage 1b.
123130
const int kNAmb = 2;
124131
const int kAmbTrack[] = {3, 6};
125132
const int kAmbBCFirst[] = {0, 3};
126133
const int kAmbBCLast[] = {2, 4};
127134

135+
const int kNFwdCl = 4;
136+
const int kFwdClTrack[] = {0, 1, 3, 4};
137+
128138
} // namespace
129139

130140
void makeTestAOD(const char *outFileName = "AO2D_test.root")
@@ -308,6 +318,16 @@ void makeTestAOD(const char *outFileName = "AO2D_test.root")
308318
t.Write();
309319
}
310320

321+
// ---- O2fwdtrkcl (grouped by fIndexFwdTracks, a Stage-1b table) ---------
322+
{
323+
TTree t("O2fwdtrkcl", "fwd track clusters");
324+
int fwd; float x;
325+
t.Branch("fIndexFwdTracks", &fwd, "fIndexFwdTracks/I");
326+
t.Branch("fX", &x, "fX/F");
327+
for (int i = 0; i < kNFwdCl; ++i) { fwd = kFwdClTrack[i]; x = 3000.f + i; t.Fill(); }
328+
t.Write();
329+
}
330+
311331
// ---- O2ambiguoustrack (slice into the deduplicated BC table) ------------
312332
{
313333
TTree t("O2ambiguoustrack", "ambiguous tracks");

MC/utils/tests/testAODBcRewriter.C

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,40 @@ int testAODBcRewriter(const char *inFileName = "AO2D_test.root",
263263
" daughter slices still point at the right particles");
264264
}
265265

266+
// ---- Grouping preserved for tables stored sorted by a reference ---------
267+
// These are written sorted by an index into a table the rewriter reorders.
268+
// Remapping the values without re-sorting the rows leaves the column
269+
// unsorted, which breaks O2's slicing cache the same way a split "-1" group
270+
// does. The referenced tables here are all genuinely reordered, so a
271+
// no-op implementation cannot pass this by accident.
272+
{
273+
const char *cases[][2] = {
274+
{"O2v0_002", "fIndexCollisions"},
275+
{"O2cascade_001", "fIndexCollisions"},
276+
{"O2cascade_001", "fIndexV0s"}, // referent is itself re-sorted
277+
{"O2fwdtrkcl", "fIndexFwdTracks"},
278+
{"O2ambiguoustrack", "fIndexTracks"},
279+
};
280+
for (auto &c : cases) {
281+
auto vIn = readInt(get(din, c[0]), c[1]);
282+
auto vOut = readInt(get(dout, c[0]), c[1]);
283+
auto ordered = [](const std::vector<Int_t> &v) {
284+
Int_t prev = -1; bool seenNull = false;
285+
for (auto x : v) {
286+
if (x < 0) { seenNull = true; continue; }
287+
if (seenNull || x < prev) return false;
288+
prev = x;
289+
}
290+
return true;
291+
};
292+
std::string what = std::string(c[0]) + "." + c[1];
293+
if (vIn.empty()) { fail(what + ": column missing from input"); continue; }
294+
if (!ordered(vIn)) { fail(what + ": fixture is not sorted on input — fix makeTestAOD.C"); continue; }
295+
if (!ordered(vOut)) fail(what + ": sorted on input, unsorted on output (grouping destroyed)");
296+
else pass(what + ": still sorted after the rewrite");
297+
}
298+
}
299+
266300
// ---- Slice into the deduplicated BC table -------------------------------
267301
{
268302
auto bcIn = readULong(get(din, "O2bc_001"), "fGlobalBC");

0 commit comments

Comments
 (0)