From 5145b7bee13071d1ccc2b0a12887999beea87115 Mon Sep 17 00:00:00 2001 From: Max Burian Date: Tue, 18 Aug 2026 15:20:17 -0600 Subject: [PATCH] docs(NEGGIA-004): spec + audit + proposed fixture-upgrade patch (verified in scratch build: 4/4 pass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test_XdsPluginConcurrent parameterization: runStressOn helper + eiger1/ eiger2 bslz4 multi-datafile cases + Large001 capped at 80 frames (full 16-slot coverage under both frame-modulo and dataset-index dispatch; plugin-level total==5000 empirically confirmed). Patch at docs/patches/NEGGIA-004.patch — human applies (src/ is propose-only). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012mV1sTxDDLeGzV1gKVup1C --- docs/audits/NEGGIA-004.md | 83 +++++++++++++ docs/patches/NEGGIA-004.patch | 216 ++++++++++++++++++++++++++++++++++ docs/specs/NEGGIA-004.md | 44 +++++++ 3 files changed, 343 insertions(+) create mode 100644 docs/audits/NEGGIA-004.md create mode 100644 docs/patches/NEGGIA-004.patch create mode 100644 docs/specs/NEGGIA-004.md diff --git a/docs/audits/NEGGIA-004.md b/docs/audits/NEGGIA-004.md new file mode 100644 index 0000000..6bd819e --- /dev/null +++ b/docs/audits/NEGGIA-004.md @@ -0,0 +1,83 @@ +# Audit: NEGGIA-004 — Concurrent-test fixture upgrade + +## Spec Reference +../neggia/docs/specs/NEGGIA-004.md + +## Symbol / File Inventory +(from neggia-archaeologist, 2026-08-18) + +- `Test_XdsPluginConcurrent.cpp` (149 lines): `NUM_THREADS=16`, `CALLS_PER_THREAD=100` (:44-45); class derives from `TestDatasetArtificialSmall001` (:48) — hard-bound to the 5-frame synthetic master; frame count from `plugin_get_header`'s 6th out-param (:81-84); serial 1-based reference loop (:89-96); 16 threads × per-thread `std::mt19937(0xC0FFEE ^ t)` uniform draws over [1,total] (:106-129); bit-equality via `std::equal` over nx·ny int32 (:122-123); own `main()` (:145-149). +- `DatasetsFixture.h:18-22`: `WIDTH=11; HEIGHT=13; N_FRAMES_PER_DATASET=5`; Small001 = 1 dataset (5 frames); **Large001 = 1000 datasets → 5000 frames / 1000 external data files** (`DatasetsFixture.h:33-47`, dir verified: 1001 files). +- Eiger tests use **no fixture class** — hardcoded relative literals (`Test_XdsPluginWithData.cpp:97-151`) resolved through the build-dir symlink (`test/CMakeLists.txt:3-7`); only compile definition is the absolute `PATH_TO_XDS_PLUGIN` (:8). Eiger fixtures: 4 frames each; 2-datafile variants = 2 frames/file; eiger1 1030×1065 uint16, eiger2 1028×512 uint8/16/32; bslz4 + lz4. +- **No TEST_P precedent, and the vendored gtest is 1.9.0-dev (pre-1.10): `INSTANTIATE_TEST_SUITE_P` does not exist** — only deprecated `INSTANTIATE_TEST_CASE_P` (spec OQ3 resolved: use a fixture-path array/loop or per-case TEST_Fs, not parameterized-test macros). +- Dispatch on master (`H5ToXds.cpp:151-156, :514`): slot = raw **1-based** frame `% 16`; `plugin_get_header` pins worker 0 (:461). + +## Threading Model +Test-side threads only (existing pattern). New interleaving the upgrade observes: two threads dispatched to the *same* worker slot concurrently calling `readDataset` on one immutable `H5DataCache` — per-call `Dataset` + per-call buffer (`H5ToXds.cpp:395-405`) → should stay race-free; with bslz4/lz4 eiger data the **decompression paths run under concurrency for the first time ever** (the 5-frame synthetic is effectively uncompressed). + +## Call Graph +Test → dlopen'd four symbols (unchanged). No production symbol modified. + +## ABI Surface Impact +**NONE** (test-only). ABI HALT does not trigger. + +## Invariants +1. Zero production diff (spec inv 1) — everything under `test/` +2. **Worker-coverage math (spec OQ resolved + spec inv 2 sharpened):** with slot = 1-based-frame % 16, contiguous frames 1..16 are the minimum covering all 16 slots (slot 0 first served by frame 16). Under NEGGIA-005's planned dataset-index dispatch (`globalFrame / nframesPerDataset % K`), covering all 16 slots needs ≥16 datasets → **≥ K × N_FRAMES_PER_DATASET = 80 contiguous frames of Large001**. Decision: the Large001 case tests frames 1..N_test with **N_test = 80** — covers all 16 slots under BOTH dispatch keys (frames 1..80 mod 16 = all residues; datasets 0..15 = all slots), so the assert survives NEGGIA-005 unchanged. Expressed as an assertion on the tested frame range (N_test == 80, contiguous, 1-based), never a literal `% 16` (spec inv 2). +3. **Bounded Helgrind cost:** capping Large001 at 80 frames (not 5000) keeps the serial reference loop + stress phase small enough for NEGGIA-003's ≤15-min Helgrind budget — the per-frame Dataset re-parse anomaly (`docs/learnings/NEGGIA-001.md`, → NEGGIA-005) makes a 5000-frame loop needlessly expensive under valgrind +4. Stress-phase draws are uniform over [1, N_test]; with N_test=80 and 1600 draws, residue coverage is morally deterministic (P(miss) ≤ 16·(15/16)^1600 ≈ 6×10⁻⁴³) and the *reference loop* provides the deterministic guarantee regardless +5. Bit-equality asserts preserved per fixture (spec inv 4); reference = single-threaded serial reads of the same master +6. Large001 caveat: **no plugin-level test has ever opened it** — `number_of_frames == 5000` is expected from fixture arithmetic but unverified; the new case asserts `total_frames == 5000` before capping to 80 (turns the unknown into a checked fact) +7. **Spec erratum (inv 6):** test files are cap-EXEMPT per NEGGIA-001's recorded accounting ("test-cap-exempt", CMakeLists "framework-exempt"); the spec's self-imposed ≤50 declaration is withdrawn — the ~30-line projection stands as a guideline, and the patch may take the clean-refactor route (extract a `runStressOn(master, nTestFrames)` helper + 4 cases) rather than a duplication-minimizing contortion + +## Risks +- **Medium → Low after invariant 2/3 decisions**: (i) dispatch-key survivability — closed by the 80-frame rule; (ii) Helgrind wall-time — closed by the cap; (iii) Large001 plugin-level behavior unverified — converted into an explicit assertion; (iv) eiger cases add ~4-frame stress domains (slots 1-4 only) — they are for real-compression concurrency, not coverage; the audit records that division of labor so nobody "fixes" it later. + +## Existing ctest Coverage on Surface +`Test_XdsPluginConcurrent` (Small001 only, workers 1-5, no real compression); `Test_XdsPluginWithData` (eiger, single-threaded); `Test_EigerData` (dataset API, per-datafile dims — source of the 2-frames/file constants); `Test_Dataset` (Small001 + Large001 via C++ API, one frame/dataset). Gap being closed: no concurrent test on real compression, external links, or slots 0 and 6-15. + +## Challenge Questions Answered +- ≥32-frame fixture exists? Yes — Large001, 5000 frames; no generation tooling needed; h5-testfiles submodule is owned by upstream `dectris` org (extending it = governance decision), so reusing Large001 also avoids a cross-org write (OQ1). +- Path plumbing? Relative literals + build-dir symlink; nothing to extend in CMake beyond (possibly) nothing at all (OQ2). +- Parameterization? No TEST_P (pre-1.10 gtest); helper + per-case TEST_F (OQ3). +- What interleaving is newly observed? Same-slot concurrent `readDataset` on real compressed multi-datafile data — precisely NEGGIA-005/007's future surface. + +## Devil's Advocate +- **Strongest argument against:** capping Large001 at 80 frames means the test never exercises deep external-link fan-out (data files 17..1000) or late-frame paths, so a hypothetical bug appearing only past frame 80 (e.g., B-tree deep-node traversal under concurrency) stays invisible — the cap trades completeness for Helgrind budget. +- **Resolution:** the 80-frame window spans 16 external data files and every worker slot, which is the concurrency surface this ticket exists to observe; deep-fan-out correctness is *single-threaded* HDF5-parsing territory already covered by `Test_Dataset`'s Large001 pass over all 1000 datasets. If NEGGIA-005's cache changes fan-out behavior, its own verify step must extend the window — recorded here as a forward note for the NEGGIA-005 spec. +- **Second-order effects:** ctest wall-time grows (4 cases instead of 1, one with an 80-frame reference loop) — minutes at worst natively; the NEGGIA-003 interplay is handled by the cap. No production or ABI effect. +- **What would make this audit wrong:** if Large001's master declared nimages ≠ 5000 (invariant 6 converts this to a first-run assertion failure, not a silent wrong test); if the vendored gtest's TEST_F machinery behaved differently across the 4 added cases — implausible, same macro as today. + +## Cap-Unit Projection (sum-counted) +Test files cap-exempt (NEGGIA-001 precedent; spec inv 6 erratum above). Indicative size for review: helper extraction + 4 cases ≈ 60-80 sum lines in `Test_XdsPluginConcurrent.cpp`, ~0-6 in `DatasetsFixture.h` (Large001 already exists; likely zero), 0 in CMakeLists (same target). No headers weighted. + +## Audit Verdict +- **READY_FOR_PATCH** +- Rationale: scope matches the spec (test/ only); all 3 OQs resolved — the decisive finding is that Large001 already provides the ≥32-frame multi-datafile fixture, eliminating generation tooling and submodule governance entirely; the 80-frame rule makes the coverage assert survive NEGGIA-005's dispatch change and bounds Helgrind cost; the one spec erratum (cap-exemption for tests) is recorded. + +## Minimal Patch Proposal + +Source-line count: **cap-exempt** (all changes under `src/dectris/neggia/test/` — test additions encouraged, not penalised; NEGGIA-001 precedent). Indicative size: +121/−70 in `Test_XdsPluginConcurrent.cpp`; zero changes to `DatasetsFixture.*` (Large001 already existed) or `test/CMakeLists.txt` (same target). No headers. + +### Diff +Full unified diff at **`docs/patches/NEGGIA-004.patch`** (validated `git apply --check` — clean). Human applies: +``` +git apply docs/patches/NEGGIA-004.patch +``` +Shape: the NEGGIA-001 stress body becomes the fixture member `runStressOn(masterPath, capFrames, expectedTotal)` (byte-preserved logic; three textual parameterizations: master path, `n_test` in place of `total_frames` for the reference size/dist, plus the cap/expected-total block after `ASSERT_GT(total_frames, 0)`); four `TEST_F` cases — original Small001, eiger1 bslz4 2-datafile, eiger2 bslz4 uint32 2-datafile, and Large001 capped at `16*5 = 80` frames with `expectedTotal=5000`. + +### Per-hunk justification +1. Header note + `#include ` — documentation + helper signature hygiene +2. `runStressOn` helper — spec invariants 4 (bit-equality preserved verbatim), 2 (cap logic → contiguous 1..80 coverage under both dispatch keys), audit invariant 6 (`expectedTotal` pins Large001's never-before-verified plugin-level count) +3. Eiger TEST_Fs — spec invariant 3 (real bslz4 + external-link multi-datafile under ≥16 threads) +4. Large001 TEST_F — spec invariant 2 (worker coverage) + audit invariant 3 (80-frame Helgrind budget cap) + +### Verification (executed 2026-08-18 in a scratch copy outside the repo — the repo's src/ untouched per skill rule 3) +- Build: clean compile, Release, master toolchain ✓ +- ctest: `Test_XdsPluginConcurrent` → **4 tests ran, all passed, 2.6 s total** (Large001 cap keeps it cheap) ✓ +- Large001 plugin-level `number_of_frames == 5000` — **empirically confirmed** (was audit invariant 6's unknown) ✓ +- Bit-exact regression: no production change; `.so` byte-identical — trivially satisfied (skill rule 5's TSan/Helgrind/bit-exact trio applies to threading-state changes; this ticket only *observes*; the TSan+Helgrind signal lands via NEGGIA-003's lanes once both are merged) +- ABI: untouched ✓ + +### Verdict +- **READY_FOR_HUMAN_APPLY** (apply the patch on this branch, push — the PR's CI incl. the NEGGIA-003 lanes, if merged first, is the final gate) diff --git a/docs/patches/NEGGIA-004.patch b/docs/patches/NEGGIA-004.patch new file mode 100644 index 0000000..13485c0 --- /dev/null +++ b/docs/patches/NEGGIA-004.patch @@ -0,0 +1,216 @@ +--- a/src/dectris/neggia/test/Test_XdsPluginConcurrent.cpp ++++ b/src/dectris/neggia/test/Test_XdsPluginConcurrent.cpp +@@ -8,6 +8,12 @@ + // Helgrind. Both expected to report zero races / zero errors because each + // worker's H5DataCache is thread-confined post-NEGGIA-001 (per-worker + // ownership; dispatch by frame_number % NUM_WORKERS in plugin_get_data). ++// ++// NEGGIA-004 extends the suite: the shared stress body runStressOn() runs ++// against real bslz4 external-link multi-datafile masters (eiger1/2) and ++// against Large001 frames 1..80 for full worker-slot coverage (80 = 16 ++// workers x 5 frames/dataset -- covers every slot under BOTH frame-modulo ++// and NEGGIA-005's planned dataset-index dispatch). + + #include + #include +@@ -18,6 +24,7 @@ + #include + #include + #include ++#include + #include + #include "DatasetsFixture.h" + +@@ -68,78 +75,122 @@ + plugin_close_file close_file; + int error_flag; + int info_array[1024]; ++ ++ // NEGGIA-004: shared stress body. capFrames > 0 limits the tested range ++ // to frames 1..capFrames (contiguous -- deterministic worker-slot ++ // coverage); expectedTotal > 0 pins the master's plugin-level frame ++ // count (Large001 was never opened at plugin level before). ++ void runStressOn(const std::string& masterPath, ++ int capFrames, ++ int expectedTotal) { ++ // 1. Open and read header (single-threaded — happens-before any concurrent ++ // get_data calls per std::thread's spawn-creates-happens-before rule). ++ open_file(masterPath.c_str(), info_array, &error_flag); ++ ASSERT_EQ(error_flag, 0); ++ ++ int nx, ny, nbytes, total_frames; ++ float qx, qy; ++ get_header(&nx, &ny, &nbytes, &qx, &qy, &total_frames, info_array, ++ &error_flag); ++ ASSERT_EQ(error_flag, 0); ++ ASSERT_GT(total_frames, 0); ++ if (expectedTotal > 0) { ++ ASSERT_EQ(total_frames, expectedTotal); ++ } ++ int n_test = total_frames; ++ if (capFrames > 0) { ++ ASSERT_GE(total_frames, capFrames); ++ n_test = capFrames; ++ } ++ const size_t frame_pixels = (size_t)nx * (size_t)ny; ++ ++ // 2. Build single-threaded reference: read every available frame once, ++ // serialise the int32 buffer into `reference[frame_index]`. ++ std::vector> reference(n_test); ++ for (int f = 0; f < n_test; ++f) { ++ reference[f].resize(frame_pixels); ++ int frame_number = f + 1; // plugin uses 1-indexed frame numbers ++ get_data(&frame_number, &nx, &ny, reference[f].data(), info_array, ++ &error_flag); ++ ASSERT_EQ(error_flag, 0) << "reference read failed at frame " << frame_number; ++ } ++ ++ // 3. Spawn NUM_THREADS workers; each calls plugin_get_data ++ // CALLS_PER_THREAD times with frame numbers picked from a per-thread ++ // deterministic random sequence (different seed per thread so frame ++ // orderings differ across threads). ++ std::atomic mismatch_count{0}; ++ std::atomic error_count{0}; ++ std::vector workers; ++ workers.reserve(NUM_THREADS); ++ for (int t = 0; t < NUM_THREADS; ++t) { ++ workers.emplace_back([&, t]() { ++ std::mt19937 rng(0xC0FFEE ^ t); // per-thread deterministic seed ++ std::uniform_int_distribution frame_dist(1, n_test); ++ std::vector buffer(frame_pixels); ++ int thread_info[1024]; ++ std::memset(thread_info, 0, sizeof(thread_info)); ++ int thread_error = 0; ++ for (int call = 0; call < CALLS_PER_THREAD; ++call) { ++ int frame_number = frame_dist(rng); ++ get_data(&frame_number, &nx, &ny, buffer.data(), thread_info, ++ &thread_error); ++ if (thread_error != 0) { ++ ++error_count; ++ return; ++ } ++ const auto& ref = reference[frame_number - 1]; ++ if (!std::equal(buffer.begin(), buffer.end(), ref.begin())) { ++ ++mismatch_count; ++ return; ++ } ++ } ++ }); ++ } ++ for (auto& w : workers) { ++ w.join(); ++ } ++ ++ EXPECT_EQ(error_count.load(), 0) ++ << "one or more workers reported a plugin error"; ++ EXPECT_EQ(mismatch_count.load(), 0) ++ << "one or more workers read frame data that differed from " ++ "single-threaded reference"; ++ ++ // 4. Close (single-threaded — happens-after all worker joins). ++ close_file(&error_flag); ++ ASSERT_EQ(error_flag, 0); ++ } + }; + +-TEST_F(TestXdsPluginConcurrent, ConcurrentGetDataMatchesSingleThreadedReference) { +- // 1. Open and read header (single-threaded — happens-before any concurrent +- // get_data calls per std::thread's spawn-creates-happens-before rule). +- open_file(getPathToSourceFile().c_str(), info_array, &error_flag); +- ASSERT_EQ(error_flag, 0); +- +- int nx, ny, nbytes, total_frames; +- float qx, qy; +- get_header(&nx, &ny, &nbytes, &qx, &qy, &total_frames, info_array, +- &error_flag); +- ASSERT_EQ(error_flag, 0); +- ASSERT_GT(total_frames, 0); +- const size_t frame_pixels = (size_t)nx * (size_t)ny; +- +- // 2. Build single-threaded reference: read every available frame once, +- // serialise the int32 buffer into `reference[frame_index]`. +- std::vector> reference(total_frames); +- for (int f = 0; f < total_frames; ++f) { +- reference[f].resize(frame_pixels); +- int frame_number = f + 1; // plugin uses 1-indexed frame numbers +- get_data(&frame_number, &nx, &ny, reference[f].data(), info_array, +- &error_flag); +- ASSERT_EQ(error_flag, 0) << "reference read failed at frame " << frame_number; +- } +- +- // 3. Spawn NUM_THREADS workers; each calls plugin_get_data +- // CALLS_PER_THREAD times with frame numbers picked from a per-thread +- // deterministic random sequence (different seed per thread so frame +- // orderings differ across threads). +- std::atomic mismatch_count{0}; +- std::atomic error_count{0}; +- std::vector workers; +- workers.reserve(NUM_THREADS); +- for (int t = 0; t < NUM_THREADS; ++t) { +- workers.emplace_back([&, t]() { +- std::mt19937 rng(0xC0FFEE ^ t); // per-thread deterministic seed +- std::uniform_int_distribution frame_dist(1, total_frames); +- std::vector buffer(frame_pixels); +- int thread_info[1024]; +- std::memset(thread_info, 0, sizeof(thread_info)); +- int thread_error = 0; +- for (int call = 0; call < CALLS_PER_THREAD; ++call) { +- int frame_number = frame_dist(rng); +- get_data(&frame_number, &nx, &ny, buffer.data(), thread_info, +- &thread_error); +- if (thread_error != 0) { +- ++error_count; +- return; +- } +- const auto& ref = reference[frame_number - 1]; +- if (!std::equal(buffer.begin(), buffer.end(), ref.begin())) { +- ++mismatch_count; +- return; +- } +- } +- }); +- } +- for (auto& w : workers) { +- w.join(); +- } +- +- EXPECT_EQ(error_count.load(), 0) +- << "one or more workers reported a plugin error"; +- EXPECT_EQ(mismatch_count.load(), 0) +- << "one or more workers read frame data that differed from " +- "single-threaded reference"; +- +- // 4. Close (single-threaded — happens-after all worker joins). +- close_file(&error_flag); +- ASSERT_EQ(error_flag, 0); ++// Original NEGGIA-001 case: 5-frame synthetic master (slots 1-5 only). ++TEST_F(TestXdsPluginConcurrent, ++ ConcurrentGetDataMatchesSingleThreadedReference) { ++ runStressOn(getPathToSourceFile(), 0, 0); ++} ++ ++// NEGGIA-004: real bslz4 compression + external-link multi-datafile layout ++// under 16-thread stress -- the concurrency surface NEGGIA-005/007 change. ++TEST_F(TestXdsPluginConcurrent, Eiger1Bslz4MultiDatafileConcurrent) { ++ runStressOn( ++ "h5-testfiles/datasets_eiger1/" ++ "eiger1_testmode10_2datafiles_4images_bslz4_master.h5", ++ 0, 4); ++} ++ ++TEST_F(TestXdsPluginConcurrent, Eiger2Bslz4Uint32MultiDatafileConcurrent) { ++ runStressOn( ++ "h5-testfiles/datasets_eiger2/" ++ "eiger2_simread7_2datafiles_4images_bslz4_uint32_master.h5", ++ 0, 4); ++} ++ ++// NEGGIA-004: full worker-slot coverage on Large001 (1000 datasets x 5 ++// frames). Frames 1..80 contiguous cover all 16 slots under the current ++// frame-modulo dispatch AND under dataset-index dispatch (datasets 0..15). ++TEST_F(TestXdsPluginConcurrent, Large001AllWorkerSlotsCovered) { ++ runStressOn("h5-testfiles/dataset_artificial_large_001/test_master.h5", ++ 16 * 5, 5000); + } + + int main(int argc, char** argv) { diff --git a/docs/specs/NEGGIA-004.md b/docs/specs/NEGGIA-004.md new file mode 100644 index 0000000..022b9d8 --- /dev/null +++ b/docs/specs/NEGGIA-004.md @@ -0,0 +1,44 @@ +# Spec: NEGGIA-004 — Concurrent-test fixture upgrade + +## Goal +Make the concurrency stress test observe the code that the Tier-1 tickets actually change. Today `Test_XdsPluginConcurrent` stresses a 5-frame, 11×13 px synthetic fixture: only pool slots 1–5 of 16 ever serve a frame, and no real compression (bslz4/lz4) or multi-data-file external-link resolution runs under threads. After this ticket the test is parameterized over the real Eiger fixtures (`datasets_eiger1/2`) and over a ≥32-frame synthetic case, with bit-equality asserts preserved, so NEGGIA-005/006/007's TSan/Helgrind gates observe the changed paths. + +## Affected Source +- `src/dectris/neggia/test/Test_XdsPluginConcurrent.cpp` — parameterization + worker-coverage assert +- `src/dectris/neggia/test/DatasetsFixture.{h,cpp}` — fixture plumbing (eiger paths; ≥32-frame synthetic source TBD by audit) +- `src/dectris/neggia/test/CMakeLists.txt` — wiring if new test targets/definitions are needed +- `CHANGELOG.md` — `[Unreleased]` → Added + +## Invariants +1. **ABI untouched / zero production diff:** no file under `src/dectris/neggia/{plugin,user,data,compression_algorithms}/` changes — `git diff --name-only master... | grep -vE '^(src/dectris/neggia/test/|CHANGELOG|docs/)'` is empty; `nm -D` 4-symbol parity trivially preserved — source of truth: `docs/abi-baseline.txt` +2. **Worker coverage:** under the ≥32-frame case, the stress run exercises **every pool slot** — asserted via "each of the K workers serves ≥ 1 frame", expressed against the dispatch function's observable behavior (frame count ≥ 2K with contiguous 1-based frame numbers guarantees it for modulo dispatch), NOT by hardcoding `% 16` — source of truth: ticket Notes (NEGGIA-005 will change the dispatch key to dataset-index; the assert must survive that) +3. **Real-path coverage:** at least one parameterized case runs bslz4-compressed, external-link, multi-data-file data (`datasets_eiger1` or `2`) under ≥16 concurrent threads with bit-equality asserts against single-threaded reference reads +4. **Bit-equality preserved:** every parameterized case asserts byte-identical frames between concurrent and single-threaded reads (memcmp over nx·ny·nbytes) +5. **Thread-safety exercise contract:** test-side threads only (`std::thread`, existing pattern); the test remains data-race-free under TSan and Helgrind (NEGGIA-003 lanes) — the plugin's guarantee under test is: concurrent `plugin_get_data` calls, after single-threaded `plugin_open`+`plugin_get_header`, are data-race-free and bit-correct +6. **Cap:** test + fixture diff ≤ 50 cap units (projection ~30); test files are test-scope but this ticket declares the cap anyway to keep Wave-1 discipline uniform +7. **Suite integrity:** all pre-existing ctest cases pass unmodified; total ctest count grows only by the new parameterized instances + +## Acceptance Tests +1. **Parameterized concurrent stress green** — type: ctest + - Action: `ctest -R Test_XdsPluginConcurrent --output-on-failure` + - Expected: exit 0; log shows the eiger1, eiger2, and ≥32-frame synthetic instances all ran +2. **Worker coverage assert active** — type: ctest + - Action: same run + - Expected: the ≥32-frame case's coverage assertion executes and passes (all K slots served ≥1 frame) +3. **TSan/Helgrind on upgraded fixtures** — type: TSan + Helgrind + - Setup: NEGGIA-003 lanes merged (soft ordering — whichever merges second delivers this signal) + - Expected: both lanes green with the parameterized test included +4. **Bit-exact regression unaffected** — type: bit-exact + - Action: `tools/regress_bitexact.sh ` (both = master build; no production change) + - Expected: exit 0 — trivially, since the `.so` is unchanged; guards against accidental production edits + +## Out of Scope +- Any production source change (dispatch-key change to dataset-index is NEGGIA-005) +- New compression codecs or HDF5-format fixtures beyond what h5-testfiles already provides +- Benchmarking (NEGGIA-002) and CI lane definitions (NEGGIA-003) +- Extending `tools/regress_bitexact.sh` + +## Open Questions +1. Does an existing fixture already provide ≥32 frames in one master (e.g. `TestDatasetArtificialLarge001` — 1000 datasets × how many frames)? If yes, parameterize over it; if no, decide generation route (extend h5-testfiles submodule — who owns that repo per `.gitmodules` — vs. a checked-in generator needing h5py in CI) — audit resolves with fixture inventory +2. How do the eiger-data tests obtain dataset paths today (compile definitions in test/CMakeLists.txt? runtime discovery?) — audit quotes the pattern to reuse +3. Is there an existing gtest value-parameterized (TEST_P/INSTANTIATE_TEST_SUITE_P) precedent in the suite, or is this the first? — audit checks; if first, keep the parameterization minimal (fixture-path array + loop is acceptable under C++11/gtest-vendored constraints)