diff --git a/.gitattributes b/.gitattributes index efdba87..69994bb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ * text=auto *.sh text eol=lf +*.root binary diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c5ad30..88499f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,8 @@ Thank you for your interest in contributing! As part of the SHiP Collaboration, pixi run test ``` During iteration, `pixi run build` rebuilds incrementally without re-running ctest. -8. **Submission**: Open a Pull Request against the `main` branch. Ensure the CI passes. +8. **Schema changes**: The persistent schema is guarded by the backward-compatibility suite (see `tests/data/README.md`). If the `schema_snapshot` test fails after an intentional change, regenerate the gates in the same PR with `pixi run update-schema-snapshot` and `pixi run update-reference-head`; if a `compat_read_v*` test fails, your change breaks reading of existing data — make it compatible or mark the commit as a breaking change and update the reader expectations. The snapshot may also legitimately change on ROOT version bumps (type-name normalization); regenerate it in the lock-update PR after reviewing the diff. Never modify the frozen `tests/data/reference_v*.root` files. +9. **Submission**: Open a Pull Request against the `main` branch. Ensure the CI passes. ## Licensing diff --git a/README.md b/README.md index 36bb94b..34a3bc5 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,15 @@ find_package(SHiPDataModel REQUIRED) target_link_libraries(your_target PRIVATE SHiP::SHiPDataModel) ``` +## Backward compatibility + +Files written by released versions must stay readable: CI reads a frozen +reference RNTuple file per release (`tests/data/reference_v*.root`) with the +current code, and a committed schema snapshot fails CI on any schema change +until it is deliberately regenerated. See +[`tests/data/README.md`](tests/data/README.md) for the policy and what to do +when these tests fail. + ## Dependencies - ROOT 6.36+ (Core, RIO) diff --git a/pixi.toml b/pixi.toml index 81f9ac5..72006db 100644 --- a/pixi.toml +++ b/pixi.toml @@ -57,9 +57,17 @@ root_cxx_standard = "==23" mp-units = ">=2.5" [tasks] -configure = "cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo" +# BUILD_TESTING is pinned on: this is the build directory the compat suite +# and scripts/release.sh drive, and passing -D here also resets a cache left +# at OFF by a packaging-style configure. +configure = "cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DBUILD_TESTING=ON" build = { cmd = "cmake --build build -j", depends-on = ["configure"] } test = { cmd = "ctest --test-dir build --output-on-failure", depends-on = ["build"] } +# Deliberate regeneration of the schema-change gates; run these (and commit +# the results) in the same PR as an intentional schema change. See +# tests/data/README.md. +update-schema-snapshot = { cmd = "build/tests/schema_snapshot --update tests/data/schema_snapshot.txt", depends-on = ["build"] } +update-reference-head = { cmd = "build/tests/write_reference tests/data/reference_head.root", depends-on = ["build"] } [feature.lint.dependencies] prek = "*" diff --git a/scripts/backfill_reference_files.sh b/scripts/backfill_reference_files.sh new file mode 100755 index 0000000..3c8a3fb --- /dev/null +++ b/scripts/backfill_reference_files.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-3.0-or-later +# Copyright (C) CERN for the benefit of the SHiP Collaboration +# +# One-time backfill of the frozen compatibility reference files for releases +# that predate the compatibility suite (see tests/data/README.md). For each +# tag: build the tag's dictionary library, compile the CURRENT +# tests/write_reference.cpp against the TAG's headers (the old headers define +# the on-disk shape; reference_values.hpp adapts via __has_include and member +# detection), and write tests/data/reference_.root. +# +# Run from the repo root inside the pixi environment: +# pixi run bash scripts/backfill_reference_files.sh +# +# Kept for provenance; releases from v0.5.0 on write their reference file at +# release time via scripts/release.sh instead. + +set -euo pipefail + +REPO="$(git rev-parse --show-toplevel)" +mkdir -p "${REPO}/tests/data" + +for tag in v0.1.0 v0.2.0 v0.3.0 v0.4.0; do + out="${REPO}/tests/data/reference_${tag}.root" + if [[ -e "${out}" ]]; then + echo "skip ${tag}: ${out} already exists (frozen files are never rewritten)" + continue + fi + wt="$(mktemp -d)/data-model-${tag}" + tmp="${out}.tmp" + # Never leave a partial file at the frozen path (the skip-check above + # would treat it as frozen) or a registered worktree behind on failure. + trap 'rm -f "${tmp}"; git worktree remove --force "${wt}" 2>/dev/null || true' EXIT + git worktree add "${wt}" "${tag}" + cmake -S "${wt}" -B "${wt}/build" -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo -DBUILD_TESTING=OFF + cmake --build "${wt}/build" -j + # Tag headers first on the include path; reference_values.hpp resolves + # next to write_reference.cpp, i.e. to the current repo's copy. + "${CXX:-g++}" -std=c++23 -I "${wt}/include" $(root-config --cflags) \ + "${REPO}/tests/write_reference.cpp" -o "${wt}/build/write_reference" \ + $(root-config --libs) -lROOTNTuple \ + -L "${wt}/build" -Wl,--no-as-needed -lSHiPDataModel -Wl,--as-needed \ + -Wl,-rpath,"${wt}/build" + "${wt}/build/write_reference" "${tmp}" + mv "${tmp}" "${out}" + git worktree remove --force "${wt}" + trap - EXIT +done diff --git a/scripts/release.sh b/scripts/release.sh index 0282b21..fb3fe6c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -17,9 +17,12 @@ The script must be run from a clean working tree. It will: 1. bump the VERSION line in CMakeLists.txt 2. bump version and date-released in CITATION.cff (if present) 3. bump the [package] version in pixi.toml (if present) - 4. regenerate CHANGELOG.md with `git cliff --tag v` - 5. create commit `chore(release): v` - 6. create annotated tag `v` + 4. write the frozen compatibility reference file + tests/data/reference_v.root, refresh the schema snapshot and + run the test suite (requires pixi) + 5. regenerate CHANGELOG.md with `git cliff --tag v` + 6. create commit `chore(release): v` + 7. create annotated tag `v` Pushing is left to the operator: git push origin && git push origin v @@ -110,9 +113,47 @@ if [[ -f "${PIXI_FILE}" ]] && grep -qE '^version = "[0-9]+\.[0-9]+\.[0-9]+"' "${ fi fi +# Freeze the compatibility reference file for this release: written from +# exactly the code being tagged, with the ROOT version pinned in pixi.lock at +# this moment — capturing both the schema and the writing ROOT (see +# tests/data/README.md). The snapshot regen should be a no-op if CI was +# green; the test run gates the release on the compat suite, including the +# new file (picked up via the CMake glob). +if ! command -v pixi >/dev/null 2>&1; then + echo "error: 'pixi' not available; needed to write the compatibility reference file" >&2 + exit 69 +fi +REF_FILE="tests/data/reference_${TAG}.root" +if [[ -e "${REF_FILE}" ]]; then + echo "error: ${REF_FILE} already exists; frozen reference files are never rewritten" >&2 + echo "hint: a file left over from an interrupted release attempt is safe to remove" >&2 + exit 65 +fi +pixi run build +# The compat suite is only built when build/ was configured with testing on; +# fail here rather than on a bare "No such file" further down. +if [[ ! -x ./build/tests/write_reference ]]; then + echo "error: ./build/tests/write_reference missing; reconfigure build/ with BUILD_TESTING=ON" >&2 + exit 70 +fi +# Write atomically: never leave a partial file at the frozen path (the +# exists-check above would otherwise block a retry after a failed write). +REF_TMP="${REF_FILE}.tmp" +# The file stays provisional until the snapshot regen and the test suite have +# both passed: on failure remove it, together with any partial temporary, so +# no unvalidated file is left behind and the exists-check above does not block +# a retry. It has to sit at its frozen path while the tests run, because the +# compat suite discovers reference files through the CMake glob. +trap 'rm -f "${REF_TMP}" "${REF_FILE}"' EXIT +pixi run ./build/tests/write_reference "${REF_TMP}" +mv "${REF_TMP}" "${REF_FILE}" +pixi run update-schema-snapshot +pixi run test +trap - EXIT # validated from here on: keep the frozen file + git cliff --tag "${TAG}" -o CHANGELOG.md -git add "${CMAKE_FILE}" CHANGELOG.md +git add "${CMAKE_FILE}" CHANGELOG.md "${REF_FILE}" tests/data/schema_snapshot.txt if [[ -f "${CITATION_FILE}" ]]; then git add "${CITATION_FILE}" fi diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5d8e416..3f997c0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,3 +23,67 @@ add_test(NAME rntuple_io COMMAND test_rntuple_io) add_executable(test_units test_units.cpp) target_link_libraries(test_units PRIVATE SHiP::SHiPUnits) add_test(NAME units COMMAND test_units) + +# --- Backward-compatibility suite (see data/README.md) --- + +add_executable(write_reference write_reference.cpp) +target_link_libraries( + write_reference + PRIVATE ${link_dictionary} ROOT::Core ROOT::RIO ROOT::ROOTNTuple +) + +add_executable(test_read_reference test_read_reference.cpp) +target_link_libraries( + test_read_reference + PRIVATE ${link_dictionary} ROOT::Core ROOT::RIO ROOT::ROOTNTuple +) + +# One ctest test per committed reference file, for clear failure attribution. +# CONFIGURE_DEPENDS: a file added at release time is picked up on reconfigure. +file( + GLOB reference_files + CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/data/reference_*.root" +) +foreach(ref IN LISTS reference_files) + if(ref MATCHES "reference_(v[0-9]+\\.[0-9]+\\.[0-9]+|head)\\.root$") + add_test( + NAME compat_read_${CMAKE_MATCH_1} + COMMAND test_read_reference ${ref} ${CMAKE_MATCH_1} + ) + endif() +endforeach() + +# The snapshot tool needs the list of dictionary classes; derive it from +# LinkDef.h so newly linked classes enter the snapshot automatically. +file( + STRINGS ${PROJECT_SOURCE_DIR}/include/SHiP/LinkDef.h + linkdef_lines + REGEX "pragma link C\\+\\+ class SHiP::" +) +set(dictionary_classes "") +foreach(line IN LISTS linkdef_lines) + if(NOT line MATCHES "std::vector") + string(REGEX MATCH "SHiP::[A-Za-z0-9_]+" cls "${line}") + string(APPEND dictionary_classes "\"${cls}\",\n") + endif() +endforeach() +file( + CONFIGURE + OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/dictionary_classes.inc + CONTENT "${dictionary_classes}" + @ONLY +) + +add_executable(schema_snapshot schema_snapshot.cpp) +target_include_directories(schema_snapshot PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) +target_link_libraries( + schema_snapshot + PRIVATE ${link_dictionary} ROOT::Core ROOT::RIO ROOT::ROOTNTuple +) +add_test( + NAME schema_snapshot + COMMAND + schema_snapshot --check + ${CMAKE_CURRENT_SOURCE_DIR}/data/schema_snapshot.txt +) diff --git a/tests/data/README.md b/tests/data/README.md new file mode 100644 index 0000000..c763eaa --- /dev/null +++ b/tests/data/README.md @@ -0,0 +1,73 @@ +# Backward-compatibility reference data + +This directory holds the on-disk compatibility contract of the SHiP data +model. Two independent safety nets are exercised by ctest (part of +`pixi run test` and CI): + +1. **Compat read tests** (`compat_read_`): every reference file + must be readable with the *current* library, materialized into the + current structs, with all values matching the canonical recipe. Members + that did not exist in the writing version must read back + default-initialized (ROOT RNTuple automatic schema evolution). +2. **Schema snapshot** (`schema_snapshot`): `schema_snapshot.txt` is a text + dump of the persistent schema (TClass layout of every dictionary class + + the RNTuple field tree). CI fails on *any* schema change — even a + backward-compatible one — until the snapshot is deliberately regenerated + in the same PR, so every schema change is a conscious, reviewable + decision. + +## Files + +- `reference_v.root` — frozen forever, **never modified or + regenerated**. Written at release time by `scripts/release.sh` from + exactly the tagged code with the ROOT version pinned in `pixi.lock` at + that moment, so it captures both the schema and the writing ROOT. + Exception: the files for v0.1.0–v0.4.0 predate this suite and were + backfilled with `scripts/backfill_reference_files.sh` — they were written + by each tag's *headers* (defining the on-disk schema) but by ROOT 6.40.02, + not the historical ROOT versions. +- `reference_head.root` — tracks `main`; asserts "current code reads the + current schema". Regenerated in the same PR as any event-model change. +- `schema_snapshot.txt` — committed schema dump, see above. + +Each file is an RNTuple named `events` with 2 entries and top-level fields +`event_header` (v0.4.0+), `mcParticles`, `simHits`, `simParticles`, +`recParticles`, `simResult`. + +## When a compat test fails in your PR + +- `schema_snapshot` fails, `compat_read_*` pass: you changed the persistent + schema in a backward-compatible way (e.g. added a member). If intentional, + run and commit in the same PR: + + ```sh + pixi run update-schema-snapshot + pixi run update-reference-head + ``` + + This is at least a **minor** version bump. +- `compat_read_v*` fails: your change breaks reading of existing files + (e.g. renaming a member silently drops its on-disk values — RNTuple + matches members by name). Either make the change compatible (e.g. an + [I/O customization rule](https://root.cern/doc/master/md_tree_2ntuple_2doc_2SchemaEvolution.html) + mapping the old name), or accept it as a **breaking change**: mark the + commit `!`/`BREAKING CHANGE` (major version bump) and adjust the + expectations in `tests/test_read_reference.cpp` (masking table) — never by + editing the frozen files. +- `compat_read_head` fails but frozen versions pass: the current schema and + `reference_head.root` are out of sync — run `pixi run + update-reference-head` (plus the snapshot) in this PR. + +## Value recipe + +Expected values are defined in `tests/reference_values.hpp` and are part of +the contract (a future non-C++ reader can check against the same formulas). +For members that existed at v0.1.0 they equal the `SHiP::test::make*` +generators in `tests/test_utils.hpp`; members added later have their own +formulas there (e.g. `SimHit::geometryNodeId = 900 + 7*i + offset`, +`RecParticle::hits` filled from `makeSimHits(offset + 2)`). Per entry +`e` (0-based): field offsets are `e` for the top-level collections and +`e + 5` inside `simResult`; each collection has 3 elements. A reference file +written by version V contains values for exactly the members existing in V; +newer members read back default-initialized and are masked accordingly in +`test_read_reference.cpp`. diff --git a/tests/data/reference_head.root b/tests/data/reference_head.root new file mode 100644 index 0000000..e2b851b Binary files /dev/null and b/tests/data/reference_head.root differ diff --git a/tests/data/reference_v0.1.0.root b/tests/data/reference_v0.1.0.root new file mode 100644 index 0000000..a4f1d28 Binary files /dev/null and b/tests/data/reference_v0.1.0.root differ diff --git a/tests/data/reference_v0.2.0.root b/tests/data/reference_v0.2.0.root new file mode 100644 index 0000000..acc3ee9 Binary files /dev/null and b/tests/data/reference_v0.2.0.root differ diff --git a/tests/data/reference_v0.3.0.root b/tests/data/reference_v0.3.0.root new file mode 100644 index 0000000..21e91c4 Binary files /dev/null and b/tests/data/reference_v0.3.0.root differ diff --git a/tests/data/reference_v0.4.0.root b/tests/data/reference_v0.4.0.root new file mode 100644 index 0000000..7f0b7b0 Binary files /dev/null and b/tests/data/reference_v0.4.0.root differ diff --git a/tests/data/schema_snapshot.txt b/tests/data/schema_snapshot.txt new file mode 100644 index 0000000..870d7a9 --- /dev/null +++ b/tests/data/schema_snapshot.txt @@ -0,0 +1,269 @@ +# TClass layout (version, checksum, data members) +class SHiP::EventHeader version=-1 checksum=0xd65dd49a + double weight + long original_event_id +class SHiP::MCParticle version=-1 checksum=0x1717705a + int pdgCode + array vertex + array momentum + double energy + double time + int motherId + int status +class SHiP::SimHit version=-1 checksum=0xe91d6376 + int detectorId + int geometryNodeId + int trackId + int pdgCode + array position + array momentum + double energyDeposit + double time + double pathLength +class SHiP::SimParticle version=-1 checksum=0xf19644a0 + int trackId + int parentId + int pdgCode + array vertex + array endpoint + array momentum + double energy + double time + int creatorProcess +class SHiP::SimResult version=-1 checksum=0xbb708e7a + vector hits + vector particles +class SHiP::RecHit version=-1 checksum=0xe0db3d09 + int detectorId + int trackId + int pdgCode + array position + array momentum + double energyDeposit + double time + double pathLength +class SHiP::UBTHit version=-1 checksum=0xf8add1b2 + SHiP::RecHit recHit +class SHiP::SBTHit version=-1 checksum=0x21f0011c + SHiP::RecHit recHit +class SHiP::TimeDetHit version=-1 checksum=0xb309a35b + SHiP::RecHit recHit +class SHiP::StrawTubesHit version=-1 checksum=0x2d7ed115 + SHiP::RecHit recHit +class SHiP::CaloHit version=-1 checksum=0xfc234ecc + SHiP::RecHit recHit +class SHiP::TrackFitResult version=-1 checksum=0xd1ab3864 + int nMeas + int fitStatus + double chi2 + int ndf + double qoverp + double phi + double theta + double time + array refLoc + vector inputMeasurementsX + vector inputMeasurementsY + vector fittedMeasurementsX + vector fittedMeasurementsY + vector residualsX + vector residualsY +class SHiP::RecParticle version=-1 checksum=0xe90ee6a7 + int trackId + int parentId + int pdgCode + array vertex + array endpoint + array momentum + vector hits + double energy + double time + int creatorProcess + double ipPV +# RNTuple field tree (one top-level field per dictionary class) +SHiP__EventHeader : SHiP::EventHeader (typever=4294967295, checksum=0xd65dd49a) + weight : double (typever=0) + original_event_id : std::int64_t (typever=0) +SHiP__MCParticle : SHiP::MCParticle (typever=4294967295, checksum=0x1717705a) + pdgCode : std::int32_t (typever=0) + vertex : std::array (typever=0) + _0 : double (typever=0) + momentum : std::array (typever=0) + _0 : double (typever=0) + energy : double (typever=0) + time : double (typever=0) + motherId : std::int32_t (typever=0) + status : std::int32_t (typever=0) +SHiP__SimHit : SHiP::SimHit (typever=4294967295, checksum=0xe91d6376) + detectorId : std::int32_t (typever=0) + geometryNodeId : std::int32_t (typever=0) + trackId : std::int32_t (typever=0) + pdgCode : std::int32_t (typever=0) + position : std::array (typever=0) + _0 : double (typever=0) + momentum : std::array (typever=0) + _0 : double (typever=0) + energyDeposit : double (typever=0) + time : double (typever=0) + pathLength : double (typever=0) +SHiP__SimParticle : SHiP::SimParticle (typever=4294967295, checksum=0xf19644a0) + trackId : std::int32_t (typever=0) + parentId : std::int32_t (typever=0) + pdgCode : std::int32_t (typever=0) + vertex : std::array (typever=0) + _0 : double (typever=0) + endpoint : std::array (typever=0) + _0 : double (typever=0) + momentum : std::array (typever=0) + _0 : double (typever=0) + energy : double (typever=0) + time : double (typever=0) + creatorProcess : std::int32_t (typever=0) +SHiP__SimResult : SHiP::SimResult (typever=4294967295, checksum=0xbb708e7a) + hits : std::vector (typever=0) + _0 : SHiP::SimHit (typever=4294967295, checksum=0xe91d6376) + detectorId : std::int32_t (typever=0) + geometryNodeId : std::int32_t (typever=0) + trackId : std::int32_t (typever=0) + pdgCode : std::int32_t (typever=0) + position : std::array (typever=0) + _0 : double (typever=0) + momentum : std::array (typever=0) + _0 : double (typever=0) + energyDeposit : double (typever=0) + time : double (typever=0) + pathLength : double (typever=0) + particles : std::vector (typever=0) + _0 : SHiP::SimParticle (typever=4294967295, checksum=0xf19644a0) + trackId : std::int32_t (typever=0) + parentId : std::int32_t (typever=0) + pdgCode : std::int32_t (typever=0) + vertex : std::array (typever=0) + _0 : double (typever=0) + endpoint : std::array (typever=0) + _0 : double (typever=0) + momentum : std::array (typever=0) + _0 : double (typever=0) + energy : double (typever=0) + time : double (typever=0) + creatorProcess : std::int32_t (typever=0) +SHiP__RecHit : SHiP::RecHit (typever=4294967295, checksum=0xe0db3d09) + detectorId : std::int32_t (typever=0) + trackId : std::int32_t (typever=0) + pdgCode : std::int32_t (typever=0) + position : std::array (typever=0) + _0 : double (typever=0) + momentum : std::array (typever=0) + _0 : double (typever=0) + energyDeposit : double (typever=0) + time : double (typever=0) + pathLength : double (typever=0) +SHiP__UBTHit : SHiP::UBTHit (typever=4294967295, checksum=0xf8add1b2) + recHit : SHiP::RecHit (typever=4294967295, checksum=0xe0db3d09) + detectorId : std::int32_t (typever=0) + trackId : std::int32_t (typever=0) + pdgCode : std::int32_t (typever=0) + position : std::array (typever=0) + _0 : double (typever=0) + momentum : std::array (typever=0) + _0 : double (typever=0) + energyDeposit : double (typever=0) + time : double (typever=0) + pathLength : double (typever=0) +SHiP__SBTHit : SHiP::SBTHit (typever=4294967295, checksum=0x21f0011c) + recHit : SHiP::RecHit (typever=4294967295, checksum=0xe0db3d09) + detectorId : std::int32_t (typever=0) + trackId : std::int32_t (typever=0) + pdgCode : std::int32_t (typever=0) + position : std::array (typever=0) + _0 : double (typever=0) + momentum : std::array (typever=0) + _0 : double (typever=0) + energyDeposit : double (typever=0) + time : double (typever=0) + pathLength : double (typever=0) +SHiP__TimeDetHit : SHiP::TimeDetHit (typever=4294967295, checksum=0xb309a35b) + recHit : SHiP::RecHit (typever=4294967295, checksum=0xe0db3d09) + detectorId : std::int32_t (typever=0) + trackId : std::int32_t (typever=0) + pdgCode : std::int32_t (typever=0) + position : std::array (typever=0) + _0 : double (typever=0) + momentum : std::array (typever=0) + _0 : double (typever=0) + energyDeposit : double (typever=0) + time : double (typever=0) + pathLength : double (typever=0) +SHiP__StrawTubesHit : SHiP::StrawTubesHit (typever=4294967295, checksum=0x2d7ed115) + recHit : SHiP::RecHit (typever=4294967295, checksum=0xe0db3d09) + detectorId : std::int32_t (typever=0) + trackId : std::int32_t (typever=0) + pdgCode : std::int32_t (typever=0) + position : std::array (typever=0) + _0 : double (typever=0) + momentum : std::array (typever=0) + _0 : double (typever=0) + energyDeposit : double (typever=0) + time : double (typever=0) + pathLength : double (typever=0) +SHiP__CaloHit : SHiP::CaloHit (typever=4294967295, checksum=0xfc234ecc) + recHit : SHiP::RecHit (typever=4294967295, checksum=0xe0db3d09) + detectorId : std::int32_t (typever=0) + trackId : std::int32_t (typever=0) + pdgCode : std::int32_t (typever=0) + position : std::array (typever=0) + _0 : double (typever=0) + momentum : std::array (typever=0) + _0 : double (typever=0) + energyDeposit : double (typever=0) + time : double (typever=0) + pathLength : double (typever=0) +SHiP__TrackFitResult : SHiP::TrackFitResult (typever=4294967295, checksum=0xd1ab3864) + nMeas : std::int32_t (typever=0) + fitStatus : std::int32_t (typever=0) + chi2 : double (typever=0) + ndf : std::int32_t (typever=0) + qoverp : double (typever=0) + phi : double (typever=0) + theta : double (typever=0) + time : double (typever=0) + refLoc : std::array (typever=0) + _0 : double (typever=0) + inputMeasurementsX : std::vector (typever=0) + _0 : double (typever=0) + inputMeasurementsY : std::vector (typever=0) + _0 : double (typever=0) + fittedMeasurementsX : std::vector (typever=0) + _0 : double (typever=0) + fittedMeasurementsY : std::vector (typever=0) + _0 : double (typever=0) + residualsX : std::vector (typever=0) + _0 : double (typever=0) + residualsY : std::vector (typever=0) + _0 : double (typever=0) +SHiP__RecParticle : SHiP::RecParticle (typever=4294967295, checksum=0xe90ee6a7) + trackId : std::int32_t (typever=0) + parentId : std::int32_t (typever=0) + pdgCode : std::int32_t (typever=0) + vertex : std::array (typever=0) + _0 : double (typever=0) + endpoint : std::array (typever=0) + _0 : double (typever=0) + momentum : std::array (typever=0) + _0 : double (typever=0) + hits : std::vector (typever=0) + _0 : SHiP::RecHit (typever=4294967295, checksum=0xe0db3d09) + detectorId : std::int32_t (typever=0) + trackId : std::int32_t (typever=0) + pdgCode : std::int32_t (typever=0) + position : std::array (typever=0) + _0 : double (typever=0) + momentum : std::array (typever=0) + _0 : double (typever=0) + energyDeposit : double (typever=0) + time : double (typever=0) + pathLength : double (typever=0) + energy : double (typever=0) + time : double (typever=0) + creatorProcess : std::int32_t (typever=0) + ipPV : double (typever=0) diff --git a/tests/reference_values.hpp b/tests/reference_values.hpp new file mode 100644 index 0000000..8b4bdcb --- /dev/null +++ b/tests/reference_values.hpp @@ -0,0 +1,142 @@ +#pragma once + +/// Canonical value recipe for the frozen compatibility reference files. +/// +/// This header must compile unmodified against the headers of every released +/// tag: headers added in later versions are guarded with __has_include, and +/// members added in later versions are guarded with `if constexpr (requires +/// ...)` inside function templates (a discarded branch of a non-template +/// function would still be type-checked against old headers). +/// +/// The values are part of the on-disk compatibility contract documented in +/// tests/data/README.md: for members that existed at v0.1.0 they are identical +/// to SHiP::test::make* in test_utils.hpp; members added later get their own +/// distinctive values here so that reference files exercise them. + +#include + +#include "SHiP/MCParticle.hpp" +#include "SHiP/RecParticle.hpp" +#include "SHiP/SimHit.hpp" +#include "SHiP/SimParticle.hpp" +#include "SHiP/SimResult.hpp" +#if __has_include("SHiP/EventHeader.hpp") +#include "SHiP/EventHeader.hpp" +#define SHIP_REF_HAS_EVENT_HEADER 1 +#endif + +namespace SHiP::ref { + +constexpr int kEntries = 2; + +template +std::vector makeMCParticles(int offset) { + std::vector v; + for (int i = 0; i < 3; ++i) { + Particle p; + p.pdgCode = 11 + 100 * i + offset; + p.vertex = {1.5 + i + offset, -2.25 + i, 3.75 + i}; + p.momentum = {0.125 + i, -0.25 + i, 40.5 + i + offset}; + p.energy = 40.625 + i + offset; + p.time = 0.375 + i; + p.motherId = i - 1; + p.status = 1 + i + offset; + v.push_back(p); + } + return v; +} + +template +std::vector makeSimHits(int offset) { + std::vector v; + for (int i = 0; i < 3; ++i) { + Hit h; + h.detectorId = 1000 + 10 * i + offset; + h.trackId = 42 + i + offset; + h.pdgCode = -13 + 2 * i; + h.position = {10.5 + i + offset, -20.25 + i, 3000.75 + i}; + h.momentum = {1.125 + i, -2.25 + i, 30.5 + i + offset}; + h.energyDeposit = 0.0625 + i + offset; + h.time = 25.375 + i; + h.pathLength = 0.5 + i + offset; + if constexpr (requires { h.geometryNodeId; }) { // added in v0.4.0 + h.geometryNodeId = 900 + 7 * i + offset; + } + v.push_back(h); + } + return v; +} + +template +std::vector makeSimParticles(int offset) { + std::vector v; + for (int i = 0; i < 3; ++i) { + Particle p; + p.trackId = 7 + i + offset; + p.parentId = 6 + i; + p.pdgCode = 211 - 2 * i + offset; + p.vertex = {0.5 + i + offset, -1.25 + i, 2.75 + i}; + p.endpoint = {100.5 + i, -200.25 + i + offset, 5000.75 + i}; + p.momentum = {3.125 + i, -4.25 + i, 50.5 + i + offset}; + p.energy = 51.625 + i + offset; + p.time = 12.375 + i; + p.creatorProcess = 2 + i + offset; + v.push_back(p); + } + return v; +} + +template +std::vector makeRecParticles(int offset) { + std::vector v; + for (auto const& sp : makeSimParticles(offset)) { + Particle p; + p.trackId = sp.trackId; + p.parentId = sp.parentId; + p.pdgCode = sp.pdgCode; + p.vertex = sp.vertex; + p.endpoint = sp.endpoint; + p.momentum = sp.momentum; + p.energy = sp.energy; + p.time = sp.time; + p.creatorProcess = sp.creatorProcess; + p.ipPV = 0.875 + sp.trackId; + if constexpr (requires { p.hits; }) { // added in v0.3.0 + using HitType = typename decltype(Particle{}.hits)::value_type; + for (auto const& sh : makeSimHits(offset + 2)) { + HitType rh; + rh.detectorId = sh.detectorId; + rh.trackId = sh.trackId; + rh.pdgCode = sh.pdgCode; + rh.position = sh.position; + rh.momentum = sh.momentum; + rh.energyDeposit = sh.energyDeposit; + rh.time = sh.time; + rh.pathLength = sh.pathLength; + p.hits.push_back(rh); + } + } + v.push_back(p); + } + return v; +} + +template +Result makeSimResult(int offset) { + Result r; + r.hits = makeSimHits(offset + 5); + r.particles = makeSimParticles(offset + 5); + return r; +} + +#ifdef SHIP_REF_HAS_EVENT_HEADER +template +Header makeEventHeader(int entry) { + Header h; + h.weight = 0.125 + entry; + h.original_event_id = 7000 + entry; + return h; +} +#endif + +} // namespace SHiP::ref diff --git a/tests/schema_snapshot.cpp b/tests/schema_snapshot.cpp new file mode 100644 index 0000000..e434e2e --- /dev/null +++ b/tests/schema_snapshot.cpp @@ -0,0 +1,178 @@ +/// Schema snapshot gate: dump the persistent schema of every dictionary class +/// (TClass layout + RNTuple field tree) and compare it against the committed +/// snapshot, so that ANY schema change — even a backward-compatible one — +/// fails CI until the snapshot is deliberately regenerated in the same PR. +/// +/// schema_snapshot --check exit 1 on mismatch (ctest) +/// schema_snapshot --update rewrite the snapshot +/// +/// The class list is generated at configure time from include/SHiP/LinkDef.h +/// (see tests/CMakeLists.txt), so newly linked classes enter the snapshot +/// automatically. The dump is deterministic for a given dictionary and ROOT +/// version; a ROOT version bump may change it (e.g. type-name normalization), +/// in which case the snapshot is regenerated in the same PR. +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ROOT/RField.hxx" +#include "ROOT/RNTupleDescriptor.hxx" +#include "ROOT/RNTupleModel.hxx" +#include "ROOT/RNTupleReader.hxx" +#include "ROOT/RNTupleWriter.hxx" +#include "TClass.h" +#include "TDataMember.h" +#include "TList.h" + +namespace { + +char const* const kDictionaryClasses[] = { +#include "dictionary_classes.inc" +}; + +constexpr char const* kProbeFileName = "schema_snapshot_probe_tmp.root"; + +void dumpTClassSection(std::ostream& os) { + os << "# TClass layout (version, checksum, data members)\n"; + for (auto const* name : kDictionaryClasses) { + auto* cls = TClass::GetClass(name); + if (cls == nullptr) { + os << "class " << name << " MISSING FROM DICTIONARY\n"; + continue; + } + os << "class " << name << " version=" << cls->GetClassVersion() + << " checksum=0x" << std::hex << cls->GetCheckSum() << std::dec << '\n'; + for (auto const* obj : *cls->GetListOfDataMembers()) { + auto const* member = static_cast(obj); + if (!member->IsPersistent()) { + continue; + } + os << " " << member->GetTrueTypeName() << ' ' << member->GetName() + << '\n'; + } + } +} + +void dumpField(ROOT::RNTupleDescriptor const& desc, ROOT::DescriptorId_t id, + int indent, std::ostream& os) { + auto const& field = desc.GetFieldDescriptor(id); + os << std::string(2 * indent, ' ') << field.GetFieldName() << " : " + << field.GetTypeName() << " (typever=" << field.GetTypeVersion(); + if (auto checksum = field.GetTypeChecksum()) { + os << ", checksum=0x" << std::hex << *checksum << std::dec; + } + os << ")\n"; + for (auto const& child : desc.GetFieldIterable(id)) { + dumpField(desc, child.GetId(), indent + 1, os); + } +} + +bool dumpRNTupleSection(std::ostream& os) { + os << "# RNTuple field tree (one top-level field per dictionary class)\n"; + auto model = ROOT::RNTupleModel::Create(); + for (auto const* name : kDictionaryClasses) { + std::string fieldName = name; + for (auto& c : fieldName) { + if (c == ':') { + c = '_'; + } + } + model->AddField(ROOT::RFieldBase::Create(fieldName, name).Unwrap()); + } + { + auto writer = ROOT::RNTupleWriter::Recreate(std::move(model), + "schema_probe", kProbeFileName); + if (!writer) { + std::cerr << "FAIL: cannot create probe RNTuple\n"; + return false; + } + } + auto reader = ROOT::RNTupleReader::Open("schema_probe", kProbeFileName); + if (!reader) { + std::cerr << "FAIL: cannot reopen probe RNTuple\n"; + return false; + } + auto const& desc = reader->GetDescriptor(); + for (auto const& field : desc.GetFieldIterable(desc.GetFieldZeroId())) { + dumpField(desc, field.GetId(), 0, os); + } + std::remove(kProbeFileName); + return true; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc != 3 || (std::string(argv[1]) != "--check" && + std::string(argv[1]) != "--update")) { + std::cerr << "usage: schema_snapshot --check|--update \n"; + return 64; + } + std::string const mode = argv[1]; + std::string const snapshotPath = argv[2]; + + std::ostringstream dump; + dumpTClassSection(dump); + if (!dumpRNTupleSection(dump)) { + return 1; + } + + if (mode == "--update") { + std::ofstream out{snapshotPath}; + out << dump.str(); + if (!out) { + std::cerr << "FAIL: cannot write " << snapshotPath << '\n'; + return 1; + } + std::cout << "schema snapshot updated: " << snapshotPath << '\n'; + return 0; + } + + std::ifstream in{snapshotPath}; + if (!in) { + std::cout << "FAIL: cannot read snapshot " << snapshotPath + << " — run: pixi run update-schema-snapshot\n"; + return 1; + } + std::stringstream committed; + committed << in.rdbuf(); + if (committed.str() == dump.str()) { + std::cout << "schema snapshot matches\n"; + return 0; + } + + std::istringstream expected{committed.str()}; + std::istringstream actual{dump.str()}; + std::string expectedLine; + std::string actualLine; + int lineNumber = 1; + while (true) { + bool const haveExpected = + static_cast(std::getline(expected, expectedLine)); + bool const haveActual = static_cast(std::getline(actual, actualLine)); + if (!haveExpected && !haveActual) { + break; + } + if (!haveExpected || !haveActual || expectedLine != actualLine) { + std::cout << "schema snapshot MISMATCH at line " << lineNumber << ":\n" + << " committed: " + << (haveExpected ? expectedLine : "") << '\n' + << " current: " + << (haveActual ? actualLine : "") << '\n'; + break; + } + ++lineNumber; + } + std::cout << "The persistent schema changed. If intentional, regenerate the\n" + "snapshot in this PR (and reference_head.root if the event\n" + "model changed):\n" + " pixi run update-schema-snapshot\n" + " pixi run update-reference-head\n" + "See tests/data/README.md for the compatibility policy.\n"; + return 1; +} diff --git a/tests/test_read_reference.cpp b/tests/test_read_reference.cpp new file mode 100644 index 0000000..373b2b7 --- /dev/null +++ b/tests/test_read_reference.cpp @@ -0,0 +1,173 @@ +/// Backward-compatibility test: read a frozen reference file written by an +/// older (or the current) version of the data model and compare against the +/// canonical value recipe, with expectations masked to the writing version +/// (members that did not exist on disk must read back default-initialized). +/// See tests/data/README.md for the compatibility policy. +#include +#include +#include +#include +#include +#include + +#include "ROOT/RNTupleReader.hxx" +#include "SHiP/EventHeader.hpp" +#include "SHiP/MCParticle.hpp" +#include "SHiP/RecParticle.hpp" +#include "SHiP/SimHit.hpp" +#include "SHiP/SimParticle.hpp" +#include "SHiP/SimResult.hpp" +#include "reference_values.hpp" +#include "test_utils.hpp" + +namespace { + +/// Writing version of a reference file; "head" sorts after every release. +struct Version { + int major{0}; + int minor{0}; + int patch{0}; + bool head{false}; + + friend bool operator<(Version const& a, Version const& b) { + if (a.head != b.head) { + return b.head; + } + if (a.major != b.major) { + return a.major < b.major; + } + if (a.minor != b.minor) { + return a.minor < b.minor; + } + return a.patch < b.patch; + } + friend bool operator>=(Version const& a, Version const& b) { + return !(a < b); + } +}; + +constexpr Version kV030{0, 3, 0}; +constexpr Version kV040{0, 4, 0}; + +bool parseVersion(std::string_view arg, Version& out) { + if (arg == "head") { + out = Version{0, 0, 0, true}; + return true; + } + int major = 0; + int minor = 0; + int patch = 0; + if (std::sscanf(arg.data(), "v%d.%d.%d", &major, &minor, &patch) != 3) { + return false; + } + out = Version{major, minor, patch}; + return true; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc != 3) { + std::cerr << "usage: test_read_reference \n"; + return 64; + } + std::string const file = argv[1]; + std::string const versionLabel = argv[2]; + Version version; + if (!parseVersion(versionLabel, version)) { + std::cerr << "FAIL: cannot parse version '" << versionLabel << "'\n"; + return 64; + } + + // Open WITHOUT an imposed model: the model is built from the on-disk + // descriptor and class fields are reconstructed from the current + // dictionary, so automatic schema evolution maps on-disk members by name + // and default-initializes members missing on disk. + auto reader = ROOT::RNTupleReader::Open("events", file); + if (!reader) { + std::cout << "FAIL: cannot open RNTuple 'events' in " << file << '\n'; + return 1; + } + auto const& desc = reader->GetDescriptor(); + + // Structural expectations: top-level fields present iff the writing + // version had them (also cross-checks the filename-derived version). + bool const hasEventHeader = + desc.FindFieldId("event_header") != ROOT::kInvalidDescriptorId; + bool ok = SHiP::test::check("event_header present iff >= v0.4.0", + version >= kV040, hasEventHeader); + for (auto const* name : {"mcParticles", "simHits", "simParticles", + "recParticles", "simResult"}) { + ok &= + SHiP::test::check(std::string(name) + " present", true, + desc.FindFieldId(name) != ROOT::kInvalidDescriptorId); + } + if (reader->GetNEntries() != SHiP::ref::kEntries) { + std::cout << "FAIL: expected " << SHiP::ref::kEntries << " entries, got " + << reader->GetNEntries() << '\n'; + return 1; + } + + auto const& entry = reader->GetModel().GetDefaultEntry(); + std::shared_ptr eventHeader; + if (hasEventHeader) { + eventHeader = entry.GetPtr("event_header"); + } + auto mcParticles = entry.GetPtr>("mcParticles"); + auto simHits = entry.GetPtr>("simHits"); + auto simParticles = + entry.GetPtr>("simParticles"); + auto recParticles = + entry.GetPtr>("recParticles"); + auto simResult = entry.GetPtr("simResult"); + + // Mask table: one entry per "member M introduced in version X" — members + // that did not exist in the writing version must read back as defaults. + auto maskSimHits = [&](std::vector& hits) { + if (version < kV040) { + for (auto& h : hits) { + h.geometryNodeId = 0; + } + } + }; + auto maskRecParticles = [&](std::vector& particles) { + if (version < kV030) { + for (auto& p : particles) { + p.hits.clear(); + } + } + }; + + for (int i = 0; i < SHiP::ref::kEntries; ++i) { + reader->LoadEntry(i); + std::string const suffix = + " (" + versionLabel + ", entry " + std::to_string(i) + ")"; + if (hasEventHeader) { + ok &= SHiP::test::check("EventHeader" + suffix, + SHiP::ref::makeEventHeader(i), *eventHeader); + } + ok &= SHiP::test::check("MCParticle" + suffix, + SHiP::ref::makeMCParticles(i), *mcParticles); + + auto expectedSimHits = SHiP::ref::makeSimHits(i); + maskSimHits(expectedSimHits); + ok &= SHiP::test::check("SimHit" + suffix, expectedSimHits, *simHits); + + ok &= SHiP::test::check("SimParticle" + suffix, + SHiP::ref::makeSimParticles(i), *simParticles); + + auto expectedRecParticles = SHiP::ref::makeRecParticles(i); + maskRecParticles(expectedRecParticles); + ok &= SHiP::test::check("RecParticle" + suffix, expectedRecParticles, + *recParticles); + + auto expectedSimResult = SHiP::ref::makeSimResult(i); + maskSimHits(expectedSimResult.hits); + ok &= + SHiP::test::check("SimResult" + suffix, expectedSimResult, *simResult); + } + std::cout << (ok ? "Compatibility read passed" + : "Compatibility read FAILED (see tests/data/README.md)") + << " for " << versionLabel << '\n'; + return ok ? 0 : 1; +} diff --git a/tests/write_reference.cpp b/tests/write_reference.cpp new file mode 100644 index 0000000..050d350 --- /dev/null +++ b/tests/write_reference.cpp @@ -0,0 +1,52 @@ +/// Write a frozen compatibility reference file (see tests/data/README.md). +/// +/// Version-agnostic: compiles against the headers of every released tag, so +/// the same source both backfills old-version reference files (built against +/// a tag's headers) and writes the current one at release time. +#include +#include +#include +#include + +#include "ROOT/RNTupleModel.hxx" +#include "ROOT/RNTupleWriter.hxx" +#include "reference_values.hpp" + +int main(int argc, char** argv) { + if (argc != 2) { + std::cerr << "usage: write_reference \n"; + return 64; + } + auto model = ROOT::RNTupleModel::Create(); +#ifdef SHIP_REF_HAS_EVENT_HEADER + auto eventHeader = model->MakeField("event_header"); +#endif + auto mcParticles = + model->MakeField>("mcParticles"); + auto simHits = model->MakeField>("simHits"); + auto simParticles = + model->MakeField>("simParticles"); + auto recParticles = + model->MakeField>("recParticles"); + auto simResult = model->MakeField("simResult"); + auto writer = + ROOT::RNTupleWriter::Recreate(std::move(model), "events", argv[1]); + if (!writer) { + std::cerr << "FAIL: cannot create RNTuple writer for " << argv[1] << '\n'; + return 1; + } + for (int entry = 0; entry < SHiP::ref::kEntries; ++entry) { +#ifdef SHIP_REF_HAS_EVENT_HEADER + *eventHeader = SHiP::ref::makeEventHeader(entry); +#endif + *mcParticles = SHiP::ref::makeMCParticles(entry); + *simHits = SHiP::ref::makeSimHits(entry); + *simParticles = SHiP::ref::makeSimParticles(entry); + *recParticles = SHiP::ref::makeRecParticles(entry); + *simResult = SHiP::ref::makeSimResult(entry); + writer->Fill(); + } + std::cout << "wrote " << SHiP::ref::kEntries << " entries to " << argv[1] + << '\n'; + return 0; +}