diff --git a/.github/workflows/contract-tests.yml b/.github/workflows/contract-tests.yml new file mode 100644 index 00000000..64ddd3b1 --- /dev/null +++ b/.github/workflows/contract-tests.yml @@ -0,0 +1,58 @@ +name: Contract suite — functional (blocking) + +# PRD user story 23 (issue #1): the whole suite runs in CI on every PR so a +# change breaking any pinned capability fails fast. This is the BLOCKING +# functional leg — a plain (non-sanitized) build that runs everything ctest +# knows about, including the Death tests and the CurrentlyCorrupts tripwires +# that the informational sanitizer leg (sanitizers.yml) excludes. An xfail +# flipping to pass (a DISABLED_ test's tripwire going red) or a green test +# going red both fail this check. + +on: + push: + branches: [develop] + pull_request: + +jobs: + contract-functional: + name: Contract tests (Linux, ${{ matrix.preset }}, blocking) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Both build modes (issue #32): the dlopen-failure plugin pin is + # build-mode dependent — assert-abort death test in Debug, swallowed + # assert tripwire under NDEBUG (RelWithDebInfo) — so each branch needs + # its own leg or one of them never runs anywhere. The presets + # (CMakePresets.json) are the single source of truth for flags and + # binary dirs; builddir here only feeds the label-partition check. + include: + - preset: tests # RelWithDebInfo + builddir: build + - preset: tests-debug + builddir: build-debug + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 # git describe needs full history for the version macro + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y ninja-build libhdf5-dev libboost-filesystem-dev + + - name: Configure (tests on, no Python bindings) + run: cmake --preset ${{ matrix.preset }} + + - name: Build + run: cmake --build --preset ${{ matrix.preset }} + + # Issue #31: an empty `ctest -L unit` exits 0, so a broken label + # partition is invisible to the test run below — check it explicitly. + - name: Verify unit/integration label partition + run: bash tests/contract/check_label_partition.sh ${{ matrix.builddir }}/tests/contract + + - name: Run the whole test suite + run: ctest --preset ${{ matrix.preset }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d1621e0b..9e1396f7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -52,7 +52,7 @@ jobs: else VERSION=dev fi - echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - name: Build documentation env: diff --git a/.github/workflows/mdsplus-contract.yml b/.github/workflows/mdsplus-contract.yml new file mode 100644 index 00000000..ca035693 --- /dev/null +++ b/.github/workflows/mdsplus-contract.yml @@ -0,0 +1,124 @@ +name: MDSplus contract suite (non-blocking) + +# Task 2 issue #18 (parent #12): a dedicated, informational CI leg for the +# MDSplus tier (tests/contract/test_mdsplus*.cpp, TRACEABILITY.md Part 4), +# mirroring the D7 sanitizer leg (sanitizers.yml). continue-on-error: true — +# informational, not a merge gate, until the characterization baseline +# (issues #13-17) is triaged. Promotion to a blocking gate is deferred to +# Task 3. +# +# Runs on a GitHub-hosted arm64 runner: MDSplus publishes no arm64 packages +# for Ubuntu 22, only 24.04+ (docker/mdsplus/README.md's deviation note), and +# the characterization image itself is aarch64. +# +# Two-layer cache, so an ordinary push doesn't pay the full toolchain-install +# + DD-download cost every time: +# 1. the toolchain image (docker/mdsplus/Dockerfile) — keyed on its hash. +# A miss means re-running the MDSplus/Java/DD-toolchain package install. +# 2. the baked DD model tree (build-mdsplus/) — keyed on the DD version +# plus the model-build inputs (ALBuildDataDictionary.cmake, +# models/mdsplus/CMakeLists.txt). A hit restores the whole configured +# build directory (including the generated ids.xml and the model-tree +# files under build-mdsplus/models/mdsplus), so the configure step is +# skipped entirely on a hit — CMake's own dependency tracking then only +# rebuilds C++ sources that actually changed. + +on: + push: + pull_request: + +env: + DD_VERSION: "4.1.1" + IMAGE_TAG: imas-core-mdsplus:ci + +jobs: + mdsplus-contract: + name: MDSplus contract suite (aarch64, non-blocking) + runs-on: ubuntu-24.04-arm + continue-on-error: true # informational leg, not a gate — for now + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 # git describe needs full history for the version macro + + - name: Cache toolchain image (layer 1 — keyed on Dockerfile) + id: cache-image + uses: actions/cache@v4 + with: + path: /tmp/mdsplus-image.tar + key: mdsplus-image-${{ hashFiles('docker/mdsplus/Dockerfile') }} + + - name: Build toolchain image + if: steps.cache-image.outputs.cache-hit != 'true' + run: | + docker build -f docker/mdsplus/Dockerfile -t "${IMAGE_TAG}" docker/mdsplus + docker save "${IMAGE_TAG}" -o /tmp/mdsplus-image.tar + + - name: Load cached toolchain image + if: steps.cache-image.outputs.cache-hit == 'true' + run: docker load -i /tmp/mdsplus-image.tar + + - name: Cache baked DD model tree (layer 2 — keyed on DD version + model-build inputs) + id: cache-model + uses: actions/cache@v4 + with: + path: build-mdsplus + key: mdsplus-model-${{ env.DD_VERSION }}-${{ hashFiles('common/cmake/ALBuildDataDictionary.cmake', 'models/mdsplus/CMakeLists.txt') }} + + - name: Configure (bakes the DD model tree on a cache miss) + # The `mdsplus` preset (CMakePresets.json) carries the backend/model/ + # test flags and the build-mdsplus binary dir; only the DD pin stays + # here, next to the cache key it feeds. + if: steps.cache-model.outputs.cache-hit != 'true' + run: > + docker run --rm --platform linux/arm64 + -v "$PWD":/workspace -w /workspace + "${IMAGE_TAG}" bash -c " + cmake --preset mdsplus -DDD_VERSION=${DD_VERSION} + " + + - name: Build contract suite + run: > + docker run --rm --platform linux/arm64 + -v "$PWD":/workspace -w /workspace + "${IMAGE_TAG}" bash -c " + cmake --build --preset mdsplus + " + + - name: Verify baked DD model tree is present (cache-hit and cache-miss paths) + run: | + set -euo pipefail + # ids_model.datafile is legitimately 0 bytes for a model tree (all + # structure lives in .tree/.characteristics; a model carries no + # data), so it is checked for existence only. + for f in ids_model.tree ids_model.characteristics; do + if [ ! -s "build-mdsplus/models/mdsplus/${f}" ]; then + echo "FATAL: missing/empty model file build-mdsplus/models/mdsplus/${f} — the MDSplus tier would fail with %TREE-E-FOPENR (issue #42)" + exit 1 + fi + done + if [ ! -e "build-mdsplus/models/mdsplus/ids_model.datafile" ]; then + echo "FATAL: missing model file build-mdsplus/models/mdsplus/ids_model.datafile (issue #42)" + exit 1 + fi + echo "Baked DD model tree complete under build-mdsplus/models/mdsplus." + + - name: Run MDSplus contract suite + # The `mdsplus` test preset selects the tier (-L mdsplus) and points + # MDSPLUS_MODELS_PATH at the baked model tree under build-mdsplus/. + run: > + docker run --rm --platform linux/arm64 + -v "$PWD":/workspace -w /workspace + "${IMAGE_TAG}" bash -c " + ctest --preset mdsplus + " + + - name: Note + if: always() + # Block scalar, not a plain scalar: in a plain scalar ` #` starts a YAML + # comment even inside shell-looking quotes, truncating the command into + # an unterminated string (issue #43). + run: | + echo "MDSplus leg is informational (issue #12/#18). Red-by-design until the characterization baseline is triaged; promotion to a blocking gate is Task 3." diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml new file mode 100644 index 00000000..0d7b7d5c --- /dev/null +++ b/.github/workflows/sanitizers.yml @@ -0,0 +1,61 @@ +name: Contract suite — sanitizers (non-blocking) + +# Decision D7 (TEST_STRATEGY.md): build the contract suite with +# AddressSanitizer + UndefinedBehaviorSanitizer + LeakSanitizer and run it, so a +# leak / use-after-free / overrun in the hot path is caught for free — no +# per-test assertions. This leg is INFORMATIONAL, not a merge gate +# (continue-on-error: true), until the baseline is triaged. LSan leak detection +# is Linux-only, which is why this leg runs on ubuntu. + +# push is limited to develop so a PR from an in-repo branch runs the leg once, +# not twice (push + pull_request). +on: + push: + branches: [develop] + pull_request: + +jobs: + asan-ubsan-lsan: + name: ASan/UBSan/LSan (Linux, non-blocking) + runs-on: ubuntu-latest + continue-on-error: true # informational leg (D7), not a gate — for now + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 # git describe needs full history for the version macro + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y ninja-build libhdf5-dev libboost-filesystem-dev + + - name: Configure (sanitized, tests on, no Python bindings) + # The `sanitize` preset (CMakePresets.json) sets AL_CONTRACT_SANITIZE, + # which instruments both libal and the test binary. Python bindings are + # off: a sanitized libal must not be linked into a wheel whose bindings + # lack the ASan runtime (see tests/contract/CMakeLists.txt). + run: cmake --preset sanitize + + - name: Build contract suite + run: cmake --build --preset sanitize + + - name: Run contract suite under sanitizers + # The `sanitize` test preset carries the ASAN/LSAN/UBSAN_OPTIONS + # (halt/abort_on_error=0 so the whole suite runs and reports everything + # at once — informational leg) and two exclusions: + # * `Death` tests deliberately crash child processes (asserting today's + # crash-class defects) — not a memory-safety target, and ASan would + # report the intentional crash as an error. + # * `CurrentlyCorrupts` tripwires pin the ASCII rank-7 DOUBLE/COMPLEX + # corruption, which is UB whose manifestation ASan's memory layout + # masks (the data round-trips intact under ASan), so they legitimately + # fail here. They guard against rot in the blocking functional leg + # (contract-tests.yml). Real leaks/UAF in the ordinary paths still + # surface. + run: ctest --preset sanitize + + - name: Note + if: always() + run: echo "Sanitizer leg is informational (D7). Triage findings before promoting it to a required check." diff --git a/.github/workflows/uda-contract.yml b/.github/workflows/uda-contract.yml new file mode 100644 index 00000000..3f0942bf --- /dev/null +++ b/.github/workflows/uda-contract.yml @@ -0,0 +1,96 @@ +name: UDA contract suite (non-blocking) + +# Issue #29 (parent #21): a dedicated, informational CI leg for the UDA tier +# (tests/contract/test_uda*.cpp, TRACEABILITY.md Part 5), mirroring the +# mdsplus-contract leg (mdsplus-contract.yml) and its rationale: UDA is one of +# the two DD-coupled backends (CLAUDE.md), and lands as a non-blocking leg only +# now that issue #28 has triaged the whole baseline to terminal statuses, so +# this leg is green (modulo pinned defects) from its first run and there is +# never a red informational leg to maintain. +# +# Runs on a GitHub-hosted arm64 runner: docker/uda/Dockerfile is aarch64 (the +# pinned reference stack docker/uda/README.md documents), matching the +# mdsplus-contract leg's own runner choice. +# +# One cache layer, unlike the mdsplus leg's two: the UDA image +# (docker/uda/Dockerfile) is the pinned, source-independent reference stack +# (UDA server + IMAS plugin source + IDSDef.xml); it does not COPY the repo, so +# it is cached across ordinary pushes and only Dockerfile/run.sh edits bust the +# cache. IMAS-Core itself and the IMAS server plugin are always built fresh +# from the mounted workspace by docker/uda/run.sh, by design (README.md +# "Design: toolchain image + build-workspace-at-runtime") — never baked into +# the image — so there is no second, workspace-keyed cache: CMake's own +# dependency tracking is the only reuse mdsplus's layer 2 gets from a restored +# build-mdsplus/ directory, and the UDA leg has no equivalent artifact to +# restore, only source it must always rebuild against. + +on: + push: + pull_request: + +env: + IMAGE_TAG: imas-core-uda:ci + +jobs: + uda-contract: + name: UDA contract suite (aarch64, non-blocking) + runs-on: ubuntu-24.04-arm + continue-on-error: true # informational leg, not a gate + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 # git describe needs full history for the version macro + + - name: Cache reference-stack image (keyed on Dockerfile + run.sh) + id: cache-image + uses: actions/cache@v4 + with: + path: /tmp/uda-image.tar + key: uda-image-${{ hashFiles('docker/uda/Dockerfile', 'docker/uda/run.sh') }} + + - name: Build reference-stack image + if: steps.cache-image.outputs.cache-hit != 'true' + run: | + docker build --platform linux/arm64 -f docker/uda/Dockerfile -t "${IMAGE_TAG}" docker/uda + docker save "${IMAGE_TAG}" -o /tmp/uda-image.tar + + - name: Load cached reference-stack image + if: steps.cache-image.outputs.cache-hit == 'true' + run: docker load -i /tmp/uda-image.tar + + - name: Verify pinned stack identifiers match TRACEABILITY Part 5 header + run: | + set -euo pipefail + uda_server=$(docker inspect -f '{{ index .Config.Labels "org.iter.imas-core.uda-server-version" }}' "${IMAGE_TAG}") + uda_plugins=$(docker inspect -f '{{ index .Config.Labels "org.iter.imas-core.uda-plugins-version" }}' "${IMAGE_TAG}") + uda_plugins_commit=$(docker inspect -f '{{ index .Config.Labels "org.iter.imas-core.uda-plugins-commit" }}' "${IMAGE_TAG}") + dd_version=$(docker inspect -f '{{ index .Config.Labels "org.iter.imas-core.dd-version" }}' "${IMAGE_TAG}") + echo "Image labels: uda-server=${uda_server} uda-plugins=${uda_plugins}@${uda_plugins_commit} dd=${dd_version}" + header=tests/contract/TRACEABILITY.md + grep -q "tag \*\*${uda_server}\*\*" "${header}" + grep -q "tag \*\*${uda_plugins}\*\*" "${header}" + grep -q "commit \*\*\`${uda_plugins_commit}\`\*\*" "${header}" + grep -q "\*\*${dd_version}\*\*" "${header}" + echo "Image stack identifiers match ${header}'s Part 5 pinned-reference-stack table." + + # AL_UDA_DEDICATED_CI=1: inside the dedicated reference-stack job a + # server bootstrap failure must fail the job, not skip every test — + # uda-run.sh makes the listener wait and the uda_cli ping fatal, and + # fails unless a nonzero number of UDA tests actually executed + # (issue #41). Developer builds without the flag keep skip semantics. + - name: Run the UDA contract tier + run: > + docker run --rm --platform linux/arm64 + -v "$PWD":/workspace -w /workspace + -e AL_UDA_DEDICATED_CI=1 + "${IMAGE_TAG}" uda-run.sh + + - name: Note + if: always() + # Block scalar, not a plain scalar: in a plain scalar ` #` starts a YAML + # comment even inside shell-looking quotes, truncating the command into + # an unterminated string (issue #43). + run: | + echo "UDA leg is informational (issue #21/#29). Red-by-design only for un-pinned defects; TRACEABILITY.md Part 5 (issue #28) records the pinned baseline this leg should stay green against." diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index c64d447c..f3860ff0 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -198,154 +198,32 @@ jobs: AL_BACKEND_UDA: AL_BACKEND_UDA=ON UDA_REF: "2.9.3" - - os: windows-2022 - triplet: x64-windows - python: 310 - platform_id: win_amd64 - cibw_platform: windows - cibw_archs: AMD64 - manylinux_image: windows - AL_BACKEND_HDF5: AL_BACKEND_HDF5=ON - AL_BACKEND_MDSPLUS: AL_BACKEND_MDSPLUS=OFF - AL_BACKEND_UDA: AL_BACKEND_UDA=OFF - UDA_REF: "2.9.3" - FMT_REF: "11.1.4" - - - os: windows-2022 - triplet: x64-windows - python: 311 - platform_id: win_amd64 - cibw_platform: windows - cibw_archs: AMD64 - manylinux_image: windows - AL_BACKEND_HDF5: AL_BACKEND_HDF5=ON - AL_BACKEND_MDSPLUS: AL_BACKEND_MDSPLUS=OFF - AL_BACKEND_UDA: AL_BACKEND_UDA=OFF - UDA_REF: "2.9.3" - FMT_REF: "11.1.4" - - - os: windows-2022 - triplet: x64-windows - python: 312 - platform_id: win_amd64 - cibw_platform: windows - cibw_archs: AMD64 - manylinux_image: windows - AL_BACKEND_HDF5: AL_BACKEND_HDF5=ON - AL_BACKEND_MDSPLUS: AL_BACKEND_MDSPLUS=OFF - AL_BACKEND_UDA: AL_BACKEND_UDA=OFF - UDA_REF: "2.9.3" - FMT_REF: "11.1.4" - - - os: windows-2022 - triplet: x64-windows - python: 313 - platform_id: win_amd64 - cibw_platform: windows - cibw_archs: AMD64 - manylinux_image: windows - AL_BACKEND_HDF5: AL_BACKEND_HDF5=ON - AL_BACKEND_MDSPLUS: AL_BACKEND_MDSPLUS=OFF - AL_BACKEND_UDA: AL_BACKEND_UDA=OFF - UDA_REF: "2.9.3" - FMT_REF: "11.1.4" - - - os: windows-2022 - triplet: x64-windows - python: 314 - platform_id: win_amd64 - cibw_platform: windows - cibw_archs: AMD64 - manylinux_image: windows - AL_BACKEND_HDF5: AL_BACKEND_HDF5=ON - AL_BACKEND_MDSPLUS: AL_BACKEND_MDSPLUS=OFF - AL_BACKEND_UDA: AL_BACKEND_UDA=OFF - UDA_REF: "2.9.3" - FMT_REF: "11.1.4" + # Windows wheels are built by the dedicated build_wheels_windows job + # below: they share a vcpkg dependency tree prepared once by the + # windows_deps job (issue #44), and keeping them in this matrix would + # force `needs: windows_deps` onto the Linux/macOS legs too. steps: - uses: actions/checkout@v7 with: fetch-depth: 0 # unshallow for setuptools-scm.get_version to discover tags - # Recent changes in GitHub Actions Cache's API have rendered the x-gha binary caching provider useless. - # See https://github.com/microsoft/vcpkg-tool/pull/1662 - # - uses: actions/github-script@v7 - # if: startsWith(matrix.os, 'windows') - # # requirement for vcpkg binary caching https://learn.microsoft.com/en-us/vcpkg/consume/binary-caching-github-actions-cache - # with: - # script: | - # core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); - # core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '') - - uses: actions/setup-python@v6 with: # python installation used when to start cibuildwheel python-version: "3.12" cache: 'pip' - - name: Install cibuildwheel for windows - if: startsWith(matrix.os, 'windows') - run: | - python -m pip install cibuildwheel==3.1.0 - - name: Restore cibuildwheel cache id: cache uses: actions/cache@v6 with: path: | - ~\AppData\Local\pypa\cibuildwheel\Cache ~/Library/Caches/cibuildwheel ~/.cache/cibuildwheel # cf. CIBW_CACHE_PATH key: "cibuildwheel-${{ runner.os }}-${{ runner.arch }}-cp${{ matrix.python }}-${{ matrix.platform_id }}" - - name: Cache restore windows deps - if: startsWith(matrix.os, 'windows') - id: cache-win-deps - uses: actions/cache/restore@v6 - env: - cache-name: win-deps - with: - path: C:\vcpkg\installed - key: ${{ matrix.os }}-${{ env.cache-name }}-b # this key is never hit (-a is). - restore-keys: | - ${{ matrix.os }}-${{ env.cache-name }}-a - ${{ matrix.os }}-${{ env.cache-name }}- - - - name: Build wheels for windows - if: startsWith(matrix.os, 'windows') - run: | - python -c "import sys, pprint, cibuildwheel; pprint.pp(sys.path); pprint.pp(cibuildwheel)" - python -m cibuildwheel --output-dir wheelhouse --config-file pyproject.toml - env: - CIBW_BUILD: cp${{ matrix.python }}-${{ matrix.platform_id }} - CIBW_PLATFORM: ${{ matrix.cibw_platform }} - CIBW_ARCHS: ${{ matrix.cibw_archs }} - CIBW_BUILD_VERBOSITY: 1 - CIBW_SKIP: "cp31?t-*" # Skip free-threading builds (not supported yet) - CIBW_CONFIG_SETTINGS: > - cmake.define.${{ matrix.AL_BACKEND_HDF5 }} - cmake.define.${{ matrix.AL_BACKEND_MDSPLUS }} - cmake.define.${{ matrix.AL_BACKEND_UDA }} - # cmake.args="--debug-find;--debug-output" - - - name: Cache store windows deps - # always(): also if previous step failed - # and also if steps.cache-win-deps.outputs.cache-hit == 'true' - # It is difficult to predict when the cache will be there (gha erases in time) and/or up to date (changes in the build), - # making it difficult to manually bump the version of the cache. So, instead, we will override the cache always, even if - # it was completely up to date and it sped up the Build wheels for windows job (above). The performance hit of storing - # the cache redundantly doesn't compare to the time it takes to rebuild vcpkg packages. - if: ${{ always() && startsWith(matrix.os, 'windows') }} - id: cache-win-deps-save - uses: actions/cache/save@v6 - env: - cache-name: win-deps - with: - path: C:\vcpkg\installed - key: ${{ matrix.os }}-${{ env.cache-name }}-a - - uses: pypa/cibuildwheel@v4.1.0 if: startsWith(matrix.os, 'ubuntu-') env: @@ -450,10 +328,152 @@ jobs: path: wheelhouse/*.whl retention-days: 1 + # Issue #44: prepare the pinned x64-windows vcpkg dependency closure ONCE, + # then let every Windows wheel job restore it read-only. Previously all five + # cold matrix jobs independently ran vcpkg against the same upstream source + # hosts and the same mutable cache key: a rate-limited host (HTTP 429 from + # gitlab.dkrz.de for libaec) failed the whole blocking workflow, and the five + # always-save steps raced on one key. Now a cold workflow downloads each + # source archive at most once (both C:\vcpkg\installed and the + # content-addressed C:\vcpkg\downloads are cached), and only this job ever + # saves the cache. + windows_deps: + name: Prepare Windows vcpkg dependencies + runs-on: windows-2022 + env: + # The before-build script's default (matrix.UDA_REF was never exported to + # the Windows build environment, so Windows has always built this + # version); pinned explicitly here because it is part of the cache key. + UDA_REF: "2.8.1" + VCPKG_ROOT: C:/vcpkg + VCPKG_TARGET_TRIPLET: x64-windows + outputs: + cache-key: ${{ steps.key.outputs.key }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + + - name: Compute content-addressed cache key + id: key + # Key covers the manifest (vcpkg.json), the dependency-install script, + # the triplet, the UDA ref, and the runner's vcpkg baseline commit — + # any of these changing invalidates the prepared tree. restore-keys + # allow warm partial reuse; the exact key is what wheel jobs consume. + run: | + vcpkg_commit=$(git -C /c/vcpkg rev-parse HEAD 2>/dev/null || echo unknown) + echo "key=win-deps-${VCPKG_TARGET_TRIPLET}-uda${UDA_REF}-${{ hashFiles('vcpkg.json', 'ci/wheels/cibw_before_build_win.sh') }}-vcpkg-${vcpkg_commit}" >> "$GITHUB_OUTPUT" + + - name: Cache vcpkg installed tree and downloads + id: cache + uses: actions/cache@v6 + with: + path: | + C:\vcpkg\installed + C:\vcpkg\downloads + key: ${{ steps.key.outputs.key }} + restore-keys: | + win-deps-${{ env.VCPKG_TARGET_TRIPLET }}-uda${{ env.UDA_REF }}- + + - name: Install the pinned dependency closure (cold cache only) + if: steps.cache.outputs.cache-hit != 'true' + # The same script every wheel job runs as CIBW before-build; running it + # here keeps a single source of truth for the closure. It is idempotent + # against a partially restored tree: vcpkg skips installed packages and + # the UDA build is skipped when uda-cpp.pc is already installed — which + # also makes the retry loop below resume where the last attempt left + # off. The backoff exists because vcpkg's own retries are seconds + # apart: a rate-limiting upstream (HTTP 429 from gitlab.dkrz.de for + # libaec, issue #44) needs minutes for its window to reset. + run: | + for attempt in 1 2 3 4 5; do + if bash ./ci/wheels/cibw_before_build_win.sh; then + exit 0 + fi + echo "Dependency install attempt ${attempt} failed (upstream rate limit?); backing off $((attempt * 90))s" + sleep $((attempt * 90)) + done + echo "FATAL: dependency closure install failed after 5 attempts" + exit 1 + + build_wheels_windows: + name: build_wheels (Python ${{ matrix.python }}, win_amd64) + runs-on: windows-2022 + needs: [windows_deps] + strategy: + # Ensure that a wheel builder finishes even if another fails + fail-fast: false + matrix: + python: [310, 311, 312, 313, 314] + + # Recent changes in GitHub Actions Cache's API have rendered the x-gha + # binary caching provider useless. + # See https://github.com/microsoft/vcpkg-tool/pull/1662 — vcpkg reuse is + # instead provided by the windows_deps job's prepared-tree cache above. + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # unshallow for setuptools-scm.get_version to discover tags + + - uses: actions/setup-python@v6 + with: + # python installation used when to start cibuildwheel + python-version: "3.12" + cache: 'pip' + + - name: Install cibuildwheel for windows + run: | + python -m pip install cibuildwheel==3.1.0 + + - name: Restore cibuildwheel cache + id: cache + uses: actions/cache@v6 + with: + path: | + ~\AppData\Local\pypa\cibuildwheel\Cache + # cf. CIBW_CACHE_PATH + key: "cibuildwheel-${{ runner.os }}-${{ runner.arch }}-cp${{ matrix.python }}-win_amd64" + + - name: Restore prepared windows deps (read-only) + uses: actions/cache/restore@v6 + with: + path: | + C:\vcpkg\installed + C:\vcpkg\downloads + key: ${{ needs.windows_deps.outputs.cache-key }} + # The key was saved (or verified) by windows_deps in this same run; a + # miss means the prepared tree is unavailable and this job would fall + # back to a cold upstream fetch — the stampede issue #44 removes. + fail-on-cache-miss: true + + - name: Build wheels for windows + run: | + python -c "import sys, pprint, cibuildwheel; pprint.pp(sys.path); pprint.pp(cibuildwheel)" + python -m cibuildwheel --output-dir wheelhouse --config-file pyproject.toml + env: + UDA_REF: "2.8.1" # keep in sync with windows_deps.env.UDA_REF + CIBW_BUILD: cp${{ matrix.python }}-win_amd64 + CIBW_PLATFORM: windows + CIBW_ARCHS: AMD64 + CIBW_BUILD_VERBOSITY: 1 + CIBW_SKIP: "cp31?t-*" # Skip free-threading builds (not supported yet) + CIBW_CONFIG_SETTINGS: > + cmake.define.AL_BACKEND_HDF5=ON + cmake.define.AL_BACKEND_MDSPLUS=OFF + cmake.define.AL_BACKEND_UDA=OFF + # cmake.args="--debug-find;--debug-output" + + - uses: actions/upload-artifact@v7 + with: + name: cibw-wheels-cp${{ matrix.python }}-win_amd64 + path: wheelhouse/*.whl + retention-days: 1 + publish_wheels_testpypi: # only publish to TestPyPI on develop pushes and not from forks if: github.ref=='refs/heads/develop' && github.repository_owner == 'iterorganization' - needs: [build_wheels] + needs: [build_wheels, build_wheels_windows] runs-on: ubuntu-24.04 permissions: # IMPORTANT: this permission is mandatory for Trusted Publishing @@ -480,7 +500,7 @@ jobs: publish_wheels_pypi: # only publish to PyPI on tag pushes and not from forks if: startsWith(github.ref, 'refs/tags/') && github.repository_owner == 'iterorganization' - needs: [build_wheels] + needs: [build_wheels, build_wheels_windows] runs-on: ubuntu-24.04 permissions: # IMPORTANT: this permission is mandatory for Trusted Publishing diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml new file mode 100644 index 00000000..db546a13 --- /dev/null +++ b/.github/workflows/workflow-lint.yml @@ -0,0 +1,27 @@ +name: Workflow lint + +# Issue #43: a plain YAML scalar in a `run:` line silently turns ` #` into a +# YAML comment, truncating the shell command (both backend Note steps shipped +# that way). actionlint runs shellcheck over every run: script, so this class +# of truncation fails before push lands instead of turning jobs red at runtime. + +on: + push: + paths: + - ".github/workflows/**" + pull_request: + paths: + - ".github/workflows/**" + +jobs: + actionlint: + name: actionlint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Run actionlint + run: | + bash <(curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + ./actionlint -color diff --git a/.gitignore b/.gitignore index f5697b3c..fd31d7a2 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ CMakeUserPresets.json /vcpkg_installed/ settings.json /build*/ +cmake-build-*/ /_deps/ /CMakeFiles CMakeCache.txt @@ -44,3 +45,6 @@ python/lib/ /.venv/ /vcpkg/ /UDA-*/ +.idea +/graphify-out +.pytest_cache/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..dc04c24a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,115 @@ +# AGENTS.md - IMAS-Core + +Low-level IMAS Access Layer (AL core): a stable C ABI over a C++17 core with +pluggable storage backends and Cython/Python bindings. It reads/writes IMAS IDS +data addressed by Data Dictionary (DD) paths. **The active strategic effort is +the DD-version-agnostic migration — read `NORTH_STAR.md` before making +architectural changes.** A structural overview with diagrams lives in +`IMAS_CORE_STRUCTURE_MINDMAP.md`. + +## Agent skills + +### Issue tracker + +GitHub Issues on the `origin` fork `yohannmarguier/IMAS-Core` (via the `gh` CLI); external PRs are not a triage surface. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Canonical vocabulary, no overrides: `needs-triage` / `needs-info` / `ready-for-agent` / `ready-for-human` / `wontfix`, plus `bug` / `enhancement`. See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context: `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`. + +## Key architecture facts (audited 2026-07, develop @ 24bd5bc) + +- **DD paths are opaque strings through the whole C ABI** (`al_read_data` / + `al_write_data`, `include/al_lowlevel.h:487-489`). The caller (HLI) drives + tree traversal node-by-node and owns all DD semantics. The core has no + per-DD-version logic. +- **The compile-time DD version is deprecated on purpose**: + `include/al_defs.h.in:57` sets `DD_VERSION "!!DEPRECATED!!"` and + `python/tests/test_imasdef.py` asserts it stays that way. Do not reintroduce + compile-time DD coupling anywhere. +- **Backend coupling status:** HDF5, memory, ASCII, and flexbuffers are fully + DD-agnostic. Only two backends are DD-coupled: + - **MDSplus** — consumes a binary model tree baked from one `IDSDef.xml` at + build time (`models/mdsplus/`, `common/cmake/ALBuildDataDictionary.cmake`), + selected at runtime via `MDSPLUS_MODELS_PATH`; DD version stored in tree + node `VERSION:DATA_DICT` (`src/mdsplus/mdsplus_backend.cpp:5377-5389`). + - **UDA** — loads `IDSDef.xml` at runtime from `$IDSDEF_PATH` and walks it + for types/ranks/timebases (`src/uda/uda_xml.cpp:73-280`). +- The DD version of stored data lives only in the ordinary string field + `ids_properties/version_put/data_dictionary`, written by the HLI; no backend + except UDA interprets any DD version. +- **HDF5 layout:** master file + per-IDS files; one "tensorized" dataset per + leaf DD path (`/` → `&` in names), AOS flattened with a leading index + dimension plus `_SHAPE` companion datasets. HDF5 stores a backend-format + version (1.0), not the DD version. +- **Performance shape:** the per-node `Backend` contract is the bottleneck; no + general subtree/bulk read exists (`al_begin_global_action`'s `datapath` arg + is ignored by HDF5, MDSplus, Memory, ASCII, and Flexbuffers). **UDA is the + one living exception**: in remote mode with `cache_mode=ids`, `UDABackend` + reads `datapath` to scope which paths `populate_cache()` requests from the + server (`src/uda/uda_backend.cpp:1016-1031`, characterized in + `tests/contract/test_uda_unique_surface.cpp`, issue #26). Slice mode + disables HDF5 buffering + (`src/hdf5/hdf5_dataset_handler.cpp:733`). Every read/write calls + `getenv("IMAS_AL_ENABLE_PLUGINS")` (`src/al_lowlevel.cpp:108`). Python reads + are zero-copy; Python writes copy twice (`_al_lowlevel.pyx:615-631`). +- **No benchmarks exist in-repo**; C++ tests are frameworkless executables in + `tests/`, Python tests are pytest in `python/tests` (configured in + `pyproject.toml`). + +## DD version landscape (from the imas-dd tool suite) + +- 35 DD versions in circulation, 3.22.0 → 4.1.1. Within-major changes are + almost purely additive (4.0.0→4.1.1: zero breaking); the 3→4 major boundary + is the cliff (894 breaking changes, 81 IDSs, COCOS 11→17). +- Equilibrium example: 585 paths (DD3) → 641 (DD4); the 3→4 transition has 143 + rename chains (`j_tor→j_phi`, `bpol_probe→b_field_pol_probe`, + `psi_axis→psi_magnetic_axis`, …) **and 32 COCOS sign flips** — so + cross-version mapping requires value transforms, not just path renames. + Getting a sign flip wrong silently corrupts physics; strict mapping is the + default in the north star design. + +## Operating patterns for agents + +- **Use the `imas-dd` MCP tools** for anything DD-related; never guess paths + or version behaviour. Most useful: `get_dd_versions`, + `get_ids_summary(ids, dd_version=3|4)`, + `get_dd_migration_guide(from, to, ids_filter=..., summary_only=True)` (full + output can be ~300 k chars — start with `summary_only` and grep the saved + overflow file), `get_dd_version_context(change_type_filter='path_renamed', + follow_rename_chains=True)` for rename lineages, `check_dd_paths` to + validate paths. +- `src/uda/` line counts are misleading: it is mostly vendored pugixml. + Vendored code also in `src/flatbuffers/`. Effective own-code base is ~28 k + LOC. +- Build: CMake ≥3.21 (`cmake -B build && cmake --build build`); deps via vcpkg + or system (HDF5, Boost::filesystem; MDSplus/UDA backends optional, OFF by + default). Python wheel via scikit-build-core (`pip install .`); MDSplus + models build (`AL_BUILD_MDSPLUS_MODELS=ON`) is the only build path that + downloads the DD. +- Tests: `ctest` in the build dir for C++; `pytest` (rooted at `python/tests`) + for Python. +- Default branch and PR target is `develop`. + +## North star migration — where to contribute + +Phased plan, effort, and decided design points are in `NORTH_STAR.md` §9/§11. +Standing decisions to respect: keep the C ABI stable and additive; store data +as-written and convert on read; version knowledge ships as data artifacts +(schema pack + map artifacts), never compiled in; equilibrium is the pilot +IDS; TDD — every phase starts with its tests (characterization tests and the +benchmark harness are Phase 0 and gate everything else). + +## graphify + +This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. + +Rules: +- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. +- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). diff --git a/CMakeLists.txt b/CMakeLists.txt index e69e98eb..668bc9d1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,6 +35,19 @@ endif() # ############################################################################## option(AL_PYTHON_BINDINGS "Build Python bindings" OFF) +option(AL_BUILD_TESTS "Build the C/C++ low-level ABI smoke tests (tests/)" OFF) + +# Independent of AL_BUILD_TESTS: a benchmark "failing" means slower, not +# wrong, so it never gates the correctness build (NORTH_STAR.md §8.4). +option(AL_BUILD_BENCHMARKS "Build the Google Benchmark harness (benchmarks/)" OFF) + +# The fixtures/al_contract_fixtures INTERFACE library itself is always +# available (see below) so it is genuinely usable outside AL_BUILD_TESTS; this +# only gates the throwaway demonstration executable (issue #51 acceptance +# criterion 3), off by default like every other extra target in this file. +option(AL_BUILD_FIXTURE_SMOKE + "Build a throwaway executable proving tests/contract fixtures are linkable outside the test tree" OFF) + # When ON, the al C++ library is not built here; instead it is located with # find_package(al-core CONFIG). Enables a two-stage build where libimas-core # (the C/C++ library) and imas-core (the Python wrapper) are packaged @@ -211,6 +224,11 @@ endif() add_subdirectory(src) +# Shared contract fixtures (issue #51): the equilibrium seed and the ABI +# helpers it needs, usable outside tests/ (e.g. by a future benchmarks/ +# tree). Added unconditionally so it never depends on AL_BUILD_TESTS. +add_subdirectory(fixtures) + # Dummy Executable # ############################################################################## set( EXE_FLAG ) @@ -225,6 +243,20 @@ if(NOT WIN32 AND NOT APPLE) endif() add_dependencies( imas_print_version al ) +# Low-level ABI smoke tests (opt-in) +# ############################################################################## +if( AL_BUILD_TESTS ) + enable_testing() + add_subdirectory(tests) +endif() + +# Benchmark harness (opt-in, off in the ordinary build) +# ############################################################################## +if( AL_BUILD_BENCHMARKS ) + enable_testing() + add_subdirectory(benchmarks) +endif() + # Install # ############################################################################## @@ -313,4 +345,14 @@ endif() if(AL_BUILD_MDSPLUS_MODELS) add_subdirectory(models/mdsplus) + + # The MDSplus contract tier opens pulses against the baked model tree, so + # `cmake --build . --target contract_tests` must build the model too — a + # workflow that builds only contract_tests otherwise runs against an absent + # model and fails with %TREE-E-FOPENR (issue #42). Wired here, after both + # targets exist: tests/ is added before models/mdsplus/, so the dependency + # cannot be declared from tests/contract/CMakeLists.txt. + if(AL_BACKEND_MDSPLUS AND TARGET contract_tests) + add_dependencies(contract_tests al-mdsplus-model) + endif() endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 00000000..7e4b8a20 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,141 @@ +{ + "version": 3, + "cmakeMinimumRequired": { "major": 3, "minor": 21, "patch": 0 }, + "configurePresets": [ + { + "name": "tests", + "displayName": "Test build (RelWithDebInfo)", + "description": "Smoke + contract suites against the public C ABI; no Python bindings. Mirrors the blocking CI leg (contract-tests.yml).", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build", + "cacheVariables": { + "AL_BUILD_TESTS": "ON", + "AL_PYTHON_BINDINGS": "OFF", + "CMAKE_BUILD_TYPE": "RelWithDebInfo" + } + }, + { + "name": "tests-debug", + "displayName": "Test build (Debug)", + "description": "Debug leg of contract-tests.yml: the dlopen-failure plugin pin is build-mode dependent (issue #32), so CI runs both modes.", + "inherits": "tests", + "binaryDir": "${sourceDir}/build-debug", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } + }, + { + "name": "sanitize", + "displayName": "Sanitized test build (ASan/UBSan)", + "description": "AL_CONTRACT_SANITIZE=ON instruments libal and contract_tests (TEST_STRATEGY.md D7). Mirrors sanitizers.yml. Never combine with a wheel build: the bindings would link a sanitized libal without the ASan runtime.", + "inherits": "tests", + "binaryDir": "${sourceDir}/build-san", + "cacheVariables": { "AL_CONTRACT_SANITIZE": "ON" } + }, + { + "name": "mdsplus", + "displayName": "MDSplus test build (bakes the DD model tree)", + "description": "MDSplus backend + baked model tree + tests. Pass -DDD_VERSION= to pin the DD (models/mdsplus defaults to 'main'; mdsplus-contract.yml pins its own). Downloads the DD at configure time.", + "inherits": "tests", + "binaryDir": "${sourceDir}/build-mdsplus", + "cacheVariables": { + "AL_BACKEND_MDSPLUS": "ON", + "AL_BUILD_MDSPLUS_MODELS": "ON", + "CMAKE_BUILD_TYPE": "Release" + } + }, + { + "name": "benchmarks", + "displayName": "Benchmark harness build (Release)", + "description": "Google Benchmark harness in benchmarks/ (issue #52); independent of AL_BUILD_TESTS and off in the ordinary build. A benchmark 'failing' means slower, not wrong, so this never gates the correctness suite.", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build-benchmarks", + "cacheVariables": { + "AL_BUILD_BENCHMARKS": "ON", + "AL_PYTHON_BINDINGS": "OFF", + "CMAKE_BUILD_TYPE": "Release" + } + } + ], + "buildPresets": [ + { "name": "tests", "configurePreset": "tests" }, + { "name": "tests-debug", "configurePreset": "tests-debug" }, + { + "name": "sanitize", + "configurePreset": "sanitize", + "targets": [ "contract_tests" ] + }, + { + "name": "mdsplus", + "configurePreset": "mdsplus", + "targets": [ "contract_tests" ] + }, + { + "name": "benchmarks", + "configurePreset": "benchmarks" + } + ], + "testPresets": [ + { + "name": "unit", + "displayName": "Hermetic inner loop: pure lookups, Memory backend, plugin registry", + "configurePreset": "tests", + "filter": { "include": { "label": "^unit$" } }, + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error" } + }, + { + "name": "integration", + "displayName": "On-disk tier: HDF5 / ASCII / Flexbuffers backends", + "configurePreset": "tests", + "filter": { "include": { "label": "^integration$" } }, + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error" } + }, + { + "name": "tests", + "displayName": "Everything registered in the test build (contract + smoke)", + "configurePreset": "tests", + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error" } + }, + { + "name": "tests-debug", + "displayName": "Everything, Debug build (issue #32 leg)", + "configurePreset": "tests-debug", + "inherits": "tests" + }, + { + "name": "sanitize", + "displayName": "Contract suite under ASan/UBSan (informational CI leg)", + "configurePreset": "sanitize", + "filter": { + "include": { "label": "^contract$" }, + "exclude": { "name": "Death|CurrentlyCorrupts" } + }, + "environment": { + "ASAN_OPTIONS": "detect_leaks=1:halt_on_error=0:abort_on_error=0", + "LSAN_OPTIONS": "suppressions=${sourceDir}/tests/contract/sanitizer_suppressions.txt:print_suppressions=1", + "UBSAN_OPTIONS": "print_stacktrace=1:halt_on_error=0" + }, + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error" } + }, + { + "name": "mdsplus", + "displayName": "MDSplus tier against the baked model tree", + "configurePreset": "mdsplus", + "filter": { "include": { "label": "^mdsplus$" } }, + "environment": { + "MDSPLUS_MODELS_PATH": "${sourceDir}/build-mdsplus/models/mdsplus" + }, + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error" } + }, + { + "name": "benchmarks", + "displayName": "Benchmark harness smoke: proves build+run+report, asserts no timing value", + "configurePreset": "benchmarks", + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error" } + } + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69e18b1e..c7d8ceb3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,11 @@ profile and create your own feature branch off of the latest develop commit. Make sure to regularly sync your branch with the latest commits from `develop` (find instructions [here](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork)); -5. when your development is ready, create a pull request (find instructions +5. make sure the test suite passes before submitting: see +[tests/README.md](tests/README.md) for how to build and run it +(`cmake --preset tests && cmake --build --preset tests && ctest --preset tests` +is the same run as the blocking CI check); +6. when your development is ready, create a pull request (find instructions [here](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork)). diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..7d069429 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +# Front door for the containerized backend contract tiers. These targets just +# delegate to ./test (the dispatcher); see docker/mdsplus/ and docker/uda/ for +# what each leg builds. The main build stays CMake (`cmake -B build && ...`). +.PHONY: help test test-common test-mdsplus test-uda + +help: + @echo "make test-common always-on backends, host-native (== ./test common)" + @echo "make test-mdsplus MDSplus contract tier in Docker (== ./test mdsplus)" + @echo "make test-uda UDA contract tier in Docker (== ./test uda)" + @echo "make test common + mdsplus + uda (== ./test all)" + @echo "" + @echo "Override the platform on x86: PLATFORM=linux/amd64 make test-uda" + +test-common: + @./test common + +test-mdsplus: + @./test mdsplus + +test-uda: + @./test uda + +test: + @./test all diff --git a/README.md b/README.md index 70a0480b..673700e7 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,17 @@ cmake --build build --target install See [Developer Guide](docs/source/developers/index.rst) for build instructions. +### Running the tests + +The C/C++ and Python test suites, their CTest tiers, and the CMake presets +that drive them (locally and in CI) are documented in +[tests/README.md](tests/README.md). The short version: + +```bash +cmake --preset tests && cmake --build --preset tests +ctest --preset unit # fast hermetic loop; `ctest --preset tests` for everything +``` + ## Using IMAS-Core with High-Level Languages diff --git a/benchmarks/BASELINE_CAPTURE.md b/benchmarks/BASELINE_CAPTURE.md new file mode 100644 index 00000000..138a7fc4 --- /dev/null +++ b/benchmarks/BASELINE_CAPTURE.md @@ -0,0 +1,114 @@ +# Capturing a baseline + +This documents the repeatable procedure behind `capture_baseline.py` (issue +#55): turning a run of `al_benchmarks` into a metadata-tagged, committable +JSON artifact under [`benchmarks/baselines/`](baselines/README.md). + +The script wraps whatever benchmarks are registered — it does not know or +care which scenarios exist, so this procedure does not change as +`bench_*.cpp` grows beyond the current whole-IDS-read operation. + +## Laptop (development / smoke — throwaway numbers) + +Any machine, any state of the working tree. These numbers exist to prove the +capture flow works, never to be compared against anything. + +```sh +cmake --preset benchmarks +cmake --build --preset benchmarks + +python3 benchmarks/capture_baseline.py \ + --binary build-benchmarks/benchmarks/al_benchmarks \ + --machine-id "laptop-$(hostname -s)" \ + -- --benchmark_min_time=1x +``` + +`--benchmark_min_time=1x` caps every registered benchmark at one iteration — +the same flag the CTest smoke check (`benchmark_smoke_run`) uses — so this +finishes in well under a second regardless of how many scenarios exist. +Drop it to get real repetition counts (slower, and still not a measurement +of record without the cluster's isolation). + +The script prints the metadata it captured (OS, CPU, compiler, HDF5 version, +filesystem, commit SHA/dirty flag, `slurm_partition: null`) and the path it +wrote to. On a laptop `commit_dirty` will often be `true` — that's expected +and is exactly why this file is a smoke artifact, not a baseline of record. + +On macOS, the script detects the Apple chip through `system_profiler` when the +usual CPU-brand `sysctl` is unavailable. If automatic detection is blocked in +your environment, pass the precise model explicitly, for example +`--cpu-model "Apple M5 Pro"`. + +## ITER SDCC cluster (measurement of record) + +Run on a clean checkout of `develop` at a known commit, before any P1–P6 +hot-path change lands (PRD #50: the baseline becomes unrecoverable the +moment optimization starts). + +1. **Allocate an exclusive node** on the partition your supervisor names as + production-representative — the partition is a runtime parameter, never + hard-coded into the script or this doc: + + ```sh + salloc --exclusive --partition= --time=00:30:00 + ``` + + `$SLURM_JOB_PARTITION` is then set for the rest of the allocation, and + `capture_baseline.py` picks it up automatically (or pass + `--slurm-partition ` explicitly). + +2. **Load the toolchain modules**: + + ```sh + module load foss-2023b googletest + ``` + + `foss-2023b` provides the compiler and the HDF5 the benchmark links + against; `googletest` is needed because the harness links + `al_contract_fixtures` (shared with the contract suite). + +3. **Configure and build in Release**, on GPFS (`/mnt/HPC_T2`), not on a + login-node home directory: + + ```sh + cd /mnt/HPC_T2//IMAS-Core + cmake --preset benchmarks + cmake --build --preset benchmarks + ``` + +4. **Capture**, recording the filesystem explicitly (auto-detection is + best-effort and GPFS mounts don't always self-report cleanly) and a + `machine-id` that names the cluster, not the specific login/compute node: + + ```sh + python3 benchmarks/capture_baseline.py \ + --binary build-benchmarks/benchmarks/al_benchmarks \ + --machine-id sdcc-gpfs \ + --filesystem gpfs + ``` + + No `--benchmark_min_time` here — let each scenario run its full + repetition count so the committed baseline reports a real distribution + (median, mean, stddev), per PRD #50 user story 8. + +5. **Verify before committing**: the script refuses (unless `--force`) to + overwrite a baseline already committed for this `machine-id` + commit SHA, + and warns to stderr if the working tree is dirty or the build isn't + Release. Resolve any such warning rather than force past it for a + measurement of record. + +6. **Commit** the resulting file under `benchmarks/baselines/sdcc-gpfs/` — + see [`baselines/README.md`](baselines/README.md) for the layout and how + later runs compare against it. + +## What gets recorded either way + +`captured_at`, `machine_id`, `os`, `cpu_model`, `compiler` (+ version), +`compiler_flags`, `build_type`, `hdf5_version`, `filesystem`, +`slurm_partition` (`null` off-cluster), `commit_sha`, `commit_dirty` — spliced +into the Google Benchmark JSON's `context` block as `imas_baseline_metadata`. +Compiler/flags/HDF5 version are read from the build directory's +`CMakeCache.txt` (found by walking up from `--binary`, or pass `--build-dir` +explicitly if your build layout doesn't nest the binary under it). Machine IDs +are normalized to lowercase letters, digits, and dashes, ensuring every +artifact remains inside the documented baseline layout. diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt new file mode 100644 index 00000000..403c6708 --- /dev/null +++ b/benchmarks/CMakeLists.txt @@ -0,0 +1,103 @@ +project( al-benchmarks ) + +######################################################################################################################## +# Google Benchmark, pinned via FetchContent (NORTH_STAR.md §8.4). +# +# Unlike tests/contract's GoogleTest dependency, there is no find_package +# fallback here: the benchmark harness is a standalone tree gated by its own +# option (AL_BUILD_BENCHMARKS), and must not rely on a cluster module (e.g. +# the ITER SDCC `googlebenchmark` module) -- FetchContent keeps the pin +# identical on every machine the harness runs on. Pinned to the immutable +# commit for release v1.9.5 (a tag can be re-pointed). + +include(FetchContent) +FetchContent_Declare( + googlebenchmark + GIT_REPOSITORY https://github.com/google/benchmark.git + GIT_TAG 192ef10025eb2c4cdd392bc502f0c852196baa48 +) +set(BENCHMARK_ENABLE_TESTING OFF CACHE BOOL "" FORCE) +set(BENCHMARK_ENABLE_INSTALL OFF CACHE BOOL "" FORCE) +set(BENCHMARK_ENABLE_GTEST_TESTS OFF CACHE BOOL "" FORCE) +set(BENCHMARK_ENABLE_WERROR OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googlebenchmark) + +find_package(Python3 COMPONENTS Interpreter REQUIRED) + +######################################################################################################################## +# The benchmark executable. +# +# bench_write_read.cpp is the original tracer bullet: a single-leaf +# write-once-then-read on a tiny inline fixture, against the in-RAM Memory +# backend, needing no filesystem or optional backend. +# +# bench_equilibrium_read.cpp is the real P1 headline workload (issue #53): +# whole-IDS equilibrium read, reusing equilibrium_seed.h (issue #51) via the +# al_contract_fixtures INTERFACE library below. It registers the same +# operation against both HDF5 (needs the filesystem -- std::filesystem temp +# dirs) and, as a zero-I/O control (issue #54), the in-RAM Memory backend. +# +# bench_equilibrium_write.cpp is the write-side counterpart (issue #59): the +# same equilibrium_seed.h fixture, written repeatedly instead of read, +# against both backends, sharing bench_equilibrium_read.cpp's per-backend +# boilerplate via bench_common.h (issue #58). +# +# bench_timeslice_element_read.cpp (issue #60) is the shared "before" +# baseline for the future P2 (al_read_subtree) and P3 (slice-mode buffering) +# optimizations (NORTH_STAR.md §7): one time_slice element's full leaf set, +# read node-by-node by index, against both HDF5 and Memory. +# +# bench_aos_traversal.cpp (issue #61) is the "before" baseline for P1/P4's +# round-trip-count reduction (NORTH_STAR.md §7): every time_slice element, +# every field, visited via al_iterate_over_arraystruct, against both HDF5 and +# Memory. +# +# bench_main.cpp holds the one BENCHMARK_MAIN() shared by every bench_*.cpp +# TU in this executable. + +add_executable( al_benchmarks + bench_write_read.cpp + bench_equilibrium_read.cpp + bench_equilibrium_write.cpp + bench_timeslice_element_read.cpp + bench_aos_traversal.cpp + bench_main.cpp ) +target_link_libraries( al_benchmarks PRIVATE al_contract_fixtures benchmark::benchmark ) +add_dependencies( al_benchmarks al ) +target_compile_features( al_benchmarks PRIVATE cxx_std_17 ) + +if(NOT WIN32 AND NOT APPLE) + # stdc++fs is only needed on Linux with older GCC (std::filesystem use in + # bench_equilibrium_read.cpp), mirroring fixtures/CMakeLists.txt's smoke target. + target_link_libraries( al_benchmarks PRIVATE stdc++fs ) +endif() + +######################################################################################################################## +# CTest smoke entry. +# +# Proves the harness builds, links libal, drives the C ABI, and produces +# parseable Google Benchmark JSON -- it asserts none of the reported timing +# values (a benchmark "failing" means slower, not wrong; NORTH_STAR.md §8.4). +# --benchmark_min_time=1x caps every registered benchmark at exactly one +# iteration, so the smoke run stays fast regardless of what benchmarks are +# added later. +# +# Follows the FIXTURES_SETUP/FIXTURES_REQUIRED shape tests/CMakeLists.txt uses +# for its own smoke registration: one test produces the JSON file, then CMake's +# built-in JSON parser validates it without adding another runtime dependency. + +set( _smoke_json ${CMAKE_CURRENT_BINARY_DIR}/benchmark_smoke.json ) + +add_test( NAME benchmark_smoke_run + COMMAND al_benchmarks --benchmark_min_time=1x + --benchmark_out=${_smoke_json} + --benchmark_out_format=json ) +set_tests_properties( benchmark_smoke_run PROPERTIES FIXTURES_SETUP BENCHMARK_SMOKE_JSON ) + +add_test( NAME benchmark_smoke_validate + COMMAND ${CMAKE_COMMAND} -DINPUT=${_smoke_json} + -P ${CMAKE_CURRENT_SOURCE_DIR}/validate_benchmark_json.cmake ) +set_tests_properties( benchmark_smoke_validate PROPERTIES FIXTURES_REQUIRED BENCHMARK_SMOKE_JSON ) + +add_test( NAME baseline_capture_unit + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_capture_baseline.py ) diff --git a/benchmarks/baselines/README.md b/benchmarks/baselines/README.md new file mode 100644 index 00000000..f9bd3241 --- /dev/null +++ b/benchmarks/baselines/README.md @@ -0,0 +1,67 @@ +# Committed baseline layout + +``` +benchmarks/baselines//.json +``` + +Each file is the raw Google Benchmark JSON that `al_benchmarks` emitted for +that run — Google Benchmark's own top-level `context` (date, host_name, +num_cpus, ...) and `benchmarks` (one entry per `BENCHMARK()` registration, +repetitions, and `_mean`/`_median`/`_stddev`/`_cv` rows) — with one field +spliced into `context`: + +```json +{ + "imas_baseline_metadata": { + "captured_at": "2026-07-20T09:50:42.347134+00:00", + "machine_id": "sdcc-gpfs", + "os": "Linux-5.14.0-x86_64", + "cpu_model": "Intel(R) Xeon(R) Gold 6248R", + "compiler": "GNU 12.3.0", + "compiler_flags": "-O3 -DNDEBUG", + "build_type": "Release", + "hdf5_version": "1.14.3", + "filesystem": "gpfs", + "slurm_partition": "iter", + "commit_sha": "7246824127a3339e6864f91238eb434a8ebb2afc", + "commit_dirty": false + } +} +``` + +(the object above nests under `context.imas_baseline_metadata`; only that +field is shown, not the full committed file) + +`imas_baseline_metadata` is what makes two files comparable — or tells you why +they aren't (different `compiler_flags`, different `filesystem`, etc). See +[`../BASELINE_CAPTURE.md`](../BASELINE_CAPTURE.md) for how these files are +produced. + +## Naming + +- `` is the normalized value of `--machine-id` (default: the + sanitized hostname). It contains only lowercase letters, digits, and dashes. + Use a stable, + descriptive id for anything meant to be a measurement of record, e.g. + `sdcc-gpfs` for the ITER SDCC cluster, rather than a raw login-node + hostname that might change. +- `` is the full `git rev-parse HEAD` of the run. A dirty working + tree is still captured (for laptop iteration) but is flagged + `commit_dirty: true` in the metadata — that file is not reproducible from + the SHA alone and should not be treated as a measurement of record. + +## Comparing two baselines + +There is no comparison tool here by design (PRD #50: "Not tested: the +numeric results themselves... a manual/analysis step over the committed +JSON, not a CI gate"). To compare, match `benchmarks[].name` between two +files and look at the `_mean`/`_median` rows' `real_time`/`cpu_time`, e.g. +with `jq`: + +```sh +jq '.benchmarks[] | select(.name | endswith("_mean")) | {name, real_time, cpu_time}' \ + benchmarks/baselines/sdcc-gpfs/.json + +jq '.benchmarks[] | select(.name | endswith("_mean")) | {name, real_time, cpu_time}' \ + benchmarks/baselines/sdcc-gpfs/.json +``` diff --git a/benchmarks/baselines/laptop-macbook-pro-de-yohann/c0ee66c80725a6fc44b37c333789e8e80903a549.json b/benchmarks/baselines/laptop-macbook-pro-de-yohann/c0ee66c80725a6fc44b37c333789e8e80903a549.json new file mode 100644 index 00000000..b3a1a7bd --- /dev/null +++ b/benchmarks/baselines/laptop-macbook-pro-de-yohann/c0ee66c80725a6fc44b37c333789e8e80903a549.json @@ -0,0 +1,748 @@ +{ + "context": { + "date": "2026-07-20T16:44:51+02:00", + "host_name": "MacBook-Pro-de-Yohann.local", + "executable": "build-benchmarks/benchmarks/al_benchmarks", + "num_cpus": 18, + "mhz_per_cpu": 24, + "cpu_scaling_enabled": false, + "caches": [ + { + "type": "Data", + "level": 1, + "size": 65536, + "num_sharing": 0 + }, + { + "type": "Instruction", + "level": 1, + "size": 131072, + "num_sharing": 0 + }, + { + "type": "Unified", + "level": 2, + "size": 8388608, + "num_sharing": 1 + } + ], + "load_avg": [ + 1.85303, + 1.69189, + 1.72119 + ], + "library_version": "v1.9.5", + "library_build_type": "release", + "json_schema_version": 1, + "imas_baseline_metadata": { + "captured_at": "2026-07-20T14:44:50.047160+00:00", + "machine_id": "laptop-macbook-pro-de-yohann", + "os": "macOS-26.5.1-arm64-arm-64bit-Mach-O", + "cpu_model": "Apple M5 Pro", + "compiler": "Apple clang version 21.0.0 (clang-2100.1.1.101)", + "compiler_flags": "-O3 -DNDEBUG", + "build_type": "Release", + "hdf5_version": "2.1.1", + "filesystem": "apfs", + "slurm_partition": null, + "commit_sha": "c0ee66c80725a6fc44b37c333789e8e80903a549", + "commit_dirty": false + } + }, + "benchmarks": [ + { + "name": "BM_WriteReadRoundTrip", + "family_index": 0, + "per_family_instance_index": 0, + "run_name": "BM_WriteReadRoundTrip", + "run_type": "iteration", + "repetitions": 1, + "repetition_index": 0, + "threads": 1, + "iterations": 1, + "real_time": 113834.01579223573, + "cpu_time": 113999.99999994748, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 0, + "threads": 1, + "iterations": 1, + "real_time": 454708.0025076866, + "cpu_time": 454000.0000001765, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 1, + "threads": 1, + "iterations": 1, + "real_time": 372499.9842233956, + "cpu_time": 373000.00000017876, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 2, + "threads": 1, + "iterations": 1, + "real_time": 340750.0043977052, + "cpu_time": 340000.000000007, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 3, + "threads": 1, + "iterations": 1, + "real_time": 316374.99341741204, + "cpu_time": 315999.99999976094, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 4, + "threads": 1, + "iterations": 1, + "real_time": 361374.9868236482, + "cpu_time": 361999.9999997514, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 5, + "threads": 1, + "iterations": 1, + "real_time": 346792.017808184, + "cpu_time": 347000.0000000972, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 6, + "threads": 1, + "iterations": 1, + "real_time": 406292.00520925224, + "cpu_time": 405999.99999990646, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 7, + "threads": 1, + "iterations": 1, + "real_time": 347084.01653915644, + "cpu_time": 348000.00000001496, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 8, + "threads": 1, + "iterations": 1, + "real_time": 415999.99531172216, + "cpu_time": 416999.9999998897, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 9, + "threads": 1, + "iterations": 1, + "real_time": 343457.99940638244, + "cpu_time": 343999.99999989994, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 10, + "threads": 1, + "iterations": 1, + "real_time": 346459.0117800981, + "cpu_time": 346000.0000001795, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 11, + "threads": 1, + "iterations": 1, + "real_time": 323999.9932702631, + "cpu_time": 323999.9999997689, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 12, + "threads": 1, + "iterations": 1, + "real_time": 444333.01081880927, + "cpu_time": 438000.0000001605, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 13, + "threads": 1, + "iterations": 1, + "real_time": 330416.98043234646, + "cpu_time": 330000.0000001635, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 14, + "threads": 1, + "iterations": 1, + "real_time": 331124.9893158674, + "cpu_time": 330999.9999998592, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 15, + "threads": 1, + "iterations": 1, + "real_time": 313333.0028504133, + "cpu_time": 313000.00000000774, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 16, + "threads": 1, + "iterations": 1, + "real_time": 324791.00627824664, + "cpu_time": 325000.00000013073, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 17, + "threads": 1, + "iterations": 1, + "real_time": 332249.99788217247, + "cpu_time": 332999.9999999167, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 18, + "threads": 1, + "iterations": 1, + "real_time": 449457.9916354269, + "cpu_time": 448999.9999999217, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 19, + "threads": 1, + "iterations": 1, + "real_time": 414999.9876972288, + "cpu_time": 415000.0000000542, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20_mean", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "mean", + "aggregate_unit": "time", + "iterations": 20, + "real_time": 365824.99888027087, + "cpu_time": 365549.99999999226, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20_median", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "median", + "aggregate_unit": "time", + "iterations": 20, + "real_time": 346625.51479414105, + "cpu_time": 346500.00000013836, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20_stddev", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "stddev", + "aggregate_unit": "time", + "iterations": 20, + "real_time": 47090.23247364246, + "cpu_time": 46503.50863959255, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20_cv", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "iterations": 20, + "real_time": 0.12872338582048187, + "cpu_time": 0.12721517888002606, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 0, + "threads": 1, + "iterations": 1, + "real_time": 9207.986295223236, + "cpu_time": 10000.000000065513, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 1, + "threads": 1, + "iterations": 1, + "real_time": 4750.007065013051, + "cpu_time": 4000.000000115023, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 2, + "threads": 1, + "iterations": 1, + "real_time": 4458.008334040642, + "cpu_time": 3999.999999892978, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 3, + "threads": 1, + "iterations": 1, + "real_time": 4333.0073822289705, + "cpu_time": 5000.000000032756, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 4, + "threads": 1, + "iterations": 1, + "real_time": 3875.0004023313522, + "cpu_time": 4000.000000115023, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 5, + "threads": 1, + "iterations": 1, + "real_time": 3875.0004023313522, + "cpu_time": 3999.999999892978, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 6, + "threads": 1, + "iterations": 1, + "real_time": 3708.0026231706142, + "cpu_time": 2999.9999999752445, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 7, + "threads": 1, + "iterations": 1, + "real_time": 3791.9962778687477, + "cpu_time": 2999.9999999752445, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 8, + "threads": 1, + "iterations": 1, + "real_time": 3749.999450519681, + "cpu_time": 5000.000000032756, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 9, + "threads": 1, + "iterations": 1, + "real_time": 4000.0013541430235, + "cpu_time": 4999.999999810711, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 10, + "threads": 1, + "iterations": 1, + "real_time": 3790.977643802762, + "cpu_time": 2999.9999999752445, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 11, + "threads": 1, + "iterations": 1, + "real_time": 3709.0212572366, + "cpu_time": 4000.000000115023, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 12, + "threads": 1, + "iterations": 1, + "real_time": 3708.0026231706142, + "cpu_time": 4000.000000115023, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 13, + "threads": 1, + "iterations": 1, + "real_time": 3708.0026231706142, + "cpu_time": 3000.0000001972894, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 14, + "threads": 1, + "iterations": 1, + "real_time": 3708.992153406143, + "cpu_time": 3999.999999892978, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 15, + "threads": 1, + "iterations": 1, + "real_time": 3708.992153406143, + "cpu_time": 3999.999999892978, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 16, + "threads": 1, + "iterations": 1, + "real_time": 3708.0026231706142, + "cpu_time": 4000.000000115023, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 17, + "threads": 1, + "iterations": 1, + "real_time": 3749.999450519681, + "cpu_time": 3999.999999892978, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 18, + "threads": 1, + "iterations": 1, + "real_time": 3749.999450519681, + "cpu_time": 2999.9999999752445, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 19, + "threads": 1, + "iterations": 1, + "real_time": 3791.0067476332188, + "cpu_time": 4000.000000115023, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20_mean", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "mean", + "aggregate_unit": "time", + "iterations": 20, + "real_time": 4154.100315645338, + "cpu_time": 4200.000000009752, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20_median", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "median", + "aggregate_unit": "time", + "iterations": 20, + "real_time": 3770.4885471612215, + "cpu_time": 4000.0000000040004, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20_stddev", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "stddev", + "aggregate_unit": "time", + "iterations": 20, + "real_time": 1224.2255034969583, + "cpu_time": 1507.8740698547117, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20_cv", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "iterations": 20, + "real_time": 0.2947029225284309, + "cpu_time": 0.35901763567885975, + "time_unit": "ns" + } + ] +} diff --git a/benchmarks/bench_aos_traversal.cpp b/benchmarks/bench_aos_traversal.cpp new file mode 100644 index 00000000..ff6841fa --- /dev/null +++ b/benchmarks/bench_aos_traversal.cpp @@ -0,0 +1,95 @@ +// AOS-heavy full-traversal benchmark (issue #61, PRD #57 user story 7): the +// "before" baseline for P1/P4's round-trip-count reduction (NORTH_STAR.md +// §7) -- and the macro signal that would justify a future P5 Primitive +// benchmark (getenv per call, path-string replacement) if +// this comes back slow. +// +// Functionally this is the same traversal bench_equilibrium_read.cpp already +// drives via equilibrium_seed::read_back, isolated and named as its own +// operation (PRD #57 Implementation Decisions: "AOS-heavy traversal +// benchmark") because its coverage target -- P1/P4 round-trip count -- is +// distinct from "read everything in the IDS" as a whole. Where +// BM_EquilibriumWholeIdsRead also reads the top-level scalar and timebase +// leaves, BM_AosHeavyTraversal scopes strictly to the time_slice +// array-of-structures: al_begin_arraystruct_action, then for every element +// (all equilibrium_seed::kNSlices of them, not just one -- that's +// BM_TimeSliceElementRead's job) read all five leaves and +// al_iterate_over_arraystruct to the next element. +// +// Reuses equilibrium_seed.h verbatim (issue #51); no size parametrization +// (PRD #57 user story 9). +// +// Timed region = one operation (issue #53, PRD #50 user story 6): the +// reference equilibrium is written and the data entry opened once in untimed +// setup; only begin_global_action(READ) -> begin_arraystruct_action -> +// iterate every element, reading every leaf -> end_action(aos) -> +// end_action(op) is inside the `for (auto _ : state)` loop. +// +// Backend is a sub-axis of the operation (PRD #50 "structure: +// scenario-primary, backend as a sub-axis"): BM_AosHeavyTraversal is the one +// operation body, HDF5 and Memory are two BENCHMARK_CAPTURE registrations of +// it differing only in which backend id the URI is built for. + +#include "bench_equilibrium_traversal.h" + +#include +#include + +#include + +using al_bench::BenchBackend; +using al_bench::CheckOk; + +void BM_AosHeavyTraversal(benchmark::State& state, BenchBackend backend) { + // pulse=1, matching bench_equilibrium_read.cpp and + // bench_timeslice_element_read.cpp: all three are read-only operations, so + // (unlike bench_equilibrium_write.cpp's pulse=2) there's no repeated-write + // staleness concern that would require a distinct identity within the same + // al_benchmarks process. + const al_contract::PulseId pulse{/*database=*/"bench", /*version=*/"3", + /*pulse=*/1, /*run=*/0}; + + // --- untimed setup: open once, write the reference equilibrium once ------ + al_bench::BenchPulse bp = al_bench::OpenBenchPulse(backend, pulse); + CheckOk(equilibrium_seed::write(bp.pulse_ctx), "equilibrium_seed::write"); + + // --- timed region: begin_global_action(READ) -> every time_slice element, + // every field, via al_iterate_over_arraystruct -> end_action --------- + std::vector shape; + std::vector data; + for (auto _ : state) { + int op = -1; + CheckOk(al_begin_global_action(bp.pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op), + "al_begin_global_action"); + + int size = 0; + int aos_ctx = -1; + CheckOk(al_begin_arraystruct_action(op, equilibrium_seed::kAos, + equilibrium_seed::kAosTime, &size, + &aos_ctx), + "al_begin_arraystruct_action"); + + for (int i = 0; i < size; ++i) { + al_bench::ReadCurrentEquilibriumTimeSliceLeaves(aos_ctx, &shape, &data); + if (i + 1 < size) { + CheckOk(al_iterate_over_arraystruct(aos_ctx, 1), + "al_iterate_over_arraystruct"); + } + } + + CheckOk(al_end_action(aos_ctx), "al_end_action(aos_ctx)"); + CheckOk(al_end_action(op), "al_end_action(op)"); + } + + // --- untimed teardown ------------------------------------------------------ + CheckOk(al_close_pulse(bp.pulse_ctx, CLOSE_PULSE), "al_close_pulse"); + // bp.base's destructor (HDF5 only) removes the temp directory on every exit path. +} +// Repetitions(N) makes Google Benchmark report the distribution (median, +// mean, stddev, iteration count) across N independent traversal cycles, not +// a bare single-shot mean (issue #53, PRD #50 user story 8). +BENCHMARK_CAPTURE(BM_AosHeavyTraversal, HDF5, BenchBackend::kHdf5) + ->Repetitions(20); +BENCHMARK_CAPTURE(BM_AosHeavyTraversal, Memory, BenchBackend::kMemory) + ->Repetitions(20); diff --git a/benchmarks/bench_common.h b/benchmarks/bench_common.h new file mode 100644 index 00000000..3dab4c0d --- /dev/null +++ b/benchmarks/bench_common.h @@ -0,0 +1,91 @@ +// Shared by every bench_*.cpp TU in al_benchmarks: the status-checking helper +// each benchmark uses to turn a failing al_status_t into a thrown exception +// (Google Benchmark reports the failure instead of silently timing garbage), +// plus the per-backend (HDF5 vs. Memory) URI construction and pulse-open +// boilerplate that every whole-IDS-scoped benchmark needs identically (issue +// #58, prefactor for PRD #57's write/single-slice/AOS-traversal benchmarks). +// Header-only so it carries no extra link surface. +#ifndef AL_BENCHMARKS_COMMON_H +#define AL_BENCHMARKS_COMMON_H + +#include "al_contract_legacy_storage.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace al_bench { + +inline void CheckOk(const al_status_t& status, const char* what) { + if (status.code != 0) { + throw std::runtime_error(std::string(what) + " failed (code=" + + std::to_string(status.code) + "): " + + status.message); + } +} + +enum class BenchBackend { kHdf5, kMemory }; + +// HDF5 resolves FORCE_CREATE_PULSE against a real on-disk legacy tree, so it +// needs `base` alive for the URI to mean anything. Memory never touches disk +// (bench_write_read.cpp's MemoryUri established the pattern): an arbitrary +// absolute-looking `user` sidesteps the legacy URI builder's getpwnam() +// fallback for an empty/unknown user, without paying for a real temp dir. +inline std::string BuildUri(BenchBackend backend, const al_contract::PulseId& pulse, + const al_contract::LegacyPulseDirectory* base) { + char* uri = nullptr; + if (backend == BenchBackend::kHdf5) { + assert(base != nullptr && "HDF5 backend requires a legacy pulse directory for its URI"); + CheckOk(al_build_uri_from_legacy_parameters( + HDF5_BACKEND, pulse.pulse, pulse.run, base->str().c_str(), + pulse.database.c_str(), pulse.version.c_str(), + /*options=*/"", &uri), + "al_build_uri_from_legacy_parameters"); + } else { + CheckOk(al_build_uri_from_legacy_parameters( + MEMORY_BACKEND, pulse.pulse, pulse.run, "/al-benchmarks", + pulse.database.c_str(), pulse.version.c_str(), + /*options=*/"", &uri), + "al_build_uri_from_legacy_parameters"); + } + std::string result(uri ? uri : ""); + free(uri); + return result; +} + +// An open pulse context plus (HDF5 only) the temp legacy directory backing +// it. `base` is a unique_ptr rather than the more obvious optional<> so +// BenchPulse stays movable out of OpenBenchPulse regardless of +// LegacyPulseDirectory's own non-movability (its destructor removes the temp +// dir on every exit path, so it must outlive the pulse being open). +struct BenchPulse { + int pulse_ctx = -1; + std::unique_ptr base; +}; + +// Per-backend pulse lifecycle setup shared by every whole-IDS-scoped +// benchmark: materialize the on-disk legacy tree (HDF5 only), build the +// backend's URI, and open the pulse with FORCE_CREATE_PULSE. Callers write +// their fixture and run their timed region on the returned pulse_ctx, then +// close it themselves (close is operation-specific: some benchmarks time +// around it, none should hide it in a header). +inline BenchPulse OpenBenchPulse(BenchBackend backend, const al_contract::PulseId& pulse) { + BenchPulse result; + if (backend == BenchBackend::kHdf5) { + result.base = std::make_unique(); + result.base->make_legacy_tree(pulse); + } + const std::string uri = BuildUri(backend, pulse, result.base.get()); + CheckOk(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &result.pulse_ctx), + "al_begin_dataentry_action"); + return result; +} + +} // namespace al_bench + +#endif // AL_BENCHMARKS_COMMON_H diff --git a/benchmarks/bench_equilibrium_read.cpp b/benchmarks/bench_equilibrium_read.cpp new file mode 100644 index 00000000..923d682c --- /dev/null +++ b/benchmarks/bench_equilibrium_read.cpp @@ -0,0 +1,63 @@ +// The headline §7 P1 metric (NORTH_STAR.md §8.4 "whole-IDS get"): a whole-IDS +// read of the realistic equilibrium fixture, driven strictly at the public C +// ABI (al_lowlevel.h). Reuses equilibrium_seed.h verbatim (issue #51) as the +// workload -- no bespoke fixture -- so the benchmarked shape never drifts +// from what the contract suite already pins. +// +// Timed region = one operation (issue #53, PRD #50 user story 6): the +// reference equilibrium is written and the data entry opened once in untimed +// setup; only equilibrium_seed::read_back's begin_global_action(READ) -> +// traverse every leaf -> end_action is inside the `for (auto _ : state)` loop. +// +// Backend is a sub-axis of the whole-IDS-read operation (PRD #50 "structure: +// scenario-primary, backend as a sub-axis"): BM_EquilibriumWholeIdsRead below +// is the one operation body, and HDF5 (issue #53) / Memory (issue #54) are +// two BENCHMARK_CAPTURE registrations of it, differing only in which backend +// id the URI is built for. Memory is a zero-I/O control -- it never touches +// disk -- so subtracting its number from HDF5's decomposes the total into +// storage cost vs per-node traversal/CPU cost. + +#include "bench_common.h" +#include "al_contract_legacy_storage.h" +#include "equilibrium_seed.h" + +#include +#include + +#include + +using al_bench::BenchBackend; +using al_bench::CheckOk; + +void BM_EquilibriumWholeIdsRead(benchmark::State& state, BenchBackend backend) { + const al_contract::PulseId pulse{/*database=*/"bench", /*version=*/"3", + /*pulse=*/1, /*run=*/0}; + + // --- untimed setup: open once, write the reference equilibrium once ------ + // Memory keeps the fixed pulse identity across repetitions unlike + // bench_write_read.cpp's per-iteration bump: MemoryBackend::closePulse is a + // no-op (src/memory_backend.h), so correctness across the 20 Repetitions + // rests on FORCE_CREATE_PULSE clearing the reused entry's idsMap on reopen, + // not on pulse identities never colliding. + al_bench::BenchPulse bp = al_bench::OpenBenchPulse(backend, pulse); + CheckOk(equilibrium_seed::write(bp.pulse_ctx), "equilibrium_seed::write"); + + // --- timed region: begin_global_action(READ) -> every leaf -> end_action - + std::vector records; + for (auto _ : state) { + CheckOk(equilibrium_seed::read_back(bp.pulse_ctx, &records), + "equilibrium_seed::read_back"); + benchmark::DoNotOptimize(records); + } + + // --- untimed teardown ------------------------------------------------------ + CheckOk(al_close_pulse(bp.pulse_ctx, CLOSE_PULSE), "al_close_pulse"); + // bp.base's destructor (HDF5 only) removes the temp directory on every exit path. +} +// Repetitions(N) makes Google Benchmark report the distribution (median, +// mean, stddev, iteration count) across N independent setup/read/teardown +// cycles, not a bare single-shot mean (issue #53, PRD #50 user story 8). +BENCHMARK_CAPTURE(BM_EquilibriumWholeIdsRead, HDF5, BenchBackend::kHdf5) + ->Repetitions(20); +BENCHMARK_CAPTURE(BM_EquilibriumWholeIdsRead, Memory, BenchBackend::kMemory) + ->Repetitions(20); diff --git a/benchmarks/bench_equilibrium_traversal.h b/benchmarks/bench_equilibrium_traversal.h new file mode 100644 index 00000000..e4b3e6b8 --- /dev/null +++ b/benchmarks/bench_equilibrium_traversal.h @@ -0,0 +1,35 @@ +// Shared equilibrium time_slice leaf traversal for the operation benchmarks. +// Keeping the fixture-specific path list here prevents the single-element and +// full-AOS benchmarks from drifting while leaving their iteration boundaries +// and timed operation structure explicit at each call site. +#ifndef AL_BENCHMARKS_EQUILIBRIUM_TRAVERSAL_H +#define AL_BENCHMARKS_EQUILIBRIUM_TRAVERSAL_H + +#include "al_contract_abi.h" +#include "bench_common.h" +#include "equilibrium_seed.h" + +#include + +#include + +namespace al_bench { + +inline void ReadCurrentEquilibriumTimeSliceLeaves( + int aos_ctx, std::vector* shape, std::vector* data) { + auto read_leaf = [&](const char* path, int rank, const char* what) { + CheckOk(al_contract::read_data(aos_ctx, path, rank, shape, data), + what); + benchmark::DoNotOptimize(*data); + }; + + read_leaf(equilibrium_seed::kSliceTime, 0, "read time"); + read_leaf(equilibrium_seed::kPsi, 1, "read profiles_1d/psi"); + read_leaf(equilibrium_seed::kIp, 0, "read global_quantities/ip"); + read_leaf(equilibrium_seed::kMeasured, 0, "read constraints/ip/measured"); + read_leaf(equilibrium_seed::kWeight, 0, "read constraints/ip/weight"); +} + +} // namespace al_bench + +#endif // AL_BENCHMARKS_EQUILIBRIUM_TRAVERSAL_H diff --git a/benchmarks/bench_equilibrium_write.cpp b/benchmarks/bench_equilibrium_write.cpp new file mode 100644 index 00000000..f8b5c5c1 --- /dev/null +++ b/benchmarks/bench_equilibrium_write.cpp @@ -0,0 +1,54 @@ +// The write-side counterpart to bench_equilibrium_read.cpp (issue #59, PRD +// #57 user stories 2-5): whole-IDS write of the reference equilibrium +// fixture, driven strictly at the public C ABI (al_lowlevel.h), reusing +// equilibrium_seed.h verbatim and bench_common.h's shared per-backend +// pulse-open helper (issue #58). +// +// Timed region mirrors the read benchmark's discipline exactly: the pulse is +// opened once (FORCE_CREATE_PULSE) and closed once, both untimed; only the +// repeated equilibrium_seed::write call sits inside the +// `for (auto _ : state)` loop, so open/close cost never contaminates the +// write measurement. +// +// Correctness of repeated writes on the same open pulse is pinned separately +// by EquilibriumSeedMatrix.RepeatedWholeIdsWriteOnSameOpenPulseSucceeds; this +// benchmark measures the operation without re-asserting the contract. + +#include "bench_common.h" +#include "equilibrium_seed.h" + +#include +#include + +#include + +using al_bench::BenchBackend; +using al_bench::CheckOk; + +void BM_EquilibriumWholeIdsWrite(benchmark::State& state, BenchBackend backend) { + // pulse=2 (distinct from bench_equilibrium_read.cpp's pulse=1) so the two + // benchmarks' Memory-backend entries never share an identity within the + // same al_benchmarks process. + const al_contract::PulseId pulse{/*database=*/"bench", /*version=*/"3", + /*pulse=*/2, /*run=*/0}; + + // --- untimed setup: open once ---------------------------------------------- + al_bench::BenchPulse bp = al_bench::OpenBenchPulse(backend, pulse); + + // --- timed region: repeated whole-IDS write on the same open context ----- + for (auto _ : state) { + CheckOk(equilibrium_seed::write(bp.pulse_ctx), "equilibrium_seed::write"); + } + + // --- untimed teardown ------------------------------------------------------ + CheckOk(al_close_pulse(bp.pulse_ctx, CLOSE_PULSE), "al_close_pulse"); + // bp.base's destructor (HDF5 only) removes the temp directory on every exit path. +} +// Repetitions(N) makes Google Benchmark report the distribution (median, +// mean, stddev, iteration count) across N independent setup/write/teardown +// cycles, not a bare single-shot mean (issue #59, PRD #57 user story 10), +// mirroring bench_equilibrium_read.cpp. +BENCHMARK_CAPTURE(BM_EquilibriumWholeIdsWrite, HDF5, BenchBackend::kHdf5) + ->Repetitions(20); +BENCHMARK_CAPTURE(BM_EquilibriumWholeIdsWrite, Memory, BenchBackend::kMemory) + ->Repetitions(20); diff --git a/benchmarks/bench_main.cpp b/benchmarks/bench_main.cpp new file mode 100644 index 00000000..05bc67da --- /dev/null +++ b/benchmarks/bench_main.cpp @@ -0,0 +1,7 @@ +// Shared Google Benchmark entry point. Every bench_*.cpp in al_benchmarks only +// registers benchmarks via BENCHMARK(...); exactly one translation unit may +// define main(), so it lives here rather than in whichever bench_*.cpp file +// happened to have it first. +#include + +BENCHMARK_MAIN(); diff --git a/benchmarks/bench_timeslice_element_read.cpp b/benchmarks/bench_timeslice_element_read.cpp new file mode 100644 index 00000000..04e1710d --- /dev/null +++ b/benchmarks/bench_timeslice_element_read.cpp @@ -0,0 +1,90 @@ +// The shared "before" baseline for two future optimizations (issue #60, +// PRD #57): a single time_slice element's full leaf set -- its own timebase +// leaf, radial profile array, scalar quantity, and constraints subtree -- +// read by index, node-by-node, through the public C ABI. Today this +// node-by-node walk is the *only* legal way to fetch one AOS element: no +// al_read_subtree call exists yet (NORTH_STAR.md §7 P2) and HDF5 slice-mode +// buffering hasn't been built yet either (§7 P3). Both future optimizations +// diff against BM_TimeSliceElementRead below rather than against two +// near-identical registrations of their own. +// +// Reuses equilibrium_seed.h verbatim (issue #51) for the fixture, exactly as +// bench_equilibrium_read.cpp does, so the workload never drifts from what the +// contract suite already pins. Only the read shape differs: where +// BM_EquilibriumWholeIdsRead walks every time_slice element, +// BM_TimeSliceElementRead selects one (the last, kNSlices - 1, so the +// benchmark exercises al_iterate_over_arraystruct actually moving the +// cursor rather than reading the trivial post-begin default position) and +// reads only that element's five leaves. +// +// Timed region = one operation (issue #53, PRD #50 user story 6): the +// reference equilibrium is written and the data entry opened once in untimed +// setup; only begin_global_action(READ) -> begin_arraystruct_action -> +// iterate to the target index -> read every leaf of that element -> +// end_action(aos) -> end_action(op) is inside the `for (auto _ : state)` loop. +// +// Backend is a sub-axis of the operation (PRD #50 "structure: +// scenario-primary, backend as a sub-axis"): BM_TimeSliceElementRead is the +// one operation body, HDF5 and Memory are two BENCHMARK_CAPTURE +// registrations of it differing only in which backend id the URI is built +// for. + +#include "bench_equilibrium_traversal.h" + +#include +#include + +#include + +using al_bench::BenchBackend; +using al_bench::CheckOk; + +void BM_TimeSliceElementRead(benchmark::State& state, BenchBackend backend) { + const al_contract::PulseId pulse{/*database=*/"bench", /*version=*/"3", + /*pulse=*/1, /*run=*/0}; + constexpr int kTargetSlice = equilibrium_seed::kNSlices - 1; + + // --- untimed setup: open once, write the reference equilibrium once ------ + al_bench::BenchPulse bp = al_bench::OpenBenchPulse(backend, pulse); + CheckOk(equilibrium_seed::write(bp.pulse_ctx), "equilibrium_seed::write"); + + // --- timed region: begin_global_action(READ) -> select element -> + // read every leaf of that element -> end_action ---------------------- + std::vector shape; + std::vector data; + for (auto _ : state) { + int op = -1; + CheckOk(al_begin_global_action(bp.pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op), + "al_begin_global_action"); + + int size = 0; + int aos_ctx = -1; + CheckOk(al_begin_arraystruct_action(op, equilibrium_seed::kAos, + equilibrium_seed::kAosTime, &size, + &aos_ctx), + "al_begin_arraystruct_action"); + // `step` is relative to the cursor's current position, not an absolute + // index (al_lowlevel.h: "iteration step size, typically=1"); this only + // lands on kTargetSlice because al_begin_arraystruct_action always opens + // at index 0. + CheckOk(al_iterate_over_arraystruct(aos_ctx, kTargetSlice), + "al_iterate_over_arraystruct"); + + al_bench::ReadCurrentEquilibriumTimeSliceLeaves(aos_ctx, &shape, &data); + + CheckOk(al_end_action(aos_ctx), "al_end_action(aos_ctx)"); + CheckOk(al_end_action(op), "al_end_action(op)"); + } + + // --- untimed teardown ------------------------------------------------------ + CheckOk(al_close_pulse(bp.pulse_ctx, CLOSE_PULSE), "al_close_pulse"); + // bp.base's destructor (HDF5 only) removes the temp directory on every exit path. +} +// Repetitions(N) makes Google Benchmark report the distribution (median, +// mean, stddev, iteration count) across N independent read cycles, not a +// bare single-shot mean (issue #53, PRD #50 user story 8). +BENCHMARK_CAPTURE(BM_TimeSliceElementRead, HDF5, BenchBackend::kHdf5) + ->Repetitions(20); +BENCHMARK_CAPTURE(BM_TimeSliceElementRead, Memory, BenchBackend::kMemory) + ->Repetitions(20); diff --git a/benchmarks/bench_write_read.cpp b/benchmarks/bench_write_read.cpp new file mode 100644 index 00000000..f204c704 --- /dev/null +++ b/benchmarks/bench_write_read.cpp @@ -0,0 +1,91 @@ +// This is the vertical tracer bullet through the whole build+run+report path: +// one benchmark, driving strictly the public C ABI (al_lowlevel.h), against a +// tiny inline fixture -- deliberately NOT the equilibrium seed yet, so this +// benchmark stays independent of that fixture prefactor. It exists to prove +// the harness itself (FetchContent pin, CTest smoke wiring, JSON output), +// not to produce a meaningful performance number; the real hot-path scenarios +// (whole-IDS read/write, AOS traversal, ...) land separately (NORTH_STAR.md §8.4). + +#include "bench_common.h" + +#include +#include + +#include + +#include +#include + +namespace { + +using al_bench::CheckOk; + +// The core attaches no DD semantics to either string (DD paths are opaque +// strings through the whole C ABI), so an arbitrary IDS name and leaf path +// exercise the same code path a real IDS would. +constexpr const char* kIds = "magnetics"; +constexpr const char* kField = "ids_properties/homogeneous_time"; +constexpr int kValue = 42; + +// Builds a fresh in-RAM Memory-backend URI. The Memory backend never touches +// disk, so the harness has no filesystem dependency to smoke-validate the +// build+run+report path -- but the legacy-parameter URI builder always +// resolves `user` into a filesystem path before the backend even sees it +// (al_context.cpp's DataEntryContext::parseUri), and an empty/unknown user +// falls through to a getpwnam() lookup that throws. An absolute-looking +// string (never actually opened for Memory) sidesteps that lookup, the same +// trick tests/contract/al_contract.h's TempBase uses for on-disk backends. +// `pulse` is bumped by the caller on every iteration so FORCE_CREATE_PULSE +// never collides with a still-registered in-process pulse from a prior one. +std::string MemoryUri(int pulse) { + char* uri = nullptr; + CheckOk(al_build_uri_from_legacy_parameters( + MEMORY_BACKEND, pulse, /*run=*/0, /*user=*/"/al-benchmarks", + /*tokamak=*/"bench", /*version=*/"3", /*options=*/"", &uri), + "al_build_uri_from_legacy_parameters"); + std::string result(uri ? uri : ""); + free(uri); + return result; +} + +} // namespace + +// Single-leaf write-once-then-read: each iteration opens a fresh Memory-backend +// data entry, writes one INTEGER scalar, reads it back, and closes the pulse. +// The whole sequence is the timed region for this tracer-bullet benchmark; the +// finer open-once/timed-read-only split NORTH_STAR.md §8.4 calls for arrives +// with the real equilibrium read benchmark. +void BM_WriteReadRoundTrip(benchmark::State& state) { + int pulse = 0; + for (auto _ : state) { + const std::string uri = MemoryUri(pulse++); + + int pulse_ctx = -1; + CheckOk(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx), + "al_begin_dataentry_action"); + + int write_ctx = -1; + CheckOk(al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, &write_ctx), + "al_begin_global_action(write)"); + int value = kValue; + CheckOk(al_write_data(write_ctx, kField, "", &value, INTEGER_DATA, 0, + nullptr), + "al_write_data"); + CheckOk(al_end_action(write_ctx), "al_end_action(write)"); + + int read_ctx = -1; + CheckOk(al_begin_global_action(pulse_ctx, kIds, "", READ_OP, &read_ctx), + "al_begin_global_action(read)"); + int read_value = 0; + void* buf = &read_value; + int size[MAXDIM] = {0}; + CheckOk(al_read_data(read_ctx, kField, "", &buf, INTEGER_DATA, 0, size), + "al_read_data"); + CheckOk(al_end_action(read_ctx), "al_end_action(read)"); + benchmark::DoNotOptimize(read_value); + + CheckOk(al_close_pulse(pulse_ctx, CLOSE_PULSE), "al_close_pulse"); + } +} +BENCHMARK(BM_WriteReadRoundTrip); diff --git a/benchmarks/capture_baseline.py b/benchmarks/capture_baseline.py new file mode 100755 index 00000000..69d7b453 --- /dev/null +++ b/benchmarks/capture_baseline.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +"""Run the al_benchmarks binary and capture a metadata-tagged baseline artifact. + +Wraps whatever Google Benchmark JSON the harness (benchmarks/al_benchmarks, +issues #52/#53) emits and tags it with the comparability metadata a committed +baseline needs (NORTH_STAR.md §8.4, issue #55): OS, CPU model, compiler + +flags, HDF5 version, filesystem, SLURM partition, and commit SHA. The tagged +JSON is written to the committed-artifact layout, keyed by machine identifier +and commit SHA: + + benchmarks/baselines//.json + +The SLURM partition is never hard-coded: pass --slurm-partition, or run under +an allocation that sets $SLURM_JOB_PARTITION, or leave both unset (recorded as +null -- e.g. a laptop smoke run has no partition at all). + +Comparing two baselines (dividing matching "real_time"/"cpu_time" entries) is +a manual analysis step over the committed JSON, not something this script +does (PRD #50 "Not tested: the numeric results themselves"). +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import re +import socket +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_OUTPUT_DIR = REPO_ROOT / "benchmarks" / "baselines" + + +def run_git(*args: str) -> str: + return subprocess.check_output( + ["git", *args], cwd=REPO_ROOT, text=True + ).strip() + + +def commit_metadata() -> dict: + sha = run_git("rev-parse", "HEAD") + dirty = bool(run_git("status", "--porcelain")) + return {"commit_sha": sha, "commit_dirty": dirty} + + +def sanitize_machine_id(raw: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9]+", "-", raw).strip("-").lower() + return slug or "unknown-machine" + + +def best_effort_output(cmd: list[str]) -> str | None: + """Run `cmd`, returning stripped stdout, or None if it can't be run.""" + try: + return subprocess.check_output( + cmd, text=True, stderr=subprocess.STDOUT + ).strip() + except (OSError, subprocess.CalledProcessError): + return None + + +def detect_os() -> str: + return platform.platform() + + +def detect_cpu_model() -> str: + system = platform.system() + if system == "Darwin": + brand = best_effort_output(["sysctl", "-n", "machdep.cpu.brand_string"]) + if brand: + return brand + hardware = best_effort_output(["system_profiler", "SPHardwareDataType"]) + if hardware: + chip = re.search(r"^\s*Chip:\s*(.+)$", hardware, re.MULTILINE) + if chip: + return chip.group(1).strip() + elif system == "Linux": + try: + with open("/proc/cpuinfo") as f: + for line in f: + if line.lower().startswith("model name"): + return line.split(":", 1)[1].strip() + except OSError: + pass + return platform.processor() or "unknown" + + +def read_cmake_cache(build_dir: Path) -> dict[str, str]: + cache_path = build_dir / "CMakeCache.txt" + entries = {} + if not cache_path.is_file(): + return entries + for line in cache_path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or line.startswith("//"): + continue + if ":" not in line or "=" not in line: + continue + key_type, value = line.split("=", 1) + key = key_type.split(":", 1)[0] + entries[key] = value + return entries + + +def find_build_dir(binary: Path, explicit: Path | None) -> Path | None: + if explicit is not None: + return explicit + for candidate in binary.resolve().parents: + if (candidate / "CMakeCache.txt").is_file(): + return candidate + return None + + +def detect_compiler(cache: dict[str, str]) -> str: + compiler_id = cache.get("CMAKE_CXX_COMPILER_ID") + compiler_version = cache.get("CMAKE_CXX_COMPILER_VERSION") + if compiler_id and compiler_version: + return f"{compiler_id} {compiler_version}" + compiler_path = cache.get("CMAKE_CXX_COMPILER") + if compiler_path: + version_output = best_effort_output([compiler_path, "--version"]) + if version_output: + return version_output.splitlines()[0] + return "unknown" + + +def detect_compiler_flags(cache: dict[str, str]) -> str: + build_type = cache.get("CMAKE_BUILD_TYPE", "") + parts = [cache.get("CMAKE_CXX_FLAGS", "")] + if build_type: + parts.append(cache.get(f"CMAKE_CXX_FLAGS_{build_type.upper()}", "")) + return " ".join(p for p in parts if p).strip() or "unknown" + + +def detect_hdf5_version(cache: dict[str, str]) -> str: + hdf5_dir = cache.get("HDF5_DIR") + if hdf5_dir: + version_file = Path(hdf5_dir) / "hdf5-config-version.cmake" + if version_file.is_file(): + match = re.search( + r'set\(PACKAGE_VERSION\s+"([^"]+)"\)', version_file.read_text() + ) + if match: + return match.group(1) + version_output = best_effort_output(["h5dump", "--version"]) + if version_output: + match = re.search(r"Version\s+([0-9.]+)", version_output) + if match: + return match.group(1) + return "unknown" + + +def detect_filesystem(path: Path, override: str | None) -> str: + if override: + return override + if platform.system() == "Linux": + fstype = best_effort_output(["findmnt", "-no", "FSTYPE", "--target", str(path)]) + if fstype: + return fstype + elif platform.system() == "Darwin": + df_output = best_effort_output(["df", "-P", str(path)]) + mount_output = best_effort_output(["mount"]) + if df_output and mount_output: + device = df_output.splitlines()[-1].split()[0] + for line in mount_output.splitlines(): + if line.startswith(device + " "): + match = re.search(r"\(([^,]+)", line) + if match: + return match.group(1) + return "unknown" + + +def build_metadata(args: argparse.Namespace, build_dir: Path | None) -> dict[str, object]: + cache = read_cmake_cache(build_dir) if build_dir else {} + metadata = { + "captured_at": datetime.now(timezone.utc).isoformat(), + "machine_id": args.machine_id, + "os": detect_os(), + "cpu_model": args.cpu_model or detect_cpu_model(), + "compiler": detect_compiler(cache), + "compiler_flags": detect_compiler_flags(cache), + "build_type": cache.get("CMAKE_BUILD_TYPE", "unknown"), + "hdf5_version": detect_hdf5_version(cache), + "filesystem": detect_filesystem( + build_dir or REPO_ROOT, args.filesystem + ), + "slurm_partition": args.slurm_partition, + } + metadata.update(commit_metadata()) + return metadata + + +def run_benchmark(binary: Path, extra_args: list[str], out_path: Path) -> str | None: + """Run the benchmark binary; return an error message, or None on success.""" + try: + subprocess.run( + [ + str(binary), + f"--benchmark_out={out_path}", + "--benchmark_out_format=json", + *extra_args, + ], + check=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + return f"failed to run {binary}: {exc}" + return None + + +def merge_metadata(json_path: Path, metadata: dict[str, object]) -> dict: + result = json.loads(json_path.read_text()) + result.setdefault("context", {})["imas_baseline_metadata"] = metadata + return result + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="capture_baseline.py", + description=( + "Run al_benchmarks and write a metadata-tagged, committable " + "baseline artifact under benchmarks/baselines/." + ), + ) + parser.add_argument( + "--binary", required=True, type=Path, help="path to the al_benchmarks executable" + ) + parser.add_argument( + "--build-dir", + type=Path, + default=None, + help="CMake build dir to read compiler/flags/HDF5 metadata from " + "(default: discovered by walking up from --binary for CMakeCache.txt)", + ) + parser.add_argument( + "--machine-id", + default=sanitize_machine_id(socket.gethostname()), + type=sanitize_machine_id, + help="identifier this baseline is filed under (always sanitized; default: hostname)", + ) + parser.add_argument( + "--cpu-model", + default=None, + help="CPU model to record when automatic detection is unavailable", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=DEFAULT_OUTPUT_DIR, + help="root of the committed baseline layout (default: benchmarks/baselines)", + ) + parser.add_argument( + "--filesystem", + default=None, + help="filesystem name to record (default: best-effort auto-detect, " + "e.g. pass 'gpfs' explicitly on the cluster)", + ) + parser.add_argument( + "--slurm-partition", + default=None, + help="SLURM partition to record (default: $SLURM_JOB_PARTITION, or null " + "if unset -- this is always a runtime parameter, never hard-coded)", + ) + parser.add_argument( + "--force", + action="store_true", + help="overwrite an existing committed baseline file for this machine+commit", + ) + parser.add_argument( + "benchmark_args", + nargs=argparse.REMAINDER, + help="remaining args forwarded verbatim to al_benchmarks " + "(e.g. -- --benchmark_filter=Equilibrium --benchmark_min_time=1x)", + ) + args = parser.parse_args(argv) + if args.benchmark_args[:1] == ["--"]: + args.benchmark_args = args.benchmark_args[1:] + if args.slurm_partition is None: + args.slurm_partition = os.environ.get("SLURM_JOB_PARTITION") or None + return args + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + + if not args.binary.is_file(): + print(f"error: benchmark binary not found: {args.binary}", file=sys.stderr) + return 1 + + build_dir = find_build_dir(args.binary, args.build_dir) + if build_dir is None: + print( + "warning: could not locate a CMakeCache.txt; compiler/flags/HDF5 " + "metadata will be recorded as 'unknown' (pass --build-dir to fix this)", + file=sys.stderr, + ) + + metadata = build_metadata(args, build_dir) + + if metadata["commit_dirty"]: + print( + "warning: working tree is dirty -- this baseline is not reproducible " + "from the recorded commit SHA alone (metadata still records commit_dirty=true)", + file=sys.stderr, + ) + if metadata["build_type"] != "Release": + print( + f"warning: build type is '{metadata['build_type']}', not Release -- " + "NORTH_STAR.md §8.4 numbers are only comparable across Release builds", + file=sys.stderr, + ) + + machine_dir = args.output_dir / args.machine_id + out_path = machine_dir / f"{metadata['commit_sha']}.json" + if out_path.exists() and not args.force: + print( + f"error: {out_path} already exists (pass --force to overwrite a " + "committed baseline)", + file=sys.stderr, + ) + return 1 + + with tempfile.TemporaryDirectory() as tmp: + raw_json = Path(tmp) / "benchmark_raw.json" + error = run_benchmark(args.binary, args.benchmark_args, raw_json) + if error is not None: + print(f"error: {error}", file=sys.stderr) + return 1 + tagged = merge_metadata(raw_json, metadata) + + machine_dir.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(tagged, indent=2) + "\n") + + print(f"wrote baseline: {out_path}") + width = max(len(key) for key in metadata) + for key, value in metadata.items(): + print(f" {key:<{width}} = {value}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/benchmarks/test_capture_baseline.py b/benchmarks/test_capture_baseline.py new file mode 100644 index 00000000..9478e407 --- /dev/null +++ b/benchmarks/test_capture_baseline.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Hermetic tests for the baseline-capture command-line workflow.""" + +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPT = Path(__file__).with_name("capture_baseline.py") +SPEC = importlib.util.spec_from_file_location("capture_baseline", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +capture_baseline = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(capture_baseline) + + +class CaptureBaselineTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + self.binary = self.root / "fake_benchmark.py" + self.binary.write_text( + "#!/usr/bin/env python3\n" + "import json\n" + "import sys\n" + "from pathlib import Path\n" + "out = next(arg.split('=', 1)[1] for arg in sys.argv " + "if arg.startswith('--benchmark_out='))\n" + "Path(out).write_text(json.dumps({'context': {'source': 'fake'}, " + "'benchmarks': []}))\n" + ) + self.binary.chmod(0o755) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def test_capture_tags_json_and_protects_machine_commit_artifact(self) -> None: + output_dir = self.root / "baselines" + metadata = { + "captured_at": "2026-07-20T10:00:00+00:00", + "machine_id": "machine-a", + "os": "test-os", + "cpu_model": "test-cpu", + "compiler": "test-compiler", + "compiler_flags": "-O3", + "build_type": "Release", + "hdf5_version": "1.14.3", + "filesystem": "testfs", + "slurm_partition": None, + "commit_sha": "deadbeef", + "commit_dirty": False, + } + common_args = [ + "--binary", str(self.binary), + "--machine-id", "../Machine A", + "--output-dir", str(output_dir), + ] + args = [ + *common_args, + "--", "--benchmark_min_time=1x", + ] + + with mock.patch.object(capture_baseline, "build_metadata", return_value=metadata): + self.assertEqual(capture_baseline.main(args), 0) + + artifact = output_dir / "machine-a" / "deadbeef.json" + self.assertTrue(artifact.is_file()) + captured = json.loads(artifact.read_text()) + self.assertEqual(captured["context"]["source"], "fake") + self.assertEqual(captured["context"]["imas_baseline_metadata"], metadata) + + self.assertEqual(capture_baseline.main(args), 1) + self.assertEqual( + capture_baseline.main([*common_args, "--force", "--", "--benchmark_min_time=1x"]), + 0, + ) + + def test_detect_cpu_model_reads_apple_silicon_chip(self) -> None: + with mock.patch.object(capture_baseline.platform, "system", return_value="Darwin"), \ + mock.patch.object( + capture_baseline, + "best_effort_output", + side_effect=[None, "Hardware:\n Chip: Apple M5 Pro\n"], + ): + self.assertEqual(capture_baseline.detect_cpu_model(), "Apple M5 Pro") + + def test_explicit_machine_id_is_sanitized(self) -> None: + args = capture_baseline.parse_args( + ["--binary", str(self.binary), "--machine-id", "../../outside"] + ) + self.assertEqual(args.machine_id, "outside") + + +if __name__ == "__main__": + unittest.main() diff --git a/benchmarks/validate_benchmark_json.cmake b/benchmarks/validate_benchmark_json.cmake new file mode 100644 index 00000000..9e06fe18 --- /dev/null +++ b/benchmarks/validate_benchmark_json.cmake @@ -0,0 +1,27 @@ +# Parse the Google Benchmark output rather than merely checking delimiter +# balance. CMake 3.21 is the repository minimum and provides string(JSON). + +if(NOT DEFINED INPUT) + message(FATAL_ERROR "benchmark JSON checker requires -DINPUT=") +endif() + +file(READ "${INPUT}" content) +if(content STREQUAL "") + message(FATAL_ERROR "benchmark JSON file is empty: ${INPUT}") +endif() + +string(JSON root_type ERROR_VARIABLE parse_error TYPE "${content}") +if(parse_error) + message(FATAL_ERROR "benchmark output is not parseable JSON: ${parse_error}") +endif() +if(NOT root_type STREQUAL "OBJECT") + message(FATAL_ERROR "benchmark JSON root is not an object") +endif() + +string(JSON benchmarks_type ERROR_VARIABLE benchmarks_error TYPE "${content}" benchmarks) +if(benchmarks_error) + message(FATAL_ERROR "benchmark JSON has no top-level 'benchmarks' key: ${benchmarks_error}") +endif() +if(NOT benchmarks_type STREQUAL "ARRAY") + message(FATAL_ERROR "benchmark JSON 'benchmarks' key is not an array") +endif() diff --git a/ci/wheels/cibw_before_build_win.sh b/ci/wheels/cibw_before_build_win.sh index 595523fc..5c6821dd 100644 --- a/ci/wheels/cibw_before_build_win.sh +++ b/ci/wheels/cibw_before_build_win.sh @@ -69,8 +69,26 @@ vcpkg install --classic ${UDA_DEPS[@]} # N.B. all the deps in vcpkgs.json will be installed (if missing) via vcpkg's cmake toolchain file +# Skip the UDA fetch+build entirely when a restored dependency cache already +# provides the installed client (uda-cpp.pc is the last artifact the install +# produces); the windows_deps CI job's cache key includes UDA_REF and this +# script's hash, so a version bump still rebuilds from a cold cache (issue #44). +if test -f "${USEFUL_CMAKE_INSTALL_PREFIX}/lib/pkgconfig/uda-cpp.pc" ;then + echo "UDA already installed under ${USEFUL_CMAKE_INSTALL_PREFIX} (restored cache) - skipping UDA build" +else +# Reuse the UDA source archive from vcpkg's downloads directory when present: +# the windows_deps CI job caches C:\vcpkg\downloads, so each wheel job restores +# the archive instead of re-fetching it from GitHub (issue #44). A fresh +# download is copied back there so the cache saved by that job includes it. +UDA_TARBALL_CACHE="${VCPKG_ROOT}/downloads/UDA-${UDA_REF}.tar.gz" if ! test -f ${UDA_REF}.tar.gz ;then -curl -LO https://github.com/ukaea/UDA/archive/refs/tags/${UDA_REF}.tar.gz + if test -f "${UDA_TARBALL_CACHE}" ;then + cp "${UDA_TARBALL_CACHE}" ${UDA_REF}.tar.gz + else + curl -LO https://github.com/ukaea/UDA/archive/refs/tags/${UDA_REF}.tar.gz + mkdir -p "${VCPKG_ROOT}/downloads" + cp ${UDA_REF}.tar.gz "${UDA_TARBALL_CACHE}" + fi fi if ! test -d UDA-${UDA_REF} ;then tar zxf ${UDA_REF}.tar.gz @@ -96,5 +114,6 @@ if test -d UDA-${UDA_REF} ;then cmake --install build --config Release ) fi +fi # delvewheel is the equivalent of delocate/auditwheel for windows. python -m pip install delvewheel wheel \ No newline at end of file diff --git a/docker/mdsplus/Dockerfile b/docker/mdsplus/Dockerfile new file mode 100644 index 00000000..96248d10 --- /dev/null +++ b/docker/mdsplus/Dockerfile @@ -0,0 +1,48 @@ +# MDSplus characterization environment (issue #13, tracer bullet for #12). +# +# aarch64 (native on Apple Silicon) image that bakes a DD-4.1.1 MDSplus model +# tree and builds the contract suite with AL_BACKEND_MDSPLUS=ON. See +# docker/mdsplus/README.md for the characterization-discovered deviation from +# the issue's ubuntu:22.04 starting point (no arm64 MDSplus .debs exist for +# Ubuntu 22; Ubuntu 24 is the first LTS with an arm64 repo) and for the run +# recipe. +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# --- MDSplus apt repo ------------------------------------------------------- +# The repo key is an ASCII-armored PGP block; apt's signed-by wants a binary +# keyring, hence the dearmor step. Plain http (not https): www.mdsplus.org's +# TLS chain is missing the InCommon intermediate cert, so a fresh container's +# default trust store cannot verify it (characterization-discovered; a +# browser/host with a cached intermediate does not hit this). The repo +# content is still authenticated via the dearmored signed-by key below. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates gnupg wget \ + && mkdir -p /etc/apt/keyrings \ + && wget -qO- http://www.mdsplus.org/dist/mdsplus.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/mdsplus.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/mdsplus.gpg] http://www.mdsplus.org/dist/Ubuntu24/repo MDSplus stable" \ + > /etc/apt/sources.list.d/mdsplus.list \ + && apt-get update + +# --- Toolchain + MDSplus + DD-bake dependencies ----------------------------- +# mdsplus-devel: mdslib.h + link libs (common/cmake/FindMDSplus.cmake). +# mdsplus-java: jTraverser.jar (models/mdsplus/CMakeLists.txt CompileTree) + +# pulls a java-runtime. +# python3/venv: ALBuildDataDictionary.cmake's saxonche venv + pip-installs +# imas_data_dictionary==4.1.1 from PyPI (no DD git clone needed). +RUN apt-get install -y --no-install-recommends \ + mdsplus mdsplus-devel mdsplus-java \ + build-essential cmake git ninja-build pkg-config \ + libboost-filesystem-dev libhdf5-dev \ + python3 python3-venv python3-pip python3-dev \ + openjdk-21-jdk-headless \ + && rm -rf /var/lib/apt/lists/* + +ENV MDSPLUS_DIR=/usr/local/mdsplus +ENV PATH="${MDSPLUS_DIR}/bin:${PATH}" +ENV LD_LIBRARY_PATH="${MDSPLUS_DIR}/lib:${LD_LIBRARY_PATH}" +ENV CLASSPATH="${MDSPLUS_DIR}/java/classes/jTraverser.jar" + +WORKDIR /workspace diff --git a/docker/mdsplus/README.md b/docker/mdsplus/README.md new file mode 100644 index 00000000..d2230fad --- /dev/null +++ b/docker/mdsplus/README.md @@ -0,0 +1,59 @@ +# MDSplus characterization environment (issue #13) + +aarch64 image standing up a real, runnable MDSplus for the contract suite's +MDSplus tier (`tests/contract/test_mdsplus.cpp`, `TRACEABILITY.md` Part 4). + +## Deviation from the issue's starting point + +Issue #12/#13 specified `FROM ubuntu:22.04`. Empirically, MDSplus does not +publish arm64 `.deb` packages for Ubuntu 22 (or 20): its +`https://www.mdsplus.org/dist/Ubuntu22/repo/dists/MDSplus/stable/binary-arm64/Packages` +is a 0-byte file. Ubuntu 24.04 is the first LTS with a populated arm64 +repo, so this image uses `ubuntu:24.04` instead — still native aarch64 (no +emulation), which is what the issue's Apple-Silicon developer story (#12 +user story 8) actually needs. The issue's stated amd64-emulation fallback is +unnecessary as a result. + +## Build + +```sh +docker build --platform linux/arm64 -f docker/mdsplus/Dockerfile \ + -t imas-core-mdsplus:dev docker/mdsplus +``` + +## Bake the DD-4.1.1 model tree and build the contract suite + +Run from the repository root, mounting it into the container: + +```sh +docker run --rm --platform linux/arm64 \ + -v "$PWD":/workspace -w /workspace \ + imas-core-mdsplus:dev bash -c ' + cmake -B build-mdsplus -G Ninja \ + -DAL_BACKEND_MDSPLUS=ON \ + -DAL_BUILD_MDSPLUS_MODELS=ON \ + -DAL_BUILD_TESTS=ON \ + -DDD_VERSION=4.1.1 \ + -DCMAKE_BUILD_TYPE=Release && + cmake --build build-mdsplus -j$(nproc) + ' +``` + +`-DDD_VERSION=4.1.1` with the default `AL_DOWNLOAD_DEPENDENCIES=ON` makes +`common/cmake/ALBuildDataDictionary.cmake` `pip install +imas_data_dictionary==4.1.1` from PyPI — no data-dictionary git clone needed. +The model tree (`ids_model.tree`/`.datafile`/`.characteristics`) lands under +`build-mdsplus/models/mdsplus/`. + +## Run the MDSplus tests + +```sh +docker run --rm --platform linux/arm64 \ + -v "$PWD":/workspace -w /workspace \ + -e MDSPLUS_MODELS_PATH=/workspace/build-mdsplus/models/mdsplus \ + imas-core-mdsplus:dev bash -c \ + 'cd build-mdsplus/tests/contract && ctest -L mdsplus --output-on-failure' +``` + +Omitting `MDSPLUS_MODELS_PATH` makes the same tests report `Skipped` rather +than failing (`GTEST_SKIP()`, TEST_STRATEGY.md D4). diff --git a/docker/uda/Dockerfile b/docker/uda/Dockerfile new file mode 100644 index 00000000..fe5eb95c --- /dev/null +++ b/docker/uda/Dockerfile @@ -0,0 +1,132 @@ +# UDA characterization environment (issue #23, feasibility spike for #21). +# +# aarch64 (native on Apple Silicon / GitHub arm64 runners) image standing up the +# pinned UDA reference stack for the contract suite's UDA tier. Mirrors the +# MDSplus leg's philosophy (docker/mdsplus/): the image is the *toolchain + +# pinned reference stack*, and the IMAS-Core client + the IMAS server plugin are +# built at run time against the mounted workspace, so the tests always exercise +# the source under review (never a stale baked-in core). See docker/uda/README.md +# for the run recipe, the gate decision, and the empirically-answered open items. +# +# Pinned stack identifiers (recorded for TRACEABILITY Part 5): +# - UDA server ....... ukaea/uda tag 2.9.3 +# - IMAS UDA plugin .. iterorganization/UDA-Plugins tag 1.8.0, +# commit ede25b921081d8fc2d66c5b5ca152c664b50ee78 +# - Data Dictionary .. IDSDef.xml DD 4.1.1 +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Pinned stack versions (single source of truth; also surfaced as image labels). +ARG UDA_VERSION=2.9.3 +ARG UDA_PLUGINS_VERSION=1.8.0 +ARG UDA_PLUGINS_COMMIT=ede25b921081d8fc2d66c5b5ca152c664b50ee78 +ARG DD_VERSION=4.1.1 + +LABEL org.iter.imas-core.uda-server-version="2.9.3" +LABEL org.iter.imas-core.uda-plugins-version="${UDA_PLUGINS_VERSION}" +LABEL org.iter.imas-core.uda-plugins-commit="${UDA_PLUGINS_COMMIT}" +LABEL org.iter.imas-core.dd-version="4.1.1" + +# --- Toolchain + UDA server + IMAS-Core + plugin build/runtime dependencies --- +# UDA server 2.9.3 (docker/client.*.dockerfile + .github/workflows/cmake.yml): +# boost(+program_options), openssl, libxml2, fmt, spdlog, capnproto, tirpc. +# IMAS-Core (this repo, same set the mdsplus image uses): HDF5, Boost::filesystem. +# IMAS plugin (UDA-Plugins/source/imas): CURL, Boost::filesystem/system. +# xinetd fronts the per-connection uda_server on a TCP port (simplest in a +# container: no systemd/PID-1 dance). +RUN apt-get update && apt-get install -y --no-install-recommends \ + git build-essential cmake ninja-build pkg-config ca-certificates wget \ + libboost-dev libboost-program-options-dev libboost-filesystem-dev \ + libboost-system-dev \ + libssl-dev libxml2-dev libfmt-dev libspdlog-dev \ + capnproto libcapnp-dev libtirpc-dev \ + libhdf5-dev libcurl4-openssl-dev \ + libgtest-dev \ + python3 python3-venv python3-pip \ + xinetd \ + && rm -rf /var/lib/apt/lists/* + +# Cap'n Proto's Ubuntu 24.04 CMake config has a broken WITH_LIBATOMIC guard +# (ukaea/uda .github/workflows/cmake.yml patches the same file; the arm64 path +# is aarch64-linux-gnu). Harmless no-op if the line is ever fixed upstream. +RUN sed -i 's/if (yes) # WITH_LIBATOMIC/if (no) # WITH_LIBATOMIC/' \ + /usr/lib/aarch64-linux-gnu/cmake/CapnProto/CapnProtoConfig.cmake || true + +# --- UDA server (ukaea/uda, pinned) ---------------------------------------- +# Full build (CLIENT_ONLY=OFF) so we get both the uda-cpp client the IMAS-Core +# UDA backend links AND the uda_server binary + install_plugin. SSL auth OFF to +# keep the reference stack self-contained (no cert provisioning); the contract +# suite characterizes data access, not transport security. Installs to +# /usr/local: uda_server, uda_cli, install_plugin, libuda_*.so, uda-cpp.pc. +RUN git clone --branch "${UDA_VERSION}" --depth 1 \ + https://github.com/ukaea/uda.git /opt/uda-src \ + && cmake -G Ninja -S /opt/uda-src -B /opt/uda-src/build \ + -DBUILD_SHARED_LIBS=ON -DCMAKE_BUILD_TYPE=Release \ + -DCLIENT_ONLY=OFF -DSSLAUTHENTICATION=OFF -DENABLE_CAPNP=ON \ + -DCMAKE_INSTALL_PREFIX=/usr/local \ + && cmake --build /opt/uda-src/build -j"$(nproc)" \ + && cmake --install /opt/uda-src/build \ + && rm -rf /opt/uda-src/build + +ENV LD_LIBRARY_PATH="/usr/local/lib:/usr/local/lib/plugins" +ENV PATH="/usr/local/bin:${PATH}" + +# --- Data Dictionary IDSDef.xml (DD 4.1.1) --------------------------------- +# Sourced from the official imas-data-dictionary PyPI wheel (the same source the +# repo's own ALBuildDataDictionary.cmake uses), NOT by baking a MDSplus model +# tree — AL_BUILD_MDSPLUS_MODELS is deliberately not enabled here. The DD's +# GitHub releases carry no IDSDef.xml asset (it is a generated artifact), so a +# direct release download is not an option; the wheel ships a prebuilt IDSDef.xml +# whose path the packaged `idsinfo idspath` reports. The IMAS-Core UDA backend +# walks this file at run time; its lookup order is $IDSDEF_PATH first, else +# $IMAS_PREFIX/include/IDSDef.xml (src/uda/uda_xml.cpp load_xml()). +RUN python3 -m venv /opt/ddvenv \ + && /opt/ddvenv/bin/pip install --no-cache-dir "imas-data-dictionary==${DD_VERSION}" \ + && cp "$(/opt/ddvenv/bin/idsinfo idspath)" /opt/IDSDef.xml \ + && test -s /opt/IDSDef.xml \ + && grep -q "${DD_VERSION}" /opt/IDSDef.xml +ENV IDSDEF_PATH="/opt/IDSDef.xml" + +# --- A second, older DD version for the unique-surface "wrong-version DD" +# characterization (issue #26, test_uda_unique_surface.cpp) -- a real, +# pre-3-to-4-cliff IDSDef.xml, so the client can genuinely load a schema that +# disagrees with the reference stack's own DD 4.1.1, exposed as +# IDSDEF_PATH_OLDER. Not the default $IDSDEF_PATH (that stays DD 4.1.1 for +# every other test); tests that need it swap $IDSDEF_PATH to this value for +# one backend construction and restore it, and skip if this var is unset. +ARG DD_VERSION_OLDER=3.42.0 +LABEL org.iter.imas-core.dd-version-older="3.42.0" +RUN python3 -m venv /opt/ddvenv-older \ + && /opt/ddvenv-older/bin/pip install --no-cache-dir "imas-data-dictionary==${DD_VERSION_OLDER}" \ + && cp "$(/opt/ddvenv-older/bin/idsinfo idspath)" /opt/IDSDef-older.xml \ + && test -s /opt/IDSDef-older.xml \ + && grep -q "${DD_VERSION_OLDER}" /opt/IDSDef-older.xml \ + && rm -rf /opt/ddvenv-older +ENV IDSDEF_PATH_OLDER="/opt/IDSDef-older.xml" + +# --- IMAS UDA server plugin source (iterorganization/UDA-Plugins, pinned) --- +# Cloned (not tarball) so the plugin's git_describe-derived version resolves to +# the tag and the plugin reports 1.8.0 at run time. Pin both the human-readable +# tag and its immutable commit: if the upstream tag is moved, the build fails +# instead of silently changing the reference image. The plugin is *built* at +# run time (docker/uda/run.sh) because it must link the workspace's IMAS-Core +# (al-core.pc) — the subject under test — and must NOT fall back to the +# mapping-only NO_IMAS degradation. +RUN git clone --branch "${UDA_PLUGINS_VERSION}" --depth 1 \ + https://github.com/iterorganization/UDA-Plugins.git /opt/uda-plugins-src \ + && actual_commit="$(git -C /opt/uda-plugins-src rev-parse HEAD)" \ + && if [ "${actual_commit}" != "${UDA_PLUGINS_COMMIT}" ]; then \ + echo "UDA-Plugins tag ${UDA_PLUGINS_VERSION} resolved to ${actual_commit}, expected ${UDA_PLUGINS_COMMIT}" >&2; \ + exit 1; \ + fi \ + && actual_version="$(git -C /opt/uda-plugins-src describe --tags --exact-match HEAD)" \ + && if [ "${actual_version}" != "${UDA_PLUGINS_VERSION}" ]; then \ + echo "UDA-Plugins git-describe yielded ${actual_version}, expected ${UDA_PLUGINS_VERSION}" >&2; \ + exit 1; \ + fi + +COPY run.sh /usr/local/bin/uda-run.sh +RUN chmod +x /usr/local/bin/uda-run.sh + +WORKDIR /workspace diff --git a/docker/uda/README.md b/docker/uda/README.md new file mode 100644 index 00000000..7f707dd8 --- /dev/null +++ b/docker/uda/README.md @@ -0,0 +1,168 @@ +# UDA characterization environment (issue #23, spike for #21) + +aarch64 image standing up the pinned **UDA reference stack** for the contract +suite's UDA tier (`tests/contract/test_uda.cpp`, and — as later subtasks of +[#21] land — `TRACEABILITY.md` Part 5). This subtask (#23) is the time-boxed +**feasibility spike**: assemble the stack and prove one end-to-end smoke round +trip through the public C ABI, then record the gate decision and the +empirically-resolved open items the PRD listed. + +## Pinned reference stack + +Every UDA verdict is "against reference stack X"; these are that X, and they are +pinned in `Dockerfile` (as `ARG`s + image labels) for the future TRACEABILITY +Part 5 header: + +| Component | Source | Pin | +|-----------|--------|-----| +| UDA server | [`ukaea/uda`](https://github.com/ukaea/uda) | tag **2.9.3** | +| `IMAS` UDA server plugin | [`iterorganization/UDA-Plugins`](https://github.com/iterorganization/UDA-Plugins) | tag **1.8.0**, commit **`ede25b921081d8fc2d66c5b5ca152c664b50ee78`** | +| Data Dictionary | `imas-data-dictionary` (PyPI wheel) | **4.1.1** | +| Data Dictionary (older, unique-surface wrong-version row, `IDSDEF_PATH_OLDER`) | `imas-data-dictionary` (PyPI wheel) | **3.42.0** | +| Base image | `ubuntu:24.04` | arm64 | + +`IMAS` plugin reported version (from `IMAS::version()`, confirmed against the +running stack): **1.8.0**. Since 1.8.0 > 1.4.0, the UDA backend's +`supportsTimeRangeOperation()` returns *true* against this reference stack +(`src/uda/uda_backend.cpp` parses `IMAS::version()` as a semver and compares +`> 1.4.0`) — the row-8 verdict a later subtask pins. + +The Docker build clones the `1.8.0` tag, verifies that `HEAD` is exactly the +commit above, and verifies that `git describe --tags --exact-match HEAD` still +yields `1.8.0`. A moved tag therefore fails the build. The image exposes both +values as `org.iter.imas-core.uda-plugins-version` and +`org.iter.imas-core.uda-plugins-commit` labels. + +## Design: toolchain image + build-workspace-at-runtime + +Mirrors the MDSplus leg (`docker/mdsplus/`): the **image** carries the pinned, +source-independent heavy layers — the built UDA server, the DD `IDSDef.xml`, the +UDA-Plugins source, xinetd — and a **run script** (`run.sh`) assembles the +source-dependent parts against the mounted workspace so the tests always +exercise the IMAS-Core under review, never a stale baked-in core. Concretely +`run.sh`: + +1. builds + installs **IMAS-Core** (the client under test) with the HDF5 and UDA + backends, from `/workspace`; +2. builds + installs + registers the **`IMAS` server plugin** against *that* + IMAS-Core (via its `al-core.pc`) — and fails loudly if the plugin degrades to + the mapping-only `NO_IMAS` build, which the PRD forbids; +3. starts the **UDA server** (xinetd fronts the per-connection `uda_server` on + TCP 56565); +4. runs the **UDA contract tier** (`ctest -L uda`). + +Because the plugin is compiled against the workspace's IMAS-Core, "linked +against server-side IMAS-Core, not `NO_IMAS`" is guaranteed structurally: the +plugin's `find_package(IMAS)` resolves the `al-core` pkg-config module the repo +installs (`pkgconfig/al-core.pc.in`), so `IMAS_FOUND` is true and `-DNO_IMAS` is +never added (`UDA-Plugins/source/imas/CMakeLists.txt`). + +## Build + +```sh +docker build --platform linux/arm64 -f docker/uda/Dockerfile \ + -t imas-core-uda:dev docker/uda +``` + +The build is source-independent: it does not COPY the repo, so it is cached +across code changes (only `Dockerfile`/`run.sh` edits invalidate it). + +## Run the smoke round trip + +From the repository root, mounting it into the container: + +```sh +docker run --rm --platform linux/arm64 \ + -v "$PWD":/workspace -w /workspace \ + imas-core-uda:dev uda-run.sh +``` + +`uda-run.sh` builds the stack, starts the server, and runs `ctest -L uda`. Pass +an alternative command to stop after the stack is up, e.g. `... imas-core-uda:dev +uda-run.sh bash` for an interactive shell, or `... uda-run.sh ctest -L uda -R +Smoke --output-on-failure`. + +Outside this container the same test is inert: `tests/contract/test_uda.cpp` +compiles out unless `AL_BACKEND_UDA=ON` (build gate `AL_CONTRACT_HAVE_UDA`), and +runtime-skips (`GTEST_SKIP()`) unless `UDA_HOST` is set — so ordinary local +builds and always-on CI legs never see it fail (TEST_STRATEGY.md D4). + +## Cluster portability + +The recipe makes no host-specific assumptions: a single base image, all +dependencies from `apt` + the DD PyPI wheel, no bind mounts other than the +workspace, and TCP 56565 kept inside the container. `UDA_ALLOWED_PATHS=/` (set +by `run.sh`) is acceptable only because this is a single-purpose, throwaway, +network-isolated reference container — it must not be copied to a shared server. + +## Gate decision — FULL-STACK TIER CONFIRMED + +The stack assembled within the time box and the smoke round trip passes: +`ctest -L uda` runs `UdaSmokeRoundTrip.ScalarSeededViaHdf5ReadsBackThroughUda` +green — a scalar seeded through the plain HDF5 backend reads back +byte-identical through the UDA backend in remote mode, across a real +`uda_server` running the `IMAS` plugin linked against a server-side IMAS-Core +(not `NO_IMAS`; `run.sh` fails loudly on that degradation). The fallback tier +(client-side-only, remote rows blocked-by-environment) is **not** invoked. + +The subsequent subtasks of [#21] (read-only parity fixture, unique-surface file, +TRACEABILITY Part 5, the non-blocking `uda-contract` CI leg) can proceed on this +foundation. + +## Empirical findings (open items from PRD #21) + +Resolved from the running stack (2.9.3 / plugin 1.8.0 / DD 4.1.1), not from +static reading — recorded here for the Part 5 header and the unique-surface +subtask (#21 area 3). + +- **`IDSDef.xml` runtime lookup + absent-file behavior** (client-side, the UDA + backend's runtime DD coupling): lookup order is `$IDSDEF_PATH` first, else + `$IMAS_PREFIX/include/IDSDef.xml` (`src/uda/uda_xml.cpp` `load_xml()`). Both + failure modes surface through the C ABI at `al_begin_dataentry_action` (the + `UDABackend` constructor loads the DD), `al_status_t.code = -1`: + - neither var set → `neither IMAS_PREFIX or IDSDEF_PATH environmental variable + is set`; + - `$IDSDEF_PATH` set but the file missing → `IDSDef.xml not found at either + $IDSDEF_PATH or $IMAS_PREFIX/include/IDSDef.xml`. +- **Remote-write / delete error surface, exact C-ABI pin confirmed (issue + #27):** the reference `IMAS` server plugin has no `writeData`/`deleteData` + handler; raw `uda_cli --request "IMAS::writeData(...)"` / + `"IMAS::deleteData(...)"` both return `[handle_request]: Unknown function + requested!`. But the two C-ABI entry points surface that dispatch failure + *differently* — a divergence only visible by driving the actual client + library calls, not by probing with `uda_cli`: `UDABackend::deleteData` + issues its directive via `uda::Client::get()`, whose error path does + propagate the server's failure as a non-zero `al_status_t` carrying that + exact text. `UDABackend::writeData` issues its directive via + `uda::Client::put()` instead, which does not throw on an in-band server + dispatch failure — so `al_write_data` reports `al_status_t.code == 0` + (false success) and the write is silently dropped. Pinned: + `UdaUniqueSurfaceTest.RemoteWriteReportsSuccessButNeverPersists` (reopens via + remote mode afterward and confirms the scalar is unchanged) and + `UdaUniqueSurfaceTest.RemoteDeleteFailsWithUnknownFunctionRequested`. +- **Fetch-mode cache-directory naming/reuse semantics, fully characterized + (issue #27):** the local cache dir is the `local_cache` URI option when + given (still joined with the remote path's `relative_path()`, i.e. it + overrides only the cache *root*, not the whole path) or else + `${TMPDIR}/uda-cache-of-${USER}/` (falling back to + `${TMPDIR}/uda-cache` when `$USER` is unset) (`src/uda/uda_backend.cpp` + `fetch_files`). A file already present in the cache is **not** re-downloaded + (`download_file` early-returns on existence, confirmed via its own verbose + trace line), so a locally-written value persists across sessions even + though it never reached the server — the write-divergence pin. The `BYTES` + server plugin this needs ships and is registered **by default** in + `ukaea/uda`'s own build (`source/plugins/CMakeLists.txt` builds + `bytes`/`help`/`template`/`testplugin`/`uda` unless `BUILD_PLUGINS` is set, + each `uda_plugin()` call appending itself to the installed + `udaPlugins.conf`) — confirmed present on this image without any + `docker/uda/` change. The prior note that this reference stack "does not + register" `BYTES` was an unverified assumption from this spike; it did not + hold up empirically and is corrected here. +- **Reference plugin reported version:** `IMAS::version()` → **1.8.0** (see the + pinned-stack note above for the `supportsTimeRangeOperation` consequence). +- **Pinned stack identifiers** (for the Part 5 header): UDA server **2.9.3**, + `IMAS` plugin **1.8.0** at commit + **`ede25b921081d8fc2d66c5b5ca152c664b50ee78`**, DD **4.1.1**, base + `ubuntu:24.04` (arm64). Also recorded as `org.iter.imas-core.*` image labels. + +[#21]: https://github.com/yohannmarguier/IMAS-Core/issues/21 diff --git a/docker/uda/run.sh b/docker/uda/run.sh new file mode 100644 index 00000000..0953e259 --- /dev/null +++ b/docker/uda/run.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# docker/uda/run.sh — assemble the UDA reference stack against the mounted +# workspace and drive the UDA contract tier (issue #23, PRD #21). +# +# The image (docker/uda/Dockerfile) ships the pinned, source-independent layers: +# UDA server 2.9.3, the UDA-Plugins 1.8.0 source, IDSDef.xml (DD 4.1.1), xinetd. +# This script does the source-dependent assembly at run time so the tests always +# exercise the IMAS-Core under review (never a stale baked-in core), mirroring +# the MDSplus leg's build-workspace-at-runtime philosophy: +# 1. build + install IMAS-Core (the client under test) with the HDF5 and UDA +# backends, from /workspace; +# 2. build + install + register the `IMAS` server plugin against *that* +# IMAS-Core (al-core.pc) — asserting it is NOT the mapping-only NO_IMAS +# degradation the PRD forbids; +# 3. start the UDA server (xinetd fronts uda_server on TCP 56565); +# 4. run the UDA contract suite (`ctest -L uda`). +# +# Any extra CLI args are treated as the ctest invocation to run instead of the +# default, e.g. `uda-run.sh ctest -L uda -R Smoke --output-on-failure` or +# `uda-run.sh bash` for an interactive shell after the stack is up. +set -euo pipefail + +WORKSPACE=${WORKSPACE:-/workspace} +PREFIX=/usr/local +BUILD_DIR=${BUILD_DIR:-${WORKSPACE}/build-uda} +PLUGINS_SRC=/opt/uda-plugins-src + +export PKG_CONFIG_PATH="${PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-}" +export CMAKE_PREFIX_PATH="${PREFIX}:${CMAKE_PREFIX_PATH:-}" +export LD_LIBRARY_PATH="${PREFIX}/lib:${PREFIX}/lib/plugins:${LD_LIBRARY_PATH:-}" + +echo "==> [1/4] Building IMAS-Core (client under test) with HDF5 + UDA backends" +cmake -S "${WORKSPACE}" -B "${BUILD_DIR}" -G Ninja \ + -DAL_BACKEND_HDF5=ON -DAL_BACKEND_UDA=ON -DAL_BUILD_TESTS=ON \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="${PREFIX}" +cmake --build "${BUILD_DIR}" -j"$(nproc)" +cmake --install "${BUILD_DIR}" +# al-core.pc is the seam the plugin's FindIMAS.cmake consumes; fail early if the +# install didn't produce it (otherwise the plugin would silently go NO_IMAS). +pkg-config --exists al-core \ + || { echo "FATAL: al-core.pc not installed — plugin would degrade to NO_IMAS"; exit 1; } + +echo "==> [2/4] Building + registering the IMAS server plugin against IMAS-Core" +# Configure output is captured so we can prove IMAS was found (not NO_IMAS): the +# plugin's source/imas/CMakeLists.txt prints 'IMAS not found - building for +# MAPPING only' and appends -DNO_IMAS when al-core is absent. The PRD forbids +# that degradation, so we treat it as a hard failure. +cfg_log=$(mktemp) +cmake -S "${PLUGINS_SRC}" -B "${PLUGINS_SRC}/build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="${PREFIX}" \ + -DBUILD_PLUGINS="imas" 2>&1 | tee "${cfg_log}" +if grep -qi "building for MAPPING only" "${cfg_log}"; then + echo "FATAL: IMAS plugin configured as NO_IMAS (mapping-only) — not acceptable (PRD #21)." + exit 1 +fi +cmake --build "${PLUGINS_SRC}/build" -j"$(nproc)" +cmake --install "${PLUGINS_SRC}/build" +test -e "${PREFIX}/lib/plugins/libimas_plugin.so" \ + || { echo "FATAL: IMAS plugin .so not installed"; exit 1; } +"${PREFIX}/bin/install_plugin" -u "${PREFIX}" install IMAS +echo "--- registered plugins (udaPlugins.conf) ---" +cat "${PREFIX}/etc/plugins/udaPlugins.conf" + +echo "==> [3/4] Starting the UDA server (xinetd on TCP 56565)" +# udaserver.cfg ships without UDA_ALLOWED_PATHS (which defaults to blocking all +# file access) and without IDSDEF_PATH; append both. UDA_ALLOWED_PATHS=/ is safe +# in this isolated single-purpose reference container (documented in README). +# UDA_IMAS_MAPPINGS_FILE: the IMAS plugin's init unconditionally loads a +# machine-mapping table (source/imas/machine_mapping.h) and throws if the env +# var is unset -- even for remote (non-mapped) access like ours. The plugin +# ships a valid table; point at it so init succeeds. Our remote hdf5 path never +# consults the mapping, but the file must exist and parse (5 columns/line). +# +# The shipped udaserver.cfg already assigns UDA_ALLOWED_PATHS= (empty, which +# blocks all file access), so we cannot guard on that name -- we would match the +# shipped line and skip our whole block. Guard instead on UDA_IMAS_MAPPINGS_FILE +# (absent from the shipped cfg) and append at the end: udaserver.sh sources the +# file top-to-bottom, so our later UDA_ALLOWED_PATHS=/ overrides the empty one. +PLUGIN_MAPPINGS=${PLUGINS_SRC}/source/imas/mappings.txt +if ! grep -q UDA_IMAS_MAPPINGS_FILE "${PREFIX}/etc/udaserver.cfg"; then + { + echo "export UDA_ALLOWED_PATHS=/" + echo "export IDSDEF_PATH=${IDSDEF_PATH:-/opt/IDSDef.xml}" + echo "export UDA_IMAS_MAPPINGS_FILE=${PLUGIN_MAPPINGS}" + } >> "${PREFIX}/etc/udaserver.cfg" +fi +# xinetd's shipped config leaves user= empty; make it explicit for a root +# container so xinetd doesn't refuse the service. +sed -i 's/^\( *user *= *\)$/\1root/' "${PREFIX}/etc/xinetd.conf" +xinetd -dontfork -f "${PREFIX}/etc/xinetd.conf" & +XINETD_PID=$! +# Wait for the listener before running the client; a server that never comes +# up is a bootstrap failure, not something to paper over — the contract tier +# would GTEST_SKIP() every case and CTest would exit 0 having tested nothing +# (issue #41). +listening=0 +for _ in $(seq 1 30); do + if bash -c "exec 3<>/dev/tcp/localhost/56565" 2>/dev/null; then + exec 3>&- 2>/dev/null || true + listening=1 + break + fi + sleep 0.5 +done +if [ "${listening}" != 1 ]; then + echo "FATAL: UDA server never started listening on localhost:56565 (xinetd pid ${XINETD_PID})" + exit 1 +fi +echo "UDA server listening on localhost:56565 (xinetd pid ${XINETD_PID})" +# Sanity ping through UDA's own CLI (independent of IMAS-Core) so a server-side +# failure is distinguishable from a client-side one. Fatal: a stack whose own +# CLI cannot talk to the server must not proceed to a CTest run whose UDA cases +# would all skip-and-pass (issue #41). +UDA_HOST=localhost UDA_PORT=56565 "${PREFIX}/bin/uda_cli" \ + --host localhost --port 56565 --request "help::help()" >/dev/null 2>&1 \ + || { echo "FATAL: uda_cli help::help() ping failed — server unusable"; exit 1; } +echo "uda_cli help::help() OK" + +echo "==> [4/4] Running the UDA contract tier" +export UDA_HOST=localhost UDA_PORT=56565 +if [ "$#" -gt 0 ]; then + exec "$@" +fi +cd "${BUILD_DIR}/tests/contract" + +# Dedicated-CI mode (AL_UDA_DEDICATED_CI=1, set by uda-contract.yml): inside +# the reference-stack job, "server unreachable -> skip" is not a valid green. +# Parse CTest's machine-readable JUnit output and fail unless a nonzero number +# of UDA tests actually executed (issue #41). Ordinary developer builds keep +# the compile-gate/runtime-skip behavior: without the flag nothing changes. +if [ "${AL_UDA_DEDICATED_CI:-0}" = "1" ]; then + results="${BUILD_DIR}/uda-ctest-results.xml" + ctest -L uda --output-on-failure --output-junit "${results}" + # CTest writes the attributes one per line — flatten the file + # before extracting them, and take only the suite header (the + # elements carry no tests=/skipped= attributes). + suite_header=$(tr '\n\t' ' ' < "${results}" | grep -o ']*>' | head -1) + total=$(printf '%s' "${suite_header}" | grep -o 'tests="[0-9]*"' | head -1 | grep -o '[0-9]*') + skipped=$(printf '%s' "${suite_header}" | grep -o 'skipped="[0-9]*"' | head -1 | grep -o '[0-9]*') + total=${total:-0} + skipped=${skipped:-0} + executed=$((total - skipped)) + echo "Dedicated-CI check: ${total} UDA tests selected, ${skipped} skipped, ${executed} executed." + if [ "${executed}" -le 0 ]; then + echo "FATAL: dedicated UDA tier executed zero tests (all skipped or none selected) — the reference stack verified nothing (issue #41)." + exit 1 + fi +else + ctest -L uda --output-on-failure +fi diff --git a/docs/adr/0001-al-delete-data-path-semantics.md b/docs/adr/0001-al-delete-data-path-semantics.md new file mode 100644 index 00000000..97ad6f83 --- /dev/null +++ b/docs/adr/0001-al-delete-data-path-semantics.md @@ -0,0 +1,110 @@ +--- +status: accepted +--- + +# `al_delete_data`'s `path` selects a node and its subtree, never the occurrence + +`al_delete_data(ctx, path)` has always documented `path` as "the data structure +element to delete (suppress the whole subtree)", but the HDF5 backend dropped +the argument on the floor: `HDF5Backend::deleteData` did not forward it and +`HDF5Writer::deleteData` had no parameter to receive it, so **any** non-empty +path deleted the whole IDS occurrence — the per-IDS pulse file and the master +file's external link to it — and returned success (issue #63). We keep the +documented meaning and implement it: a non-empty `path` deletes that node and +its subtree only, an empty `path` addresses the whole DATAOBJECT, and deleting +an absent path is a successful no-op. + +## Considered options + +**1. Per-path delete is intended (chosen).** Implemented in +`HDF5Writer::deleteSubtree`. An IDS group is a flat namespace of "tensorized" +dataset names — the DD path with `/` → `&`, array-of-structures nodes suffixed +`[]`, one `_SHAPE` companion per non-scalar leaf, one `…[]&AOS_SHAPE` per +dynamic AOS — so `time_slice/global_quantities/ip` is stored as +`time_slice[]&global_quantities&ip`, flattened over the AOS index. Matching is +therefore **per segment**, not by string prefix: each segment of the requested +path matches a name segment with or without the `[]` suffix, and the name's +remaining segments are its subtree. Segment-wise is what tells a real child +from a same-prefix stranger (deleting `time` must not take `time_slice[]&x`, +deleting `code` must not take `code_name`) while still letting a path address a +node *inside* an AOS. That last case has only one available meaning: the ABI +hands `deleteData` an `OperationContext`, never an arraystruct one, so there is +no way to name an element and `time_slice/global_quantities` can only mean "that +subtree in every element" — which is exactly what the tensorized layout stores. +A raw-prefix rule matched no dataset at all for such a path and returned +success, i.e. it reproduced in miniature the very failure mode this ADR +rejects. + +**2. Occurrence-wide delete is intended: reject a non-empty `path`.** Rejected. +It is the cheaper change, but it is not what the ABI says and not what the other +backends do — and it would break every HDF5 write through the HLI. imas-python's +`delete_children()` walks the DD and issues one `al_delete_data` per leaf +(`imas/backends/imas_core/db_entry_helpers.py`), and `DBEntry.put()` runs that +whole traversal before writing (`db_entry_al.py`), so rejecting non-empty paths +would fail every non-slice `put()` and `DBEntry.delete_data()` on HDF5. Today +that traversal *appears* to work only because the first forwarded delete +destroys the file and the following ones silently do nothing (the first call +closes the IDS group, after which every later call returns early) — and the put +then rewrites everything from scratch. That accident is what hid the defect. + +The third state the code was actually in — argument accepted, ignored, success +reported — is what neither option tolerates, and it is not merely untidy. It is +the mechanism behind a silent data corruption downstream: the +IMAS-Multiversion-DD-Loader refuses to delete an occurrence's DD-version stamp +so a converted pulse cannot hold undateable numbers, but inside a full put the +traversal's first forwarded delete has already taken the whole file, stamp +included, and the rest of the put is written untranslated +([IMAS-Multiversion-DD-Loader#139](https://github.com/yohannmarguier/IMAS-Multiversion-DD-Loader/issues/139)). + +## Per-backend behaviour, as of this ADR + +Checked by reading each `deleteData` and pinned in `tests/contract/` +(`test_structured_data.cpp` `DeleteMatrix` / `Hdf5Delete`, +`test_mdsplus_delete.cpp`, `test_uda_unique_surface.cpp`); the ledger is +`tests/contract/TRACEABILITY.md`. + +| Backend | node | subtree | whole DATAOBJECT (`path == ""`) | +|---|---|---|---| +| HDF5 | ✅ | ✅ | ✅ — deletes `.h5` *and* the master-file link | +| Memory | ✅ | ✅ (AOS node; a plain non-AOS structure is uncharacterized) | ❌ no code path for it — xfail | +| MDSplus | ✅ | ❌ `%TREE-W-NNF` — xfail | ❌ `%TREE-W-NNF` — xfail | +| ASCII | ❌ empty body | ❌ | ❌ | +| Flexbuffers | ❌ empty body | ❌ | ❌ | +| UDA | forwards `path` (remote: no server handler → clean refusal; local/fetch: delegates to the local backend) | " | " | + +So HDF5 was the only backend that *widened* `path`; the rest either honour it or +ignore the call entirely. None of them reports "I cannot honour this path", +which is why the header now says so explicitly rather than leaving an HLI to +discover it. + +`MemoryBackend::deleteData` also carries a dead `ctx->getType() == +CTX_ARRAYSTRUCT_TYPE` branch: the parameter is an `OperationContext*` and +`al_delete_data` `dynamic_cast`s to one, so an arraystruct context can never +reach it. A plain (non-AOS) structure path on Memory is not characterized. + +## Consequences + +- **An emptied occurrence still exists on HDF5.** `DBEntry.delete_data()` walks + the DD and deletes leaf by leaf, so `.h5` and its master-file link + survive with no datasets left in them, and `al_get_occurrences` keeps + reporting the occurrence. That is the honest consequence of `path` meaning + what it says; an HLI that wants the occurrence gone should pass `path == ""`, + which is now the only call that removes it. +- **A delete never shrinks `.h5`.** `H5Ldelete` unlinks; the space is + reclaimed only by repacking the file. +- **The master-file link now follows the file.** A whole-DATAOBJECT delete + removes the external link as well, so the master file no longer advertises an + occurrence whose backing file is gone — the second half of what issue #63 + measured. Issue #63 raises the dangling link under option 2 only, but it is + listed there as part of what was measured on today's code, so it is fixed + either way. +- **A whole-DATAOBJECT delete now also removes an IDS file that was closed but + still on disk.** The old code reopened such a file and then marked it closed + without deleting it — leaking the handle and leaving the data in place. The + issue never measured this (through a `WRITE_OP` the file is always open, so + the branch was unreachable in the reproduction), but the empty-path contract + cannot hold with it in place. +- A whole-IDS delete lists the IDS group's link names once per delete rather + than once per leaf (`HDF5Writer::groupMembers`, invalidated by anything that + can add a dataset). Without that cache the HLI's leaf-by-leaf traversal would + be quadratic in the size of the IDS. diff --git a/docs/adr/0002-absolute-field-path-resolution.md b/docs/adr/0002-absolute-field-path-resolution.md new file mode 100644 index 00000000..3333f7f8 --- /dev/null +++ b/docs/adr/0002-absolute-field-path-resolution.md @@ -0,0 +1,130 @@ +--- +status: accepted +--- + +# An absolute field path resolves by segment match against the open context chain + +`al_read_data(ctx, fieldpath, …)` documents `fieldpath` as "always relative to +current Context, dataobject absolute path can be specified with a prepended +'/'" (`include/al_lowlevel.h`, read-data block). An absolute field path is not +a filesystem path and does not carry the IDS name: +`/time_slice/constraints/j_phi/reconstructed` names the same stored datum as +`reconstructed` read from the corresponding array-of-structures context. + +The HDF5 read path honoured only the relative half. `HDF5Reader::read_ND_Data` +replaced every `/` with `&` (the tensorized separator) and then +*unconditionally* prepended the current AOS context's tensorized path, so an +already-rooted name was appended to the context it was meant to replace: + +```text +requested /time_slice/constraints/j_phi/reconstructed +looked up time_slice[]&constraints&j_phi[]&&time_slice&constraints&j_phi&reconstructed +stored as time_slice[]&constraints&j_phi[]&reconstructed +``` + +`H5Lexists` found nothing, `read_ND_Data` returned 0, and `al_plugin_read_data` +filled the caller's buffer with `EMPTY_DOUBLE` while reporting `code == 0` +(`Lowlevel::setDefaultValue`). Stored data was therefore indistinguishable from +genuinely absent data — a silent wrong answer, not an error (issue #65). The +same defect made a root field unreadable from the operation context itself, +where the leading `&` was the only thing left to strip. + +We keep the documented meaning and implement it: an absolute field path is +resolved **by path segment** against the AOS chain the caller currently has +open, retaining the tensorized markers and the indices of every level the +target still sits under, and only those. + +## Considered options + +**1. Segment-match against the open context chain (chosen).** Implemented in +`HDF5Utils::resolveAbsoluteFieldPath`, called from `read_ND_Data`. An IDS group +is a flat namespace of tensorized dataset names — the DD path with `/` → `&`, +each array-of-structures segment suffixed `[]`, the AOS flattened into a +leading index dimension — so resolving an absolute path means answering two +questions at once, which is why one function answers both: + +- **which name to look up.** The stored name keeps the `[]` of every AOS the + field sits under, so the tensorized prefix of the matched levels is retained + and only the remaining segments are appended. +- **which indices still apply.** Each retained `[]` level keeps its current + cursor; levels the target does not sit under are dropped, along with the + timed-AOS designation if the timed level is among them (the dataset then + carries no time dimension to select in). + +Both fall out of one walk: compare the absolute path's segments against the +open chain's segments and cut at the last AOS boundary that still matches. +Matching is per segment, never by string prefix — `time_slice_extra/…` shares a +textual prefix with `time_slice[]` but no path prefix at all. This is the same +rule ADR 0001 adopted for `al_delete_data`, for the same reason: segment-wise +is what tells a real descendant from a same-prefix stranger. + +Worked examples, with `time_slice[]` → `constraints/j_phi[]` open at +`(slice=1, constraint=2)`: + +| Requested | Resolved name | Indices applied | +|---|---|---| +| `/time_slice/constraints/j_phi/reconstructed` | `time_slice[]&constraints&j_phi[]&reconstructed` | `(1, 2)` | +| `/time_slice/global_quantities/ip` | `time_slice[]&global_quantities&ip` | `(1)` | +| `/vacuum_toroidal_field/r0` | `vacuum_toroidal_field&r0` | none | +| `/time_slice/constraints/j_phi/reconstructed`, from the `time_slice` context | `time_slice[]&constraints&j_phi&reconstructed` | `(1)` → not found, absent | + +**2. Strip the leading `/` and use the rest verbatim.** Rejected. It resolves a +root field correctly and nothing else: the result carries no `[]` for any AOS +above the target, so every path through an array of structures — the case the +issue is actually about — still misses. It also silently duplicates the path +when the target is under the open context. + +**3. Skip the context prefix by string length.** Rejected. Cheaper than a +segment walk and wrong twice over: it drops the `[]` markers along with the +prefix, and a purely textual prefix matches same-prefix strangers. + +**4. Keep the child's whole cursor regardless of where the target sits.** +Rejected. An ancestor-level dataset carries fewer tensorized dimensions than +the child context has indices, so applying all of them either selects out of +extent or reads the wrong element. This is the mirror image of option 2's +failure and would be the more dangerous one, since it returns a number. + +**5. Leave the core alone; have the caller rewrite absolute to relative.** +Rejected. The contract being broken is the core's own, stated in its public +header, so every caller would have to carry the same workaround. It is also not +generally possible: an absolute path may address an *ancestor* or a *root* +field, which no relative spelling from the current context can name, and a +blanket rewrite of the caller's argument would corrupt exactly those cases. + +## Scope + +The reproduction, diagnosis and fix are HDF5 **reads**. Writes, +`al_delete_data`, `al_begin_arraystruct_action` and the other backends were not +established as affected by this reproduction and are deliberately not changed +here; a concrete finding about any of them belongs in its own issue. The +regression suite writes through relative spellings only, so it never presumes +an answer for the write side. + +The change is confined to path/selection computation: no DD-version knowledge, +no schema lookup, no C ABI change. The version knowledge constraint in +`NORTH_STAR.md` is the reason a "just look up whether that segment is an AOS" +resolution is not on the table at all. + +## Consequences + +- **A target beyond an AOS the caller never opened is reported absent.** There + is no cursor for that level, and the core holds no schema that would even + tell it the segment is an array of structures. The resolved name carries no + `[]` for it, finds nothing, and travels the ordinary missing-data path. That + is the honest answer; inventing an index is how a wrong value gets returned + in place of no value. Pinned by + `Hdf5AbsoluteFieldPaths.TargetBeyondAnUnopenedAosIsReportedAbsent`. +- **Ordinary missing-data semantics are unchanged.** An absolute path that + resolves but names nothing stored still returns `code == 0` with the type's + empty sentinel, exactly as the relative spelling does. The fix makes stored + data findable; it does not make absent data look present. +- **Existence and dataset caches are keyed on the resolved name** + (`existing_data_sets`, `opened_data_sets`), so the two spellings of one + address share one cache entry and cannot disagree across repeated reads. +- **A root timebase is now classified correctly.** `/time` from the operation + context previously resolved to `&time`, which matched the "time dataset + inside a dynamic AOS" suffix test rather than the homogeneous-timebase one. + It now resolves to `time` and is classified as the homogeneous timebase. +- **Absolute and relative reads may still differ in *cost*.** Nothing here + changes the per-node `Backend` contract: an absolute read is resolved and + looked up one field at a time, like every other read. diff --git a/fixtures/CMakeLists.txt b/fixtures/CMakeLists.txt new file mode 100644 index 00000000..fa4f92eb --- /dev/null +++ b/fixtures/CMakeLists.txt @@ -0,0 +1,45 @@ +######################################################################################################################## +# Shared contract fixtures (issue #51). +# +# The realistic equilibrium seed (equilibrium_seed.h), legacy on-disk storage +# helper (al_contract_legacy_storage.h), and generic typed ABI round-trip +# helpers (al_contract_abi.h), factored out of +# tests/contract/ so a build target outside the test tree -- the future +# benchmarks/ tree (PRD #50, issues #52/#53) -- can link the exact same +# workload the contract suite pins. No fixture drift between what is tested +# and what is measured (spec #50, Option C). +# +# Header-only and carries no GoogleTest dependency (unlike +# tests/contract/al_contract.h), so a non-test binary can link it for free. +# Added unconditionally (not gated by AL_BUILD_TESTS) so it is genuinely +# usable independent of the test build. + +add_library(al_contract_fixtures INTERFACE) +target_include_directories(al_contract_fixtures INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(al_contract_fixtures INTERFACE al) +target_compile_features(al_contract_fixtures INTERFACE cxx_std_17) + +######################################################################################################################## +# Throwaway proof (issue #51 acceptance criterion 3), opt-in via +# AL_BUILD_FIXTURE_SMOKE -- off by default like every other extra target in +# the root CMakeLists.txt (AL_BUILD_TESTS, AL_BUILD_BENCHMARKS), so an +# ordinary/minimal build (e.g. the Python wheel) never pays for it. The +# al_contract_fixtures library above stays unconditional regardless: that is +# the actual "usable outside the test tree" requirement, this executable is +# just a demonstration of it. +# +# A minimal translation unit outside tests/ that includes the fixture, builds, +# and round-trips it against a real backend -- demonstrating the fixture is +# usable without AL_BUILD_TESTS or GoogleTest. Uses the always-on, zero-I/O +# Memory backend so this needs no on-disk setup and no optional backend. +if(AL_BUILD_FIXTURE_SMOKE) + add_executable(al_contract_fixtures_smoke fixture_smoke.cpp) + target_link_libraries(al_contract_fixtures_smoke PRIVATE al_contract_fixtures) + add_dependencies(al_contract_fixtures_smoke al) + target_compile_features(al_contract_fixtures_smoke PRIVATE cxx_std_17) + + if(NOT WIN32 AND NOT APPLE) + # stdc++fs is only needed on Linux with older GCC (std::filesystem in the smoke TU). + target_link_libraries(al_contract_fixtures_smoke PRIVATE stdc++fs) + endif() +endif() diff --git a/fixtures/al_contract_abi.h b/fixtures/al_contract_abi.h new file mode 100644 index 00000000..8f4d8933 --- /dev/null +++ b/fixtures/al_contract_abi.h @@ -0,0 +1,106 @@ +// Generic typed round-trip helpers over al_write_data / al_read_data +// (issue #51). Factored out of tests/contract/al_contract.h so a build target +// outside tests/contract/ -- notably a future benchmarks/ tree (PRD #50, issue +// #52) -- can link the exact same write_data/read_data machinery +// equilibrium_seed.h uses, without also pulling in GoogleTest. tests/contract/ +// al_contract.h includes this header for its own copy of these helpers, so +// there is exactly one definition either way. +// +// Binds only to the `extern "C"` surface (al_lowlevel.h + al_const.h), per +// TEST_STRATEGY.md decision D1: no C++ internals, no Python bindings. + +#ifndef AL_CONTRACT_ABI_H +#define AL_CONTRACT_ABI_H + +#include +#include + +#include +#include +#include +#include + +namespace al_contract { + +// --- datatype descriptor: element C++ type -> ABI datatype id + label ------- +template +struct DType; +template <> +struct DType { + static constexpr int value = CHAR_DATA; + static constexpr const char* name = "CHAR"; +}; +template <> +struct DType { + static constexpr int value = INTEGER_DATA; + static constexpr const char* name = "INTEGER"; +}; +template <> +struct DType { + static constexpr int value = DOUBLE_DATA; + static constexpr const char* name = "DOUBLE"; +}; +template <> +struct DType> { + static constexpr int value = COMPLEX_DATA; + static constexpr const char* name = "COMPLEX"; +}; + +inline std::size_t element_count(const std::vector& shape) { + std::size_t n = 1; + for (int d : shape) n *= static_cast(d); + return n; +} + +// --- generic write ---------------------------------------------------------- +// al_write_data takes a non-const void* and int* size but does not mutate +// them for a write; the const_casts keep the caller's data/shape const. +template +al_status_t write_data(int ctx, const char* field, const std::vector& shape, + const std::vector& data) { + const int dim = static_cast(shape.size()); + int* size = dim ? const_cast(shape.data()) : nullptr; + return al_write_data(ctx, field, "", const_cast(data.data()), + DType::value, dim, size); +} + +// --- generic read ----------------------------------------------------------- +// Honors the two ownership regimes the ABI uses (mirrors testlowlevel.cpp): +// dim == 0 : the core writes into the caller's buffer; nothing to free. +// dim >= 1 : the core malloc's the buffer and returns it via *data; the +// caller owns it, so we copy out and free. +// On success out_shape is the shape the core reported (scalar -> empty) and +// out_data holds element_count(out_shape) elements. +template +al_status_t read_data(int ctx, const char* field, int expected_rank, + std::vector* out_shape, std::vector* out_data) { + int size[MAXDIM] = {0}; + + if (expected_rank == 0) { + T scalar{}; + void* buf = &scalar; + al_status_t s = + al_read_data(ctx, field, "", &buf, DType::value, 0, size); + if (s.code == 0) { + out_shape->clear(); + out_data->assign(1, scalar); + } + return s; + } + + void* buf = nullptr; + al_status_t s = al_read_data(ctx, field, "", &buf, DType::value, + expected_rank, size); + if (s.code == 0 && buf != nullptr) { + out_shape->assign(size, size + expected_rank); + const std::size_t n = element_count(*out_shape); + T* p = static_cast(buf); + out_data->assign(p, p + n); + } + free(buf); + return s; +} + +} // namespace al_contract + +#endif // AL_CONTRACT_ABI_H diff --git a/fixtures/al_contract_legacy_storage.h b/fixtures/al_contract_legacy_storage.h new file mode 100644 index 00000000..76e97718 --- /dev/null +++ b/fixtures/al_contract_legacy_storage.h @@ -0,0 +1,76 @@ +// Shared filesystem support for contract fixtures and benchmarks that exercise +// the legacy data-entry URI layout. This header deliberately has no GoogleTest +// dependency: callers choose how to report URI or ABI failures. +#ifndef AL_CONTRACT_LEGACY_STORAGE_H +#define AL_CONTRACT_LEGACY_STORAGE_H + +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +namespace al_contract { + +// The legacy address of a pulse: (database, version, pulse, run). These four +// values always travel together — into the URI and into the on-disk tree path. +struct PulseId { + std::string database; + std::string version; + int pulse; + int run; +}; + +// Owns a unique temporary base directory and prepares the legacy pulse layout +// required by on-disk backends using FORCE_CREATE_PULSE. +class LegacyPulseDirectory { +public: + LegacyPulseDirectory() { + static std::atomic counter{0}; + const unsigned n = counter.fetch_add(1); + path_ = std::filesystem::temp_directory_path() / + ("al_contract_" + std::to_string(process_id()) + "_" + + std::to_string(n)); + std::error_code ec; + std::filesystem::remove_all(path_, ec); + std::filesystem::create_directories(path_, ec); + } + + ~LegacyPulseDirectory() { + std::error_code ec; + std::filesystem::remove_all(path_, ec); + } + + LegacyPulseDirectory(const LegacyPulseDirectory&) = delete; + LegacyPulseDirectory& operator=(const LegacyPulseDirectory&) = delete; + + const std::filesystem::path& path() const { return path_; } + std::string str() const { return path_.string(); } + + void make_legacy_tree(const PulseId& id) const { + std::error_code ec; + std::filesystem::create_directories( + path_ / id.database / id.version / std::to_string(id.pulse) / + std::to_string(id.run), + ec); + } + +private: + static int process_id() { +#if defined(_WIN32) + return _getpid(); +#else + return getpid(); +#endif + } + + std::filesystem::path path_; +}; + +} // namespace al_contract + +#endif // AL_CONTRACT_LEGACY_STORAGE_H diff --git a/fixtures/equilibrium_seed.h b/fixtures/equilibrium_seed.h new file mode 100644 index 00000000..182ba10f --- /dev/null +++ b/fixtures/equilibrium_seed.h @@ -0,0 +1,312 @@ +// Deterministic single-version equilibrium-seed generator (issues #4, #33). +// +// TEST_STRATEGY.md decision D5: "one realistic single-version equilibrium +// seed — a curated subset (scalar, a profiles_1d array with a timebase, a +// constraints AOS) from a deterministic in-repo generator (generator + hash, +// not committed blobs) — to exercise real AOS/timebase/HDF5-tensorization +// shapes." This header IS the generator: no binary fixture is checked in, the +// shape is produced by code, and the round-trip oracle is a *structural* hash +// (issue #33). +// +// DD-4.1.1-conformant shape (issue #33 — the earlier version modelled +// profiles_1d/psi as a flat top-level leaf and constraints/{measured,weight} +// as a generic top-level AOS; neither is a valid equilibrium shape, so the two +// DD-coupled backends could only skip the seed). Every path below is a real +// equilibrium DD-4.1.1 path, validated against the baked-from artifact +// build-mdsplus/_deps/data-dictionary-src/IDSDef.xml (the method +// real_dd_path_catalog.h documents; the imas-dd MCP tool CLAUDE.md names is not +// in this session's MCP config). It stays opaque strings through the C ABI +// (decision D1: the core attaches no DD semantics to any of these paths): +// - a static scalar -> vacuum_toroidal_field/r0 (FLT_0D) +// - a top-level timebase array-> time (FLT_1D, the IDS homogeneous timebase) +// - a real dynamic AOS -> time_slice (struct_array, timebasepath +// "time"), multiple elements, each carrying: +// * time (FLT_0D) -- per-slice coordinate +// * profiles_1d/psi (FLT_1D) -- radial profile +// * global_quantities/ip (FLT_0D) +// * constraints/ip/measured (FLT_0D) -- a real constraints +// * constraints/ip/weight (FLT_0D) subtree, not generic +// +// Structural hash (issue #33): the oracle is a hash over a canonical stream +// that includes, per field, its path identity, datatype, rank, every extent, +// and (for AOS elements) the array size and element index, then the values. +// Hashing values alone let a 3x4->4x3 extent transpose with unchanged bytes, +// or a dropped/reordered AOS element, pass silently; the structural stream +// closes that. The read side rebuilds the same stream from what it *actually* +// observed (real ranks/extents/AOS sizes), so any shape drift shows up as a +// hash (and explicit rank/extent) mismatch instead of being reconciled away. +#ifndef AL_CONTRACT_EQUILIBRIUM_SEED_H +#define AL_CONTRACT_EQUILIBRIUM_SEED_H + +#include "al_contract_abi.h" + +#include +#include + +#include +#include +#include + +namespace equilibrium_seed { + +constexpr const char* kIds = "equilibrium"; +// static scalar + top-level timebase array +constexpr const char* kScalar = "vacuum_toroidal_field/r0"; // FLT_0D +constexpr const char* kTimebase = "time"; // FLT_1D +// the real dynamic AOS and its per-element leaves +constexpr const char* kAos = "time_slice"; // struct_array +constexpr const char* kAosTime = "time"; // timebasepath +constexpr const char* kSliceTime = "time"; // FLT_0D leaf +constexpr const char* kPsi = "profiles_1d/psi"; // FLT_1D +constexpr const char* kIp = "global_quantities/ip"; // FLT_0D +constexpr const char* kMeasured = "constraints/ip/measured"; // FLT_0D +constexpr const char* kWeight = "constraints/ip/weight"; // FLT_0D + +constexpr int kNSlices = 2; // time_slice AOS elements (>1: real AOS) +constexpr int kRadialLen = 4; // profiles_1d/psi grid points per slice + +// A "not a real datatype" marker used only for the synthetic AOS-size record in +// the canonical stream (so array size participates in the hash). +constexpr int kAosSizeMarker = -1; + +// --- deterministic content ------------------------------------------------- +// Distinct arithmetic progressions per role and per slice, so a transposed, +// dropped, or reordered element is caught by the structural hash. Kept clear of +// the write-path "absent" sentinels documented in al_contract.h (kEmptyDouble). +inline double scalar_r0() { return 6.2; } + +inline std::vector timebase_values() { + std::vector t; + for (int i = 0; i < kNSlices; ++i) t.push_back(1.0 + 0.5 * i); + return t; +} + +inline double slice_time(int i) { return 1.0 + 0.5 * i; } +inline double slice_ip(int i) { return 100.0 + 7.0 * i; } +inline double slice_measured(int i) { return 200.0 + 3.0 * i; } +inline double slice_weight(int i) { return 0.5 + 0.1 * i; } + +// Per-slice radial profile: distinct per (slice, radial index). +inline std::vector slice_psi(int i) { + std::vector v; + v.reserve(kRadialLen); + for (int r = 0; r < kRadialLen; ++r) v.push_back(10.0 * i + r + 0.25); + return v; +} + +// --- a single observed/expected field record ------------------------------- +struct Obs { + std::string field; // canonical identity, e.g. + // "time_slice[1]/profiles_1d/psi" + int datatype; // DOUBLE_DATA, or kAosSizeMarker + int rank; + std::vector extents; // actual dims (empty for rank 0) + std::vector values; + + bool same_structure(const Obs& o) const { + return field == o.field && datatype == o.datatype && rank == o.rank && + extents == o.extents; + } + bool operator==(const Obs& o) const { + return same_structure(o) && values == o.values; + } +}; + +inline std::string slice_field(int i, const char* leaf) { + return std::string(kAos) + "[" + std::to_string(i) + "]/" + leaf; +} + +// The expected canonical stream, built straight from the generator functions +// above (this is the "generator", not a committed golden value: change any +// generator function and the expected records — and the expected hash — +// recompute automatically). +inline std::vector expected_records() { + std::vector r; + r.push_back({kScalar, DOUBLE_DATA, 0, {}, {scalar_r0()}}); + r.push_back({kTimebase, DOUBLE_DATA, 1, {kNSlices}, timebase_values()}); + r.push_back({std::string(kAos) + ".size", kAosSizeMarker, 0, {}, + {static_cast(kNSlices)}}); + for (int i = 0; i < kNSlices; ++i) { + r.push_back({slice_field(i, kSliceTime), DOUBLE_DATA, 0, {}, + {slice_time(i)}}); + r.push_back({slice_field(i, kPsi), DOUBLE_DATA, 1, {kRadialLen}, + slice_psi(i)}); + r.push_back({slice_field(i, kIp), DOUBLE_DATA, 0, {}, {slice_ip(i)}}); + r.push_back({slice_field(i, kMeasured), DOUBLE_DATA, 0, {}, + {slice_measured(i)}}); + r.push_back({slice_field(i, kWeight), DOUBLE_DATA, 0, {}, + {slice_weight(i)}}); + } + return r; +} + +// --- FNV-1a over the canonical structural stream ---------------------------- +inline uint64_t fnv1a(uint64_t h, const void* data, size_t n) { + const unsigned char* p = static_cast(data); + for (size_t i = 0; i < n; ++i) { + h ^= p[i]; + h *= 1099511628211ull; + } + return h; +} +inline uint64_t fnv1a(uint64_t h, int v) { return fnv1a(h, &v, sizeof(v)); } +inline uint64_t fnv1a(uint64_t h, double v) { return fnv1a(h, &v, sizeof(v)); } + +constexpr uint64_t kFnvOffsetBasis = 14695981039346656037ull; + +// Canonical hash: every structural attribute participates, with lengths +// interleaved so no field's bytes can slide into the next (path "ab"+"c" must +// not collide with "a"+"bc"; extents {3,4} must not collide with {4,3}). +inline uint64_t canonical_hash(const std::vector& records) { + uint64_t h = kFnvOffsetBasis; + h = fnv1a(h, static_cast(records.size())); + for (const Obs& o : records) { + h = fnv1a(h, static_cast(o.field.size())); + h = fnv1a(h, o.field.data(), o.field.size()); + h = fnv1a(h, o.datatype); + h = fnv1a(h, o.rank); + h = fnv1a(h, static_cast(o.extents.size())); + for (int e : o.extents) h = fnv1a(h, e); + h = fnv1a(h, static_cast(o.values.size())); + for (double v : o.values) h = fnv1a(h, v); + } + return h; +} + +inline uint64_t expected_hash() { return canonical_hash(expected_records()); } + +// --- write the seed into an already-open pulse (its own GLOBAL write) ------ +inline al_status_t write(int pulse_ctx) { + int op = -1; + al_status_t s = al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, &op); + if (s.code != 0) return s; + + s = al_contract::write_data(op, kScalar, {}, {scalar_r0()}); + if (s.code != 0) { al_end_action(op); return s; } + + s = al_contract::write_data(op, kTimebase, {kNSlices}, + timebase_values()); + if (s.code != 0) { al_end_action(op); return s; } + + int size = kNSlices; + int aos_ctx = -1; + s = al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos_ctx); + if (s.code != 0) { al_end_action(op); return s; } + for (int i = 0; i < kNSlices; ++i) { + s = al_contract::write_data(aos_ctx, kSliceTime, {}, + {slice_time(i)}); + if (s.code != 0) break; + s = al_contract::write_data(aos_ctx, kPsi, {kRadialLen}, + slice_psi(i)); + if (s.code != 0) break; + s = al_contract::write_data(aos_ctx, kIp, {}, {slice_ip(i)}); + if (s.code != 0) break; + s = al_contract::write_data(aos_ctx, kMeasured, {}, + {slice_measured(i)}); + if (s.code != 0) break; + s = al_contract::write_data(aos_ctx, kWeight, {}, + {slice_weight(i)}); + if (s.code != 0) break; + if (i + 1 < kNSlices) { + s = al_iterate_over_arraystruct(aos_ctx, 1); + if (s.code != 0) break; + } + } + al_status_t end_aos = al_end_action(aos_ctx); + if (s.code == 0) s = end_aos; + + al_status_t end_op = al_end_action(op); + if (s.code == 0) s = end_op; + return s; +} + +// --- read the seed back, rebuilding the observed canonical stream ---------- +// Records exactly what the backend returned (path identity, datatype, real +// rank/extents, AOS size/index, values). No assertions here -- the caller +// compares the observed records against expected_records() structurally and by +// hash, so shape drift on read surfaces as a mismatch, not a silent +// reconciliation. Returns the first non-OK status encountered (with whatever +// records were gathered up to that point), or OK. +inline al_status_t read_back(int pulse_ctx, std::vector* out) { + out->clear(); + int op = -1; + al_status_t s = al_begin_global_action(pulse_ctx, kIds, "", READ_OP, &op); + if (s.code != 0) return s; + + auto read_leaf = [&](const char* path, const std::string& field, int rank, + Obs* obs) -> al_status_t { + std::vector shape; + std::vector data; + al_status_t st = + al_contract::read_data(op, path, rank, &shape, &data); + if (st.code != 0) return st; + *obs = Obs{field, DOUBLE_DATA, rank, shape, data}; + return st; + }; + + Obs obs; + s = read_leaf(kScalar, kScalar, 0, &obs); + if (s.code != 0) { al_end_action(op); return s; } + out->push_back(obs); + + s = read_leaf(kTimebase, kTimebase, 1, &obs); + if (s.code != 0) { al_end_action(op); return s; } + out->push_back(obs); + + int size = 0; + int aos_ctx = -1; + s = al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos_ctx); + if (s.code != 0) { al_end_action(op); return s; } + out->push_back(Obs{std::string(kAos) + ".size", kAosSizeMarker, 0, {}, + {static_cast(size)}}); + + for (int i = 0; i < size; ++i) { + // path is per-element ("time"); the recorded field carries the index. + auto read_slice = [&](const char* path, const char* leaf, + int rank) -> al_status_t { + std::vector shape; + std::vector data; + al_status_t st = + al_contract::read_data(aos_ctx, path, rank, &shape, + &data); + if (st.code != 0) return st; + out->push_back(Obs{slice_field(i, leaf), DOUBLE_DATA, rank, shape, + data}); + return st; + }; + s = read_slice(kSliceTime, kSliceTime, 0); + if (s.code != 0) break; + s = read_slice(kPsi, kPsi, 1); + if (s.code != 0) break; + s = read_slice(kIp, kIp, 0); + if (s.code != 0) break; + s = read_slice(kMeasured, kMeasured, 0); + if (s.code != 0) break; + s = read_slice(kWeight, kWeight, 0); + if (s.code != 0) break; + if (i + 1 < size) { + s = al_iterate_over_arraystruct(aos_ctx, 1); + if (s.code != 0) break; + } + } + al_status_t end_aos = al_end_action(aos_ctx); + if (s.code == 0) s = end_aos; + + al_status_t end_op = al_end_action(op); + if (s.code == 0) s = end_op; + return s; +} + +// Convenience for the always-on round-trip sites: read back and hash. The +// structural asserts live in the test bodies (they need gtest EXPECT_EQ); +// this just yields the observed hash. +inline al_status_t read_and_hash(int pulse_ctx, uint64_t* out_hash) { + std::vector obs; + al_status_t s = read_back(pulse_ctx, &obs); + if (s.code == 0) *out_hash = canonical_hash(obs); + return s; +} + +} // namespace equilibrium_seed + +#endif // AL_CONTRACT_EQUILIBRIUM_SEED_H diff --git a/fixtures/fixture_smoke.cpp b/fixtures/fixture_smoke.cpp new file mode 100644 index 00000000..dddd0aef --- /dev/null +++ b/fixtures/fixture_smoke.cpp @@ -0,0 +1,77 @@ +// Throwaway proof (issue #51 acceptance criterion 3): a minimal translation +// unit living outside tests/ that includes equilibrium_seed.h, builds, and +// exercises it against a real backend through the public C ABI only -- no +// GoogleTest, no AL_BUILD_TESTS. Demonstrates that the fixture extracted by +// this issue is genuinely linkable from a target like the future +// benchmarks/ tree (PRD #50), not just from tests/contract/. +// +// Uses the always-on, zero-I/O Memory backend so this needs no on-disk setup +// and no optional backend enabled. + +#include "equilibrium_seed.h" + +#include +#include + +#include +#include +#include +#include + +int main() { + // The Memory backend stores nothing on disk, but URI construction still + // resolves the legacy "user" field as a base directory, so it must exist + // (mirrors tests/contract/al_contract.h's TempBase for the on-disk cases). + const std::filesystem::path base = + std::filesystem::temp_directory_path() / "al_contract_fixtures_smoke"; + std::filesystem::create_directories(base); + const std::string base_str = base.string(); + + char* uri = nullptr; + al_status_t s = al_build_uri_from_legacy_parameters( + MEMORY_BACKEND, /*pulse=*/1, /*run=*/0, /*user=*/base_str.c_str(), + /*tokamak=*/"test", /*version=*/"3", /*options=*/"", &uri); + if (s.code != 0) { + std::fprintf(stderr, "al_build_uri_from_legacy_parameters failed: %s\n", + s.message); + return 1; + } + + int pulse_ctx = -1; + s = al_begin_dataentry_action(uri, FORCE_CREATE_PULSE, &pulse_ctx); + free(uri); + if (s.code != 0) { + std::fprintf(stderr, "al_begin_dataentry_action failed: %s\n", s.message); + return 1; + } + + s = equilibrium_seed::write(pulse_ctx); + if (s.code != 0) { + std::fprintf(stderr, "equilibrium_seed::write failed: %s\n", s.message); + return 1; + } + + uint64_t observed_hash = 0; + s = equilibrium_seed::read_and_hash(pulse_ctx, &observed_hash); + if (s.code != 0) { + std::fprintf(stderr, "equilibrium_seed::read_and_hash failed: %s\n", + s.message); + return 1; + } + + al_close_pulse(pulse_ctx, CLOSE_PULSE); + + if (observed_hash != equilibrium_seed::expected_hash()) { + std::fprintf(stderr, + "structural hash mismatch: observed=%llu expected=%llu\n", + static_cast(observed_hash), + static_cast( + equilibrium_seed::expected_hash())); + return 1; + } + + std::printf("equilibrium seed round-tripped through the Memory backend " + "outside the test tree: hash=%llu\n", + static_cast(observed_hash)); + return 0; +} diff --git a/include/al_backend.h b/include/al_backend.h index aec9860f..314728b0 100644 --- a/include/al_backend.h +++ b/include/al_backend.h @@ -147,8 +147,14 @@ class IMAS_CORE_LIBRARY_API Backend Deletes data. This function deletes some data (can be a signal, a structure, the whole DATAOBJECT) in the database given the passed context. + + An implementation must not widen `path`: a non-empty `path` deletes that + node and its subtree only, and an empty `path` addresses the whole + DATAOBJECT. Deleting a path that holds no data is a no-op, not an error. + See docs/adr/0001-al-delete-data-path-semantics.md. + @param[in] ctx pointer on operation context - @param[in] path path of the data structure element to delete (suppress the whole subtree) + @param[in] path path of the data structure element to delete (suppress the whole subtree); empty means the whole DATAOBJECT @throw BackendException **/ virtual void deleteData(OperationContext *ctx, diff --git a/include/al_lowlevel.h b/include/al_lowlevel.h index e3bd79be..e063d783 100644 --- a/include/al_lowlevel.h +++ b/include/al_lowlevel.h @@ -415,8 +415,29 @@ extern "C" Deletes data. This function deletes some data (can be a signal, a structure, the whole DATAOBJECT) in the database given the passed context. + + `path` selects *what* is deleted, and is never widened by the core: + - a non-empty `path` deletes that node and its subtree, and nothing else — + sibling and parent nodes, and the DATAOBJECT itself, survive; + - an empty `path` addresses the whole DATAOBJECT (the occurrence opened by + `ctx`), and is the only way to remove it. + Deleting a path that holds no data is not an error: the call succeeds and + changes nothing. + + Backend support for the three granularities is uneven, and a backend that + cannot honour `path` does not report that it could not: ASCII and + flexbuffers ignore the call entirely, memory implements node and + array-of-structures deletes but not the DATAOBJECT one, MDSplus implements + node deletes only. HDF5 implements all three. UDA forwards `path` to + whichever backend it is fronting: in fetch mode to the local one, and in + remote mode to a server plugin that need not implement delete at all (the + reference plugin does not, and refuses cleanly). The per-backend matrix, + and why the argument is specified this way rather than as an + occurrence-wide delete, are recorded in + docs/adr/0001-al-delete-data-path-semantics.md. + @param[in] ctx operation context id (from al_plugin_begin_global_action() or al_plugin_begin_slice_action()) - @param[in] path path of the data structure element to delete (suppress the whole subtree) + @param[in] path path of the data structure element to delete (suppress the whole subtree); empty means the whole DATAOBJECT @result error status [_success if al_status_t.code = 0 or failure if < 0_] */ IMAS_CORE_LIBRARY_API al_status_t al_delete_data(int ctx, diff --git a/pyproject.toml b/pyproject.toml index 68165362..622f5ebc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,6 +116,11 @@ local_scheme = "no-local-version" [tool.pytest.ini_options] testpaths = ["python/tests"] +filterwarnings = [ + # pytest-10 deprecations are errors so removed-in-10 usage (e.g. iterator + # argvalues, issue #48) fails here before an unbounded pytest upgrade does + "error::pytest.PytestRemovedIn10Warning", +] [tool.coverage.run] branch = true diff --git a/python/tests/test_exception.py b/python/tests/test_exception.py index 9b86433d..b3c0a400 100644 --- a/python/tests/test_exception.py +++ b/python/tests/test_exception.py @@ -7,7 +7,8 @@ @pytest.mark.parametrize( "message, status", - itertools.product(["an exception", "another exception"], [None, -3, 44]), + # materialized: pytest 10 rejects non-Collection iterables as argvalues + list(itertools.product(["an exception", "another exception"], [None, -3, 44])), ) def test_ALException(message, status): match = message diff --git a/src/hdf5/hdf5_backend.cpp b/src/hdf5/hdf5_backend.cpp index cb95f539..2283bd18 100644 --- a/src/hdf5/hdf5_backend.cpp +++ b/src/hdf5/hdf5_backend.cpp @@ -131,7 +131,7 @@ void HDF5Backend::deleteData(OperationContext * ctx, std::string path) { if (file_id == -1) //master file is closed return; - hdf5Writer->deleteData(ctx, this->file_id, opened_IDS_files, files_directory, relative_file_path); + hdf5Writer->deleteData(ctx, path, this->file_id, opened_IDS_files, files_directory, relative_file_path); } void HDF5Backend::beginWriteArraystructAction(ArraystructContext * ctx, int *size) diff --git a/src/hdf5/hdf5_reader.cpp b/src/hdf5/hdf5_reader.cpp index 7c8e9230..e60d9c6d 100644 --- a/src/hdf5/hdf5_reader.cpp +++ b/src/hdf5/hdf5_reader.cpp @@ -478,14 +478,32 @@ int HDF5Reader::read_ND_Data(Context *ctx, std::string &att_name, std::string &t if (gid == -1) // IDS does not exist in the file return 0; + // Whether the field is addressed from the dataobject root must be decided + // before the separators are normalized, while the leading '/' marker is + // still distinguishable (issue #65). + const bool absolute_field = HDF5Utils::isAbsoluteFieldPath(att_name); + std::string &dataset_name = att_name; std::replace(dataset_name.begin(), dataset_name.end(), '/', '&'); // character '/' is not supported in datasets names std::replace(timebasename.begin(), timebasename.end(), '/', '&'); hid_t dataset_id = -1; std::string tensorized_path = dataset_name; + // How many of the open AOS chain's levels the field lives under. Only an + // absolute path can sit above the current context and so retain fewer than + // all of them; a relative one is always read at the full current cursor. + size_t applicable_aos_levels = std::numeric_limits::max(); - if (ctx->getType() == CTX_ARRAYSTRUCT_TYPE) + if (absolute_field) + { + const std::vector no_open_aos; // an operation context opens none + const std::vector &tensorized_paths = + (ctx->getType() == CTX_ARRAYSTRUCT_TYPE) + ? tensorized_paths_per_context[static_cast(ctx)] + : no_open_aos; + tensorized_path = HDF5Utils::resolveAbsoluteFieldPath(dataset_name, tensorized_paths, &applicable_aos_levels); + } + else if (ctx->getType() == CTX_ARRAYSTRUCT_TYPE) { auto &tensorized_paths = tensorized_paths_per_context[static_cast(ctx)]; tensorized_path = tensorized_paths.back() + "&" + dataset_name; @@ -540,6 +558,18 @@ int HDF5Reader::read_ND_Data(Context *ctx, std::string &att_name, std::string &t std::vector current_arrctx_indices; hdf5_utils.getAOSIndices(ctx, current_arrctx_indices, &timed_AOS_index); // getting current AOS indices + if (applicable_aos_levels < current_arrctx_indices.size()) + { + // An absolute field path that stops short of the innermost open AOS + // keeps only the indices of the levels it still passes through -- the + // child's cursor must not be applied to an ancestor's dataset, which + // carries fewer tensorized dimensions (issue #65). The indices run + // outermost first, so the retained ones are the leading entries. + current_arrctx_indices.resize(applicable_aos_levels); + if (timed_AOS_index >= static_cast(applicable_aos_levels)) + timed_AOS_index = -1; // the timed AOS is not among them: the field is not tensorized on time + } + bool is_dynamic = timebasename.compare("") != 0 ? true : false; bool isTimed = (timed_AOS_index != -1); int slice_index = -1; diff --git a/src/hdf5/hdf5_utils.cpp b/src/hdf5/hdf5_utils.cpp index c51c227b..9b277cd1 100644 --- a/src/hdf5/hdf5_utils.cpp +++ b/src/hdf5/hdf5_utils.cpp @@ -587,6 +587,120 @@ void HDF5Utils::setTensorizedPaths(ArraystructContext * ctx, std::vector < std:: std::replace(tensorized_paths.back().begin(), tensorized_paths.back().end(), '/', '&'); } +// --- absolute field paths (issue #65) -------------------------------------- +// +// al_read_data's contract (al_lowlevel.h) lets a caller address a field from +// the dataobject root by prepending '/': it is not a filesystem path and does +// not carry the IDS name, so "/time_slice/constraints/j_phi/reconstructed" +// names the same datum as "reconstructed" read from the corresponding AOS +// context. +// +// Resolving one answers two questions at once, which is why they are answered +// here together rather than by trimming the string at the call site: +// +// * which dataset name to look up. The stored name keeps the tensorized +// "[]" marker of every AOS the field sits under, so simply dropping the +// '/' ("time_slice&constraints&j_phi&reconstructed") never matches, and +// simply skipping the context prefix duplicates nothing but loses those +// markers. +// * which AOS indices still apply. A field that sits above the current +// context must not be read at the child's cursor. +// +// Both follow from one walk: compare the absolute path's segments against the +// open context chain's segments and cut at the last AOS boundary that still +// matches. Segments, never string prefixes -- "time_slice_extra/..." shares a +// textual prefix with "time_slice[]" but no path prefix at all. +// +// Whatever follows the cut is appended verbatim. A path that leaves the open +// chain -- a sibling branch, or one crossing an AOS the caller never opened -- +// therefore resolves to a name carrying no "[]" for that branch: it is looked +// up, not found, and reported by the ordinary missing-data path. That is the +// honest answer rather than a guess: the core holds no schema, so it cannot +// know an unopened segment is an AOS, and would have no cursor for it if it +// did. + +// Tensorized dataset names join their segments with '&' ('/' is not a legal +// HDF5 link-name character), and mark each array of structures with a +// trailing "[]" -- see setTensorizedPaths() above. +static const char TENSORIZED_SEPARATOR = '&'; +static const char *AOS_MARKER = "[]"; + +static bool isAOSSegment(const std::string & segment) +{ + const size_t marker_length = strlen(AOS_MARKER); + return segment.size() >= marker_length && + segment.compare(segment.size() - marker_length, marker_length, AOS_MARKER) == 0; +} + +static std::string stripAOSMarker(const std::string & segment) +{ + return isAOSSegment(segment) ? segment.substr(0, segment.size() - strlen(AOS_MARKER)) : segment; +} + +static std::vector < std::string > splitTensorizedSegments(const std::string & name) +{ + std::vector < std::string > segments; + size_t start = 0; + while (true) { + const size_t end = name.find(TENSORIZED_SEPARATOR, start); + if (end == std::string::npos) { + segments.push_back(name.substr(start)); + return segments; + } + segments.push_back(name.substr(start, end - start)); + start = end + 1; + } +} + +bool HDF5Utils::isAbsoluteFieldPath(const std::string & field) +{ + return !field.empty() && field[0] == '/'; +} + +std::string HDF5Utils::resolveAbsoluteFieldPath(const std::string & absolute_dataset_name, + const std::vector < std::string > &tensorized_paths, + size_t * applicable_aos_levels) +{ + *applicable_aos_levels = 0; + + // The '/' separators (including the leading absolute marker) have already + // been normalized to '&' by the caller, so the path and the context chain + // are compared in one spelling. + std::string rooted_name = absolute_dataset_name; + if (!rooted_name.empty() && rooted_name[0] == TENSORIZED_SEPARATOR) + rooted_name.erase(0, 1); + + if (tensorized_paths.empty()) // no AOS is open: the marker was all there was to strip + return rooted_name; + + const std::vector < std::string > context_segments = splitTensorizedSegments(tensorized_paths.back()); + const std::vector < std::string > target_segments = splitTensorizedSegments(rooted_name); + + size_t matched_segments = 0; // target segments consumed at the last matching AOS boundary + size_t levels = 0; // AOS levels retained at that boundary + size_t levels_walked = 0; + for (size_t i = 0; i < context_segments.size() && i < target_segments.size(); i++) { + if (stripAOSMarker(context_segments[i]) != target_segments[i]) + break; + if (isAOSSegment(context_segments[i])) { + levels_walked++; + matched_segments = i + 1; + levels = levels_walked; + } + } + *applicable_aos_levels = levels; + + // tensorized_paths[levels - 1] is that boundary's own tensorized path: + // every segment up to and including its "[]" marker. + std::string resolved_name = (levels == 0) ? std::string() : tensorized_paths[levels - 1]; + for (size_t i = matched_segments; i < target_segments.size(); i++) { + if (!resolved_name.empty()) + resolved_name += TENSORIZED_SEPARATOR; + resolved_name += target_segments[i]; + } + return resolved_name; +} + hid_t HDF5Utils::createOrOpenHDF5Group(const std::string & path, const hid_t & parent_loc_id) { diff --git a/src/hdf5/hdf5_utils.h b/src/hdf5/hdf5_utils.h index a7d621a5..520bc221 100644 --- a/src/hdf5/hdf5_utils.h +++ b/src/hdf5/hdf5_utils.h @@ -79,6 +79,18 @@ class HDF5Utils { void getAOSIndices(Context * ctx, std::vector < int >&indices, int *timedAOS_index); int getAOSIndicesSize(Context * ctx); void setTensorizedPaths(ArraystructContext * ctx, std::vector < std::string > &tensorized_paths); + + // Absolute field paths (issue #65). isAbsoluteFieldPath() must be asked + // before '/' separators are normalized to '&'; resolveAbsoluteFieldPath() + // takes the already-normalized name, the tensorized paths of the open AOS + // chain (innermost last, as setTensorizedPaths builds them) and reports, + // through applicable_aos_levels, how many of those levels the resolved + // dataset still lives under -- i.e. how many of the caller's current AOS + // indices apply to it. See the block comment in hdf5_utils.cpp. + static bool isAbsoluteFieldPath(const std::string & field); + static std::string resolveAbsoluteFieldPath(const std::string & absolute_dataset_name, + const std::vector < std::string > &tensorized_paths, + size_t * applicable_aos_levels); void showStatus(hid_t file_id); enum Files_paths_strategies { FULL_MDSPLUS_STRATEGY = 1, MODIFIED_MDSPLUS_STRATEGY = 2, FREE_PATH_STRATEGY = 3}; void setDefaultOptions(size_t *read_cache, size_t *write_cache, bool *readBuffering, bool *writeBuffering); diff --git a/src/hdf5/hdf5_writer.cpp b/src/hdf5/hdf5_writer.cpp index 57d8f702..ad14ee1d 100644 --- a/src/hdf5/hdf5_writer.cpp +++ b/src/hdf5/hdf5_writer.cpp @@ -14,7 +14,7 @@ using namespace boost::filesystem; HDF5Writer::HDF5Writer(std::string backend_version_) : backend_version(backend_version_), opened_data_sets(), existing_data_sets(), tensorized_paths_per_context(), arrctx_shapes_per_context(), -dynamic_AOS_slices_extension(), homogeneous_time(-1), IDS_group_id(), slice_mode(GLOBAL_OP) +dynamic_AOS_slices_extension(), homogeneous_time(-1), IDS_group_id(), group_members_gid(-1), group_members(), slice_mode(GLOBAL_OP) { //H5Eset_auto2(H5E_DEFAULT, NULL, NULL); } @@ -58,7 +58,112 @@ void HDF5Writer::close_file_handler(std::string external_link_name, std::unorder } } -void HDF5Writer::deleteData(OperationContext * ctx, hid_t file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, std::string & files_directory, std::string & relative_file_path) +// An IDS group is a flat namespace of "tensorized" dataset names: the DD path +// with '/' replaced by '&', every array-of-structures node suffixed "[]", one +// "_SHAPE" companion per non-scalar leaf and one "…[]&AOS_SHAPE" per dynamic +// AOS. So "time_slice/global_quantities/ip" is stored as +// "time_slice[]&global_quantities&ip", flattened over the AOS index. +// +// Matching therefore has to be per segment, not by raw string prefix: each +// segment of the requested path matches a dataset-name segment with or without +// the "[]" AOS suffix, and the dataset's remaining segments are its subtree. +// Segment-wise is what tells a real child from a same-prefix stranger — +// deleting "time" must not touch "time_slice[]&x", and deleting "code" must not +// touch "code_name" — while still letting a path address a node *inside* an +// AOS, which the C ABI gives no way to do per element: al_delete_data takes an +// OperationContext, so "time_slice/global_quantities" can only mean "that +// subtree in every element", exactly as the tensorized layout stores it. +static std::vector < std::string > split_on_ampersand(const std::string & path) +{ + std::vector < std::string > segments; + std::string::size_type start = 0; + while (true) { + const std::string::size_type sep = path.find('&', start); + if (sep == std::string::npos) { + segments.push_back(path.substr(start)); + return segments; + } + segments.push_back(path.substr(start, sep - start)); + start = sep + 1; + } +} + +static bool is_in_subtree(const std::string & dataset_name, const std::string & mangled_path) +{ + const std::vector < std::string > name_segments = split_on_ampersand(dataset_name); + const std::vector < std::string > wanted_segments = split_on_ampersand(mangled_path); + if (name_segments.size() < wanted_segments.size()) + return false; + + for (size_t i = 0; i < wanted_segments.size(); i++) { + std::string segment = name_segments[i]; + if (segment.size() >= 2 && segment.compare(segment.size() - 2, 2, "[]") == 0) + segment.erase(segment.size() - 2); //an AOS node: "time_slice[]" -> "time_slice" + if (segment == wanted_segments[i]) + continue; + //The addressed node's own "_SHAPE" companion is the one case where the + //last segment may carry a suffix. A deeper AOS's "AOS_SHAPE" and a + //child's "…_SHAPE" are already covered: they are extra segments, or + //segments past the requested path. + if (i + 1 == wanted_segments.size() && name_segments.size() == wanted_segments.size() && + segment == wanted_segments[i] + "_SHAPE") + continue; + return false; + } + return true; +} + +void HDF5Writer::invalidateGroupMembers() +{ + group_members_gid = -1; + group_members.clear(); +} + +// The cached listing is trusted only while it still accounts for every link in +// the group. Nothing but a write can add one, and a write can only add, so an +// unchanged link count means an unchanged set of names — which spares every +// write path from having to remember to invalidate this. +const std::vector < std::string > &HDF5Writer::groupMembers(hid_t gid) +{ + H5G_info_t group_info; + if (H5Gget_info(gid, &group_info) < 0) + throw ALBackendException("HDF5Backend: H5Gget_info has failed in HDF5Writer::groupMembers()", LOG); + + if (group_members_gid != gid || group_members.size() != (size_t) group_info.nlinks) { + group_members.clear(); + H5L_iterate_t collect = [](hid_t, const char *name, const H5L_info_t *, void *op_data) -> herr_t { + static_cast < std::vector < std::string > *>(op_data)->push_back(name); + return 0; + }; + if (H5Literate(gid, H5_INDEX_NAME, H5_ITER_NATIVE, NULL, collect, &group_members) < 0) { + invalidateGroupMembers(); + throw ALBackendException("HDF5Backend: H5Literate has failed in HDF5Writer::groupMembers()", LOG); + } + group_members_gid = gid; + } + return group_members; +} + +void HDF5Writer::unlinkFromGroup(hid_t gid, const std::vector < std::string > &names) +{ + for (const std::string & name : names) { + // H5Ldelete only unlinks: the freed space stays in the file until it is + // repacked, so a delete never shrinks .h5. + if (H5Ldelete(gid, name.c_str(), H5P_DEFAULT) < 0) { + invalidateGroupMembers(); + //A dataset name is a caller-supplied DD path, so it is built into + //the message as a string rather than through a fixed-size buffer. + throw ALBackendException("HDF5Backend: unable to delete HDF5 dataset: " + name, LOG); + } + } + //Keep the cached listing in step, so a run of deletes still lists the group + //only once. This is the only code that removes a link from the group. + for (const std::string & name : names) + group_members.erase(std::remove(group_members.begin(), group_members.end(), name), + group_members.end()); +} + +void HDF5Writer::deleteData(OperationContext * ctx, const std::string & path, hid_t file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, std::string & files_directory, std::string & relative_file_path) { if (file_id == -1) throw ALBackendException("HDF5Backend: master file not opened in HDF5Writer::deleteData()", LOG); //the master file is assumed to be opened @@ -69,33 +174,58 @@ void HDF5Writer::deleteData(OperationContext * ctx, hid_t file_id, std::unordere if (gid == -1) return; + + // An empty path addresses the whole DATAOBJECT; anything else addresses one + // node and its subtree. See al_lowlevel.h (al_delete_data) and + // docs/adr/0001-al-delete-data-path-semantics.md. + if (path.empty()) + deleteOccurrence(ctx, file_id, opened_IDS_files, files_directory, relative_file_path); + else + deleteSubtree(gid, path); +} + +void HDF5Writer::deleteSubtree(hid_t gid, const std::string & path) +{ + std::string mangled_path = path; + std::replace(mangled_path.begin(), mangled_path.end(), '/', '&'); // character '/' is not supported in datasets names + + // Pending buffered writes have to reach the file before anything is + // unlinked: HDF5DataSetHandler::close() frees its write buffers without + // flushing them, so closing first would silently drop writes made earlier + // in the same WRITE_OP session to datasets this delete does not touch. + if (!opened_data_sets.empty()) + write_buffers(); + close_datasets(); //also clears the existing_data_sets H5Lexists cache + + std::vector < std::string > doomed; + for (const std::string & name : groupMembers(gid)) { + if (is_in_subtree(name, mangled_path)) + doomed.push_back(name); + } + unlinkFromGroup(gid, doomed); +} + +void HDF5Writer::deleteOccurrence(OperationContext * ctx, hid_t file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, std::string & files_directory, std::string & relative_file_path) +{ close_datasets(); - close_group(ctx); + close_group(ctx); //also invalidates the cached group listing std::string IDS_link_name = ctx->getDataobjectName(); std::replace(IDS_link_name.begin(), IDS_link_name.end(), '/', '_'); HDF5Utils hdf5_utils; - //Deleting IDS link from master file if (H5Lexists(file_id, IDS_link_name.c_str(), H5P_DEFAULT) > 0) { //the IDS is referenced in the master file - auto got = opened_IDS_files.find(IDS_link_name); - hid_t IDS_file_id = -1; std::string IDSpulseFile = hdf5_utils.getIDSPulseFilePath(files_directory, relative_file_path, IDS_link_name); + auto got = opened_IDS_files.find(IDS_link_name); if (got != opened_IDS_files.end()) { - IDS_file_id = got->second; - if (IDS_file_id < 0) { - if (exists(IDSpulseFile.c_str())) { - hdf5_utils.openIDSFile(ctx, IDSpulseFile, &IDS_file_id, false); - } - } - else { - hdf5_utils.closeIDSFile(IDS_file_id, IDS_link_name); - hdf5_utils.deleteIDSFile(IDSpulseFile); - } + if (got->second >= 0) + hdf5_utils.closeIDSFile(got->second, IDS_link_name); opened_IDS_files[IDS_link_name] = -1; } - else { - if (exists(IDSpulseFile.c_str())) - hdf5_utils.deleteIDSFile(IDSpulseFile); - } + if (exists(IDSpulseFile.c_str())) + hdf5_utils.deleteIDSFile(IDSpulseFile); + //Deleting IDS link from master file: the link has to go with the file it + //points at, or the master keeps advertising an occurrence + //(al_get_occurrences enumerates these links) whose backing file is gone. + hdf5_utils.removeLinkFromMasterPulseFile(file_id, IDS_link_name); } } @@ -194,6 +324,7 @@ void HDF5Writer::close_datasets() void HDF5Writer::close_group(OperationContext *ctx) { + invalidateGroupMembers(); hid_t gid = -1; auto got = IDS_group_id.find(ctx); if (got != IDS_group_id.end()) { @@ -339,6 +470,28 @@ ArraystructContext* HDF5Writer::getDynamicAOS(Context * ctx) { return NULL; } +std::unique_ptr HDF5Writer::openOrCreateNonSliceDataSet( + const std::string& tensorized_path, hid_t* dataset_id, int datatype, + hid_t location, int dim, int* size, int aos_rank, + int* aos_shapes, bool shapes_dataset, + DataEntryContext* dataentry_context) { + auto data_set = std::make_unique( + true, dataentry_context->getURI()); + data_set->setNonSliceMode(); + constexpr bool create_chunk_cache = true; + if (H5Lexists(location, tensorized_path.c_str(), H5P_DEFAULT) == 0) { + data_set->create(tensorized_path.c_str(), dataset_id, datatype, + location, dim, size, aos_rank, aos_shapes, + shapes_dataset, create_chunk_cache); + } else { + data_set->open(tensorized_path.c_str(), location, dataset_id, dim, + size, datatype, shapes_dataset, create_chunk_cache, + dataentry_context->getURI(), aos_rank, aos_shapes); + data_set->setCurrentShapesAndExtend(size, aos_shapes); + } + return data_set; +} + void HDF5Writer::write_ND_Data(Context * ctx, std::string & att_name, std::string & timebasename, int datatype, int dim, int *size, void *data) { @@ -414,13 +567,11 @@ void HDF5Writer::write_ND_Data(Context * ctx, std::string & att_name, std::strin if (slice_mode != SLICE_OP) { //std::cout << "WRITER NOT IN SLICE MODE!!! " << std::endl; - if (dataset_id < 0) //in global mode (put), data set not yet created + if (dataset_id < 0) //not in this action's opened_data_sets cache -- may still exist on disk from a prior action on the same open pulse { - std::unique_ptr < HDF5DataSetHandler > dataSetHandler(new HDF5DataSetHandler(true, dec->getURI())); - dataSetHandler->setNonSliceMode(); - bool create_chunk_cache = true; - dataSetHandler->create(tensorized_path.c_str(), &dataset_id, datatype, gid, dim, size, AOSRank, arrctx_shapes.data(), false, create_chunk_cache); - data_set = std::move(dataSetHandler); + data_set = openOrCreateNonSliceDataSet( + tensorized_path, &dataset_id, datatype, gid, dim, size, + AOSRank, arrctx_shapes.data(), false, dec); } else { data_set = std::move(got->second); opened_data_sets.erase(got); @@ -606,9 +757,9 @@ std::string & timebasename, int timed_AOS_index, const std::vector < int > &curr } } else { - data_set->setNonSliceMode(); - bool create_chunk_cache = true; - data_set->create(tensorized_path.c_str(), &dataset_id, alconst::integer_data, loc_id, dim, size, AOSRank, aos_shapes.data(), shapes_dataset, create_chunk_cache); + data_set = openOrCreateNonSliceDataSet( + tensorized_path, &dataset_id, alconst::integer_data, loc_id, + dim, size, AOSRank, aos_shapes.data(), shapes_dataset, dec); } if (data_set->useBuffering && slice_mode != SLICE_OP) { diff --git a/src/hdf5/hdf5_writer.h b/src/hdf5/hdf5_writer.h index 70caf417..a629387f 100644 --- a/src/hdf5/hdf5_writer.h +++ b/src/hdf5/hdf5_writer.h @@ -21,6 +21,16 @@ class HDF5Writer { int homogeneous_time; std::unordered_map < OperationContext *, hid_t> IDS_group_id; + + // Link names of one IDS group, cached so that a run of deleteData() calls + // lists the group once instead of once per call — the HLI issues one + // al_delete_data per DD leaf, so re-listing per call would be quadratic in + // the size of the IDS. The cache validates itself against the group's live + // link count rather than relying on write paths to invalidate it: only a + // write can add a link, and the only code that removes one + // (unlinkFromGroup) keeps the cache in step itself. + hid_t group_members_gid; + std::vector < std::string > group_members; int slice_mode; @@ -30,8 +40,18 @@ class HDF5Writer { int readTimedAOSShape(Context * ctx, hid_t loc_id, const std::vector < int > ¤t_arrctx_indices); int readTimedAOSShape(hid_t loc_id, std::string &tensorized_path, const std::vector < int > ¤t_arrctx_indices, uri::Uri uri); int getDynamic_AOS_slices_extension(Context *ctx); + void deleteSubtree(hid_t gid, const std::string & path); + void deleteOccurrence(OperationContext * ctx, hid_t file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, std::string & files_directory, std::string & relative_file_path); + const std::vector < std::string > &groupMembers(hid_t gid); + void unlinkFromGroup(hid_t gid, const std::vector < std::string > &names); + void invalidateGroupMembers(); int getDynamic_slices_extension(Context *ctx, int timed_AOS_index, int time_vector_length); ArraystructContext* getDynamicAOS(Context * ctx); + std::unique_ptr openOrCreateNonSliceDataSet( + const std::string& tensorized_path, hid_t* dataset_id, int datatype, + hid_t location, int dim, int* size, int aos_rank, + int* aos_shapes, bool shapes_dataset, + DataEntryContext* dataentry_context); public: @@ -44,7 +64,7 @@ class HDF5Writer { static size_t write_chunk_cache_size; virtual void closePulse(DataEntryContext * ctx, int mode, hid_t *file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, int files_path_strategy, std::string & files_directory, std::string & relative_file_path); - virtual void deleteData(OperationContext * ctx, hid_t file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, std::string & files_directory, std::string & relative_file_path); + virtual void deleteData(OperationContext * ctx, const std::string & path, hid_t file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, std::string & files_directory, std::string & relative_file_path); virtual void write_ND_Data(Context * ctx, std::string & att_name, std::string & timebasename, int datatype, int dim, int *size, void *data); virtual void beginWriteArraystructAction(ArraystructContext * ctx, int *size); diff --git a/test b/test new file mode 100755 index 00000000..6c5c6444 --- /dev/null +++ b/test @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Convenience dispatcher for the backend contract tiers. +# +# The common backends run host-native; the MDSplus and UDA tiers each need a +# real backend that only exists inside a Docker image (docker/mdsplus/, +# docker/uda/), whose `docker build` + `docker run` recipes are long and easy to +# mistype. This wraps all of them behind one verb. +# +# ./test common build the always-on backends on the host, run ctest +# ./test mdsplus bake the DD-4.1.1 model tree, build, run `ctest -L mdsplus` +# ./test uda assemble the UDA reference stack, run `ctest -L uda` +# ./test all common + mdsplus + uda, then a PASS/FAIL summary +# (runs every tier even if one fails; exits non-zero +# if any did) +# ./test uda pass args through to uda-run.sh (e.g. `./test uda bash`, +# `./test uda ctest -L uda -R Smoke --output-on-failure`) +# +# `common` is host-native (no Docker): the default backends — HDF5, memory, +# ASCII, flexbuffers — build with the toolchain on your machine (needs HDF5 + +# Boost::filesystem installed). AL_BACKEND_MDSPLUS/UDA default OFF, so their +# contract tiers compile out and this ctest runs only the common-backend tests. +# +# Flags live in CMakePresets.json, not here: every tier delegates to the same +# `tests` / `mdsplus` presets CI runs, so this script only adds orchestration +# (Docker images, bind mounts, the summary table) on top — never a second copy +# of the configure line. +# +# The mdsplus/uda backing images are built automatically on first use and reused +# after that (rebuild by hand with `docker build`, or `docker image rm` to +# force it). Both legs bind-mount the repo at /workspace, so they always +# exercise the IMAS-Core you are currently editing — no image rebuild after code +# edits. +# +# Env overrides: +# PLATFORM docker --platform value (default linux/arm64; set linux/amd64 on +# an x86 cluster). The images and their apt sources are aarch64 by +# default per docker/*/README.md. +set -euo pipefail + +PLATFORM=${PLATFORM:-linux/arm64} +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Presentation. Colour only when stdout is a terminal, so redirected/CI logs +# stay plain. HR is a fixed-width rule reused by every header and the summary. +if [ -t 1 ]; then + C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'; C_GREEN=$'\033[32m'; C_RED=$'\033[31m'; C_RESET=$'\033[0m' +else + C_BOLD=; C_DIM=; C_GREEN=; C_RED=; C_RESET= +fi +HR="────────────────────────────────────────────────────────────────────────────" + +usage() { + # Print the contiguous header-comment block (from line 2 to the first + # non-comment line), stripping the leading "# ". + awk 'NR>1 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "${BASH_SOURCE[0]}" + exit "${1:-0}" +} + +# --- shared helpers --------------------------------------------------------- + +# Allocate a TTY only when stdin is one, so `./test uda bash` is interactive but +# CI / piped invocations still work. +tty_flags() { [ -t 0 ] && printf '%s' "-it" || true; } + +# Section header printed at the start of every tier: rule, bold title, subtitle. +section() { + printf '\n%s\n %s%s%s — %s\n%s\n\n' "$HR" "$C_BOLD" "$1" "$C_RESET" "$2" "$HR" +} + +# Build a backing image on first use only (reused thereafter). +ensure_image() { + local tag=$1 context=$2 + if ! docker image inspect "$tag" >/dev/null 2>&1; then + echo "==> building $tag (first use) [$PLATFORM]" + docker build --platform "$PLATFORM" -f "$context/Dockerfile" -t "$tag" "$context" + fi +} + +# Run one tier (label + command), time it, and print a uniform status line so +# COMMON/MDS-PLUS/UDA all report identically. Also appends a plain (uncoloured, +# aligned) row to SUMMARY for the end-of-run table. Returns the tier's exit code. +SUMMARY="" +run_tier() { + local label=$1; shift + local start=$SECONDS rc=0 + "$@" || rc=$? + local secs=$(( SECONDS - start )) + if [ "$rc" -eq 0 ]; then + printf '\n %sPASS%s %s%s%s (%ss)\n' "$C_GREEN" "$C_RESET" "$C_BOLD" "$label" "$C_RESET" "$secs" + SUMMARY="${SUMMARY}$(printf ' PASS %-9s %5ss' "$label" "$secs") +" + else + printf '\n %sFAIL%s %s%s%s (%ss, exit %s)\n' "$C_RED" "$C_RESET" "$C_BOLD" "$label" "$C_RESET" "$secs" "$rc" + SUMMARY="${SUMMARY}$(printf ' FAIL %-9s %5ss' "$label" "$secs") +" + fi + return "$rc" +} + +# End-of-run table (only for `all`). +print_summary() { + printf '\n%s\n %sSUMMARY%s\n%s\n%s%s\n' "$HR" "$C_BOLD" "$C_RESET" "$HR" "$SUMMARY" "$HR" +} + + +# --- COMMON: always-on backends (HDF5, memory, ASCII, flexbuffers), host-native + +run_common() { + section "COMMON" "always-on backends (HDF5, memory, ASCII, flexbuffers) — host-native" + # The `tests` presets are the same entry points as CI's blocking leg + # (contract-tests.yml): RelWithDebInfo, Ninja, no Python bindings, build/. + # The && chain returns non-zero on the first failing step. + cd "$REPO_ROOT" \ + && cmake --preset tests \ + && cmake --build --preset tests \ + && ctest --preset tests +} + + +# --- MDS-PLUS: Docker (docker/mdsplus/), bake model tree + build + ctest ------- + +run_mdsplus() { + section "MDS-PLUS" "Docker: bake DD-4.1.1 model tree, build, ctest -L mdsplus" + ensure_image imas-core-mdsplus:dev "$REPO_ROOT/docker/mdsplus" + # The `mdsplus` presets carry the backend/model/test flags, the + # build-mdsplus binary dir, the tier filter and MDSPLUS_MODELS_PATH; only + # the DD pin is passed here (mdsplus-contract.yml pins its own the same way). + docker run --rm --platform "$PLATFORM" \ + -v "$REPO_ROOT":/workspace -w /workspace \ + imas-core-mdsplus:dev bash -c ' + cmake --preset mdsplus -DDD_VERSION=4.1.1 && + cmake --build --preset mdsplus && + ctest --preset mdsplus + ' +} + + +# --- UDA: Docker (docker/uda/), assemble reference stack + ctest -------------- + +run_uda() { + section "UDA" "Docker: assemble reference stack (uda-run.sh), ctest -L uda" + ensure_image imas-core-uda:dev "$REPO_ROOT/docker/uda" + docker run --rm $(tty_flags) --platform "$PLATFORM" \ + -v "$REPO_ROOT":/workspace -w /workspace \ + imas-core-uda:dev uda-run.sh "$@" +} + + +# --- dispatch --------------------------------------------------------------- + +cmd=${1:-} +[ $# -gt 0 ] && shift || true +case "$cmd" in + common) run_tier COMMON run_common ;; + mdsplus) run_tier MDS-PLUS run_mdsplus ;; # no arg passthrough (fixed recipe) + uda) run_tier UDA run_uda "$@" ;; + all) + # Run every tier regardless of individual failures, then summarise and exit + # non-zero if any tier failed (coherent report over fail-fast). + rc=0 + run_tier COMMON run_common || rc=1 + run_tier MDS-PLUS run_mdsplus || rc=1 + run_tier UDA run_uda || rc=1 + print_summary + exit "$rc" + ;; + ""|-h|--help|help) usage 0 ;; + *) echo "unknown target: $cmd" >&2; usage 1 ;; +esac diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index db5b6b13..8ad3cb53 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -2,72 +2,77 @@ project( lowlevel-tests ) ######################################################################################################################## -# Dependencies +# Targets +# +# These smoke tests exercise the public C ABI (al_lowlevel.h) end-to-end and, +# by building the same program as both C++ and C, guard that the header stays +# C-compatible. The `al` target exports its include dirs PUBLICly, so linking +# it is enough to resolve al_lowlevel.h / al_defs.h. +set( EXE_FLAG ) +set( EXE_EXT ) -######################################################################################################################## -# Sources +add_executable( testlowlevel ${EXE_FLAG} testlowlevel.cpp ) +target_link_libraries( testlowlevel PRIVATE al ) +add_dependencies( testlowlevel al ) +set_target_properties( testlowlevel PROPERTIES OUTPUT_NAME testlowlevel${EXE_EXT} ) -include_directories( - ${CMAKE_CURRENT_SOURCE_DIR} - ${CMAKE_SOURCE_DIR}/lowlevel -) +add_executable( testlowlevel_c ${EXE_FLAG} testlowlevel_c.c ) +target_link_libraries( testlowlevel_c PRIVATE al ) +add_dependencies( testlowlevel_c al ) +set_target_properties( testlowlevel_c PROPERTIES OUTPUT_NAME testlowlevel_c${EXE_EXT} ) + +if(NOT WIN32 AND NOT APPLE) + # stdc++fs is only needed on Linux with older GCC + target_link_libraries( testlowlevel PRIVATE stdc++fs ) + target_link_libraries( testlowlevel_c PRIVATE stdc++fs ) +endif() ######################################################################################################################## -# Targets +# CTest registration +# +# The legacy `imas_public` user is not a real account, so the pulse path is +# supplied directly by passing an absolute base directory as the "user" (-u): +# the core then builds ////. Each test gets +# its own base dir (created by a fixture) to stay parallel-safe, and uses the +# DD-agnostic ASCII backend so no MDSplus model tree is required. -set( EXE_FLAG ) -set( EXE_EXT ) +set( _testdb ${CMAKE_CURRENT_BINARY_DIR}/testdb ) -if( BUILD_SHARED_LIBS ) - set( LOWLEVEL_LINK lowlevel-shared ) -else() - set( LOWLEVEL_LINK lowlevel-static ) -endif() +add_test( NAME testlowlevel_c_setup + COMMAND ${CMAKE_COMMAND} -E make_directory ${_testdb}/c/west/3/12/0 ) +set_tests_properties( testlowlevel_c_setup PROPERTIES FIXTURES_SETUP TESTDB_C ) -add_executable( testlowlevel ${EXE_FLAG} testlowlevel.cpp ) -target_link_libraries( testlowlevel PRIVATE - ${LOWLEVEL_LINK} -) +add_test( NAME testlowlevel_cpp_setup + COMMAND ${CMAKE_COMMAND} -E make_directory ${_testdb}/cpp/west/3/12/0 ) +set_tests_properties( testlowlevel_cpp_setup PROPERTIES FIXTURES_SETUP TESTDB_CPP ) -if( BUILD_SHARED_LIBS ) - add_dependencies( testlowlevel lowlevel-shared ) -else() - add_dependencies( testlowlevel lowlevel-static ) -endif() +add_test( NAME testlowlevel_c + COMMAND testlowlevel_c -b=A -s=12 -r=0 -o=C -c=C -u=${_testdb}/c ) +set_tests_properties( testlowlevel_c PROPERTIES FIXTURES_REQUIRED TESTDB_C ) -set_target_properties( testlowlevel - PROPERTIES - OUTPUT_NAME testlowlevel${EXE_EXT} - COMPILE_FLAGS -DNOIMPLIB -) +add_test( NAME testlowlevel_cpp + COMMAND testlowlevel -b=A -s=12 -r=0 -o=C -c=C -u=${_testdb}/cpp ) +set_tests_properties( testlowlevel_cpp PROPERTIES FIXTURES_REQUIRED TESTDB_CPP ) -add_executable( testlowlevel_c ${EXE_FLAG} testlowlevel_c.c ) -target_link_libraries( testlowlevel_c PRIVATE - ${LOWLEVEL_LINK} -) -set_target_properties( testlowlevel_c - PROPERTIES - OUTPUT_NAME testlowlevel_c${EXE_EXT} - COMPILE_FLAGS -DNOIMPLIB -) +######################################################################################################################## +# Contract test suite (GoogleTest — Task 2, TEST_STRATEGY.md) +# +# The C/C++ programs above stay as smoke tests + the pure-C compile canary +# (testlowlevel_c.c guards al_lowlevel.h staying C-compatible, per decision D3). +# The GoogleTest-based contract suite lives in the contract/ subdirectory. + +add_subdirectory(contract) ######################################################################################################################## # Install install( - TARGETS testlowlevel + TARGETS testlowlevel testlowlevel_c RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} ) - -install( - TARGETS testlowlevel_c - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} - LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} - ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} -) \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..b7837e6b --- /dev/null +++ b/tests/README.md @@ -0,0 +1,90 @@ +# Running the tests + +Everything here is driven through the presets in [`CMakePresets.json`](../CMakePresets.json) +— the same entry points CI uses, so a local run reproduces a CI leg exactly. + +```sh +cmake --preset tests # configure (AL_BUILD_TESTS=ON, no Python bindings) +cmake --build --preset tests # build libal + test binaries +ctest --preset unit # fast hermetic inner loop (< 1 s) +ctest --preset tests # everything the test build registers +``` + +For the tiers that need a Docker backend (MDSplus, UDA), the [`./test`](../test) +dispatcher at the repo root wraps the image build + `docker run` recipes behind +one verb and delegates all cmake/ctest flags to these same presets: + +```sh +./test common # host-native: the three preset commands above +./test mdsplus # Docker: bake the DD model tree, run the mdsplus tier +./test uda # Docker: assemble the UDA reference stack, run the uda tier +./test all # every tier + a PASS/FAIL summary table +``` + +## What lives where + +| Suite | Location | Framework | Purpose | +|---|---|---|---| +| Smoke tests | `tests/testlowlevel.cpp`, `tests/testlowlevel_c.c` | none (plain executables) | End-to-end C ABI exercise; the `.c` build doubles as a compile canary that `al_lowlevel.h` stays C-compatible. | +| Contract suite | `tests/contract/` | GoogleTest + CTest | Pins the behavior of the public C ABI (`al_lowlevel.h` + `al_const.h`) capability by capability. The *why* is [`TEST_STRATEGY.md`](../TEST_STRATEGY.md); the coverage ledger is [`tests/contract/TRACEABILITY.md`](contract/TRACEABILITY.md). | +| Python binding tests | `python/tests/` | pytest | Bindings-level checks (import, exceptions, `imasdef` constants, HDF5 permissions). | + +## Test tiers (CTest labels) + +Every contract test carries exactly one tier, assigned post-discovery by +`tests/contract/assign_contract_labels.cmake.in` (the tier definitions live +there) and verified disjoint by `tests/contract/check_label_partition.sh`. + +| `ctest --preset …` | Label | What runs | Needs | +|---|---|---|---| +| `unit` | `unit` | Pure lookups (`Introspection.*`), the in-RAM Memory backend matrix, plugin registry lifecycle + ABI death tests, URI ownership | Nothing — hermetic, no disk | +| `integration` | `integration` | HDF5 / ASCII / Flexbuffers round trips on disk | Disk only | +| `tests` / `tests-debug` | *(all)* | Contract suite + smoke tests, in the build mode CI's blocking legs use (both modes matter — issue #32) | — | +| `sanitize` | `contract` | The suite under ASan/UBSan (`AL_CONTRACT_SANITIZE=ON`), excluding `Death` and `CurrentlyCorrupts` cases — see the comments in `sanitizers.yml` for why | Linux for leak detection; on macOS prepend `ASAN_OPTIONS=detect_leaks=0` | +| `mdsplus` | `mdsplus` | MDSplus tier against the DD model tree the `mdsplus` configure preset bakes | MDSplus installed; configure downloads the DD (`-DDD_VERSION=` to pin) | +| *(no preset)* | `uda` | UDA tier against the pinned reference stack | `./test uda` (or `docker/uda/run.sh` inside the container — see `docker/uda/README.md`); there is deliberately no host preset | + +Each test preset (except the run-everything ones' smoke tests) fails on an +empty selection (`noTestsAction: error`), so a broken label partition can't +pass silently. + +For a one-off finer cut, plain CTest still works from the build directory: +`ctest -R Introspection`, `ctest -L integration -E Ascii`, etc. + +## Reading the output + +The suite encodes its own status vocabulary (full definitions in the +[TRACEABILITY.md preamble](contract/TRACEABILITY.md)): + +- **`DISABLED_…` test + a paired `CurrentlyCorrupts`/tripwire test** — an + *xfail*: the disabled test asserts the *correct* behavior of a known defect, + the tripwire pins today's wrong behavior. The tripwire going red means + someone fixed the defect — flip the pair, don't "fix the test". +- **`GTEST_SKIP()`** — either a legitimate backend *divergence* (the test + shape doesn't transfer to that backend) or an unconfigured optional backend + (e.g. `MDSPLUS_MODELS_PATH` / `UDA_HOST` unset). The skip message says which. + +## CI legs + +| Workflow | Preset(s) | Gate | +|---|---|---| +| `contract-tests.yml` | `tests`, `tests-debug` | **Blocking** on every PR | +| `sanitizers.yml` | `sanitize` | Informational (D7) | +| `mdsplus-contract.yml` | `mdsplus` (in Docker, DD pinned in the workflow) | Informational | +| `uda-contract.yml` | — (`docker/uda/run.sh`) | Informational | + +## Python tests + +```sh +pip install .[test] +pytest # testpaths is rooted at python/tests in pyproject.toml +``` + +## Fixture tools + +Two build-gated helper executables prepare states that must *not* be reachable +through the public C ABI (out-of-band file/tree surgery); the tests invoke +them as subprocesses: `hdf5_fixture_tool.cpp` (rewrites the stored +HDF5 backend version) and `mdsplus_fixture_tool.cpp` (rewrites the model +tree's version node). They are fixture *preparation*, never assertion — the +contract suite asserts only through the C ABI. diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt new file mode 100644 index 00000000..fd5f9f55 --- /dev/null +++ b/tests/contract/CMakeLists.txt @@ -0,0 +1,279 @@ +######################################################################################################################## +# Contract test suite (Task 2 — TEST_STRATEGY.md) +# +# GoogleTest + CTest suite binding the public C ABI (al_lowlevel.h + al_const.h) +# — decisions D1 (C-ABI seam) and D3 (GoogleTest for death tests + TEST_P). This +# is the scaffold slice (step 1): it proves every mechanism — round trip on an +# on-disk and an in-RAM backend, and one expected-fail on a real defect — before +# the matrix is broadened in later steps. +# +# The whole suite lives under AL_BUILD_TESTS (OFF by default), so it never +# perturbs the normal library / wheel build. + +######################################################################################################################## +# GoogleTest — prefer an installed copy, fall back to fetching a pinned release. +# +# find_package first keeps CI/offline builds fast when GTest is already provided +# (e.g. a module or system package). FetchContent is the portable fallback; it +# only runs when tests are being built. + +find_package(GTest QUIET) + +if(NOT GTest_FOUND) + include(FetchContent) + FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + # Pinned to the immutable commit for v1.15.2 (a tag can be re-pointed). + GIT_TAG b514bdc898e2951020cbdca1304b75f5950d1f59 + ) + # Honor the parent project's shared-CRT choice on Windows. + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + set(INSTALL_GTEST OFF CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(googletest) +endif() + +include(GoogleTest) + +######################################################################################################################## +# Optional sanitizer build (decision D7): compile the same functional suite with +# AddressSanitizer + UndefinedBehaviorSanitizer so it self-checks for UB / UAF / +# overruns with no per-test code. Non-blocking / informational — a dedicated CI +# leg, not a hard gate, until the baseline is triaged. Enable with +# -DAL_CONTRACT_SANITIZE=ON. LeakSanitizer's leak detection is Linux-only; macOS +# gives crash/overrun detection but not reliable leak detection. + +option(AL_CONTRACT_SANITIZE + "Build the contract test suite with ASan/UBSan (non-blocking)" OFF) + +######################################################################################################################## +# The contract test executable. +# +# Step 3 (issue #5) adds two files to the single suite: the pure introspection +# lookups (test_introspection.cpp — the thin unit tier: const2str/err2str/ +# versions, no backend) and the version sentinel (test_version_sentinel.cpp). +# They join the existing round-trip and known-defect files from the scaffold. +# +# The finer CTest unit/integration LABEL split (decision D6) stays deferred, as +# the scaffold commit recorded — it arrives with the broader matrix in a later +# step. Until then the pure-lookup subset is addressable directly via +# `ctest -R Introspection` / `--gtest_filter=Introspection.*` (a fast, disk-free +# inner loop) even though every case shares the single "contract" label. + +add_executable(contract_tests + test_roundtrip.cpp + test_roundtrip_matrix.cpp + test_introspection.cpp + test_version_sentinel.cpp + test_capability_gated.cpp + test_known_defects.cpp + test_plugins.cpp + test_backend_factory.cpp + test_hdf5_version_drift.cpp + test_hdf5_absolute_paths.cpp + real_dd_path_catalog.cpp + test_real_dd_path_catalog.cpp + test_structured_data.cpp + test_pulse_lifecycle.cpp + test_mdsplus.cpp + test_mdsplus_real_paths.cpp + test_mdsplus_delete.cpp + test_mdsplus_version_drift.cpp + test_mdsplus_unique_surface.cpp + test_uda.cpp + test_uda_real_paths.cpp + test_uda_breadth.cpp + test_uda_unique_surface.cpp +) + +target_link_libraries(contract_tests PRIVATE al al_contract_fixtures GTest::gtest GTest::gtest_main) +add_dependencies(contract_tests al) +target_compile_features(contract_tests PRIVATE cxx_std_17) + +######################################################################################################################## +# HDF5 drift-fixture producer (issue #36). +# +# The stored-backend-version drift check is asserted through the public C ABI +# (test_hdf5_version_drift.cpp), but preparing a MISMATCHED pulse requires +# rewriting master.h5's HDF5_BACKEND_VERSION attribute — out-of-band HDF5 C +# API surgery that must NOT leak into contract_tests. It lives in a separate +# tool the tests invoke as a subprocess; contract_tests only learns the tool's +# path (and gates the test file's compilation on it). +if(AL_BACKEND_HDF5) + find_package(HDF5 COMPONENTS C REQUIRED) + add_executable(al-hdf5-fixture-tool hdf5_fixture_tool.cpp) + target_include_directories(al-hdf5-fixture-tool PRIVATE ${HDF5_C_INCLUDE_DIRS}) + target_link_libraries(al-hdf5-fixture-tool PRIVATE ${HDF5_C_LIBRARIES}) + target_compile_definitions(al-hdf5-fixture-tool PRIVATE ${HDF5_C_DEFINITIONS}) + target_compile_features(al-hdf5-fixture-tool PRIVATE cxx_std_17) + add_dependencies(contract_tests al-hdf5-fixture-tool) + target_compile_definitions(contract_tests PRIVATE + AL_CONTRACT_HDF5_FIXTURE_TOOL="$") +endif() + +######################################################################################################################## +# MDSplus build gate (issue #13 / TEST_STRATEGY.md D4 "compile-guarded tier"). +# +# test_mdsplus.cpp wraps its whole body in #ifdef AL_CONTRACT_HAVE_MDSPLUS, so +# it compiles out to nothing when the backend is off — a developer without +# MDSplus installed never pays for it, and CI legs that build with +# AL_BACKEND_MDSPLUS=OFF are unaffected. When the backend is on, the file's +# lone test still runtime-skips (GTEST_SKIP()) if MDSPLUS_MODELS_PATH is +# unset, so "on but unconfigured" never fails either. +if(AL_BACKEND_MDSPLUS) + target_compile_definitions(contract_tests PRIVATE AL_CONTRACT_HAVE_MDSPLUS) + + # MDSplus drift-fixture producer (issue #35). test_mdsplus_version_drift.cpp + # (issue #16) forces the version-mismatch case by overwriting the pulse's + # VERSION:BACK_MAJOR tree node — reachable only through the raw MDSplus C++ + # API (), i.e. outside the public C ABI that is the contract + # suite's one seam. That mutation is fixture PREPARATION, so it lives in a + # separate build-gated tool the test invokes as a subprocess; contract_tests + # itself has no MDSplus include or link dependency, and a rewrite satisfying + # the C ABI is judged by exactly the C ABI. find_package(MDSplus) is + # repeated (idempotent) for the tool only. + find_package(MDSplus REQUIRED) + add_executable(al-mdsplus-fixture-tool mdsplus_fixture_tool.cpp) + target_include_directories(al-mdsplus-fixture-tool PRIVATE ${MDSPLUS_INCLUDES}) + target_link_libraries(al-mdsplus-fixture-tool PRIVATE ${MDSPLUS_LIBRARIES}) + target_compile_features(al-mdsplus-fixture-tool PRIVATE cxx_std_17) + add_dependencies(contract_tests al-mdsplus-fixture-tool) + target_compile_definitions(contract_tests PRIVATE + AL_CONTRACT_MDSPLUS_FIXTURE_TOOL="$") +endif() + +######################################################################################################################## +# UDA build gate (issue #23 / PRD #21, TEST_STRATEGY.md D4 "compile-guarded tier"). +# +# Same shape as the MDSplus gate above: test_uda.cpp wraps its whole body in +# #ifdef AL_CONTRACT_HAVE_UDA, so it compiles out to nothing when neither UDA +# backend is built, and always-on CI legs (both UDA options OFF) are unaffected. +# The gate tracks AL_BACKEND_UDA *and* AL_BACKEND_UDAFAT (the fat-client build), +# matching how src/CMakeLists.txt enables the UDA source. When the +# backend is on the test still runtime-skips (GTEST_SKIP()) if UDA_HOST is unset, +# so "on but no reference stack reachable" never fails either. Unlike MDSplus the +# gate needs no extra test-only link deps: the whole round trip goes through the +# public C ABI, and the uda-cpp client is already linked PRIVATEly into `al` by +# src/uda/CMakeLists.txt (the smoke test never touches UDA's own API). +if(AL_BACKEND_UDA OR AL_BACKEND_UDAFAT) + target_compile_definitions(contract_tests PRIVATE AL_CONTRACT_HAVE_UDA) +endif() + +######################################################################################################################## +# Test plugin fixture (issue #7, TEST_STRATEGY.md step 5). +# +# A real, loadable plugin the C ABI can dlopen, so the register/bind/unbind/ +# unregister state machine and the al_setvalue_*_parameter_plugin happy paths +# run end to end (not just their failure modes). This is the classic red-green +# fixture/harness the issue calls out. Built as a MODULE named exactly +# "_plugin.so" (PREFIX ""/SUFFIX ".so") because registerPlugin resolves +# "$IMAS_AL_PLUGINS/_plugin.so" (src/al_lowlevel.cpp:335). The +# plugin base classes are header-only, so the module links no libal symbols — +# it only needs al's include dirs (and al's generated al_defs.h). The plugins +# framework is dlopen/dlfcn-based, so this is POSIX-only. +if(NOT WIN32) + add_library(al_contract_plugin MODULE test_plugin_fixture.cpp) + # Pull in al's (source + generated) include dirs without linking its symbols. + target_include_directories(al_contract_plugin + PRIVATE $) + add_dependencies(al_contract_plugin al) # ensure al_defs.h is generated + add_dependencies(contract_tests al_contract_plugin) + target_compile_features(al_contract_plugin PRIVATE cxx_std_17) + set_target_properties(al_contract_plugin PROPERTIES + PREFIX "" + OUTPUT_NAME "alcontract_plugin" + SUFFIX ".so" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/plugins") + + # Tell test_plugins.cpp where the built .so lives and what name to register. + target_compile_definitions(contract_tests PRIVATE + AL_CONTRACT_PLUGIN_DIR="${CMAKE_CURRENT_BINARY_DIR}/plugins" + AL_CONTRACT_PLUGIN_NAME="alcontract") +endif() + +if(NOT WIN32 AND NOT APPLE) + # stdc++fs is only needed on Linux with older GCC (std::filesystem in the harness). + target_link_libraries(contract_tests PRIVATE stdc++fs) +endif() + +if(AL_CONTRACT_SANITIZE) + if(MSVC) + message(WARNING "AL_CONTRACT_SANITIZE not supported with MSVC; ignoring.") + else() + set(_al_sanitize_flags -fsanitize=address,undefined -fno-omit-frame-pointer -g) + target_compile_options(contract_tests PRIVATE ${_al_sanitize_flags}) + target_link_options(contract_tests PRIVATE -fsanitize=address,undefined) + # Instrument the library too, not just the tests: D7's value ("a future + # leak/UAF in the hot path is caught for free") is about libal's code, so + # the flags must reach the `al` target — otherwise the round trip runs + # through an uninstrumented library and most internal UAF/overruns escape. + # `al` is defined in the root CMakeLists before tests/ is added, so the + # target is visible here. Note: a sanitized libal is meant for the dedicated + # C++-only sanitizer CI leg; don't combine this option with a wheel build, + # whose bindings would then link a sanitized libal without the ASan runtime. + target_compile_options(al PRIVATE ${_al_sanitize_flags}) + target_link_options(al PRIVATE -fsanitize=address,undefined) + endif() +endif() + +######################################################################################################################## +# CTest registration. +# +# gtest_discover_tests runs each GoogleTest case as its own ctest entry (so +# --gtest_list_tests / -R selection works). Every case carries the "contract" +# label; on top of that we split by *substrate* (decision D6) so `ctest -L unit` +# is a fast, hermetic inner loop (PRD user story 29) and `ctest -L integration` +# is the on-disk tier: +# unit = pure lookups (Introspection.*), the in-RAM Memory backend +# (*Memory* parametrized instances), the plugin ABI death +# tests (KnownDefects*), the plugin lifecycle suites +# (Plugin* and ReadbackPlugins.*, an in-process registry over +# the Memory backend — no on-disk backend), and the URI +# builder ownership check (UriOwnership.*, pure string). +# integration = everything else (HDF5 / ASCII / Flexbuffers on disk). +# One discovery pass over the one binary; assign_contract_labels.cmake then +# classifies every discovered test into exactly one tier (disjoint by +# construction — see the note at the registration block below). + +# Issue #31: labels are assigned POST-discovery, not through +# gtest_discover_tests. Two version-dependent defects ruled the old multi-pass +# TEST_FILTER partition out across the supported CMake range: +# - gtest_discover_tests' PROPERTIES forwarding does not preserve a +# multi-element LABELS list on every supported version (empirically +# CMake 3.28 collapses "contract\;unit" to just "contract", silently +# dropping the rest), so `ctest -L unit` selected nothing there; and +# - TEST_FILTER needs CMake >= 3.22, so the declared 3.21 floor fell back +# to a single all-"contract" pass with no unit/integration split at all. +# Instead there is ONE discovery pass (no TEST_FILTER, single-element LABELS — +# both fine on 3.21), and assign_contract_labels.cmake runs afterwards via the +# directory TEST_INCLUDE_FILES property: ctest processes it after the +# discovery include has registered every test, and it re-labels each test by +# name with a plain set_tests_properties list — a mechanism with no +# version-dependent forwarding in the middle. The tier definitions (which +# suites are unit / integration / mdsplus / uda, per issues #13-#17 and +# #23-#26) are documented and maintained in assign_contract_labels.cmake.in. +# No LABELS here: ctest MERGES labels across set_tests_properties calls +# (append semantics), so a discovery-time "contract" could not be removed by +# the labeler and would leak onto the mdsplus/uda tiers, changing what +# `ctest -L contract` (sanitizers.yml) selects. assign_contract_labels.cmake +# assigns each test its complete, final label list instead. +gtest_discover_tests(contract_tests) + +# Bake the generated registration file's path into the labeler. A single +# discovery pass for contract_tests means GoogleTest writes +# "contract_tests[1]_tests.cmake". +set(_AL_CONTRACT_DISCOVERED_TESTS_FILE + "${CMAKE_CURRENT_BINARY_DIR}/contract_tests[1]_tests.cmake") +configure_file(assign_contract_labels.cmake.in assign_contract_labels.cmake @ONLY) +set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES + "${CMAKE_CURRENT_BINARY_DIR}/assign_contract_labels.cmake") + +######################################################################################################################## +# Install (mirrors the smoke-test targets). + +install( + TARGETS contract_tests + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} +) diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md new file mode 100644 index 00000000..d596aca2 --- /dev/null +++ b/tests/contract/TRACEABILITY.md @@ -0,0 +1,1097 @@ +# Contract-suite traceability matrix (Task 2) + +Per **TEST_STRATEGY.md decision D8**, this matrix — not line coverage — is the +**primary definition of "comprehensive."** It has **one row per +`FUNCTIONALITY_INVENTORY.md` capability, across all three Audiences** (User, +Backend implementer, Plugin author), in the inventory's own Part/Cluster order. +A capability with no row here is an omission in *this file*, not an untested +capability that is silently fine — the point of the enumeration is that "done" +is a checkable list. + +Status vocabulary: + +- **covered** — an intended-contract test asserts the behavior and passes. +- **covered (indirect)** — a Backend-implementer / lower-layer capability with + no C-ABI seam of its own (decision D1 forbids linking the C++ `Backend` + classes), asserted through its sole ABI-observable consequence, or a User + capability exercised as an asserted dependency of another test (e.g. every + round trip asserts its `al_close_pulse` returns 0). The residual, un-asserted + modes/paths are named in the row's Test(s)/notes cell. +- **xfail** — a genuine defect; the test asserts the *correct* behavior and is + expected-fail (decision D2, `DISABLED_` + a paired current-behavior tripwire). + Flipping to pass means someone fixed it. +- **divergence** (issue #12, Part 4) — not a defect: a legitimate storage-model + difference where a parity row's synthetic/DD-agnostic test shape doesn't + transfer to a backend that enforces something the others don't (e.g. + MDSplus resolving every path against a real DD-baked model tree). The test + is skipped with an explanation (`GTEST_SKIP()`), not paired with a + `DISABLED_`/tripwire — there is nothing to "fix," since flipping it would + require changing the test's own shape, not the backend. Distinct from + `xfail` (a bug that could be fixed) and from `gap` (not tested at all). +- **gap** — not tested. Either *deferred*, naming the later build-order step + (TEST_STRATEGY §4) that owns it, or — now that issue #8 (the last build-order + step) is closing — *terminal*: reachable in principle but out of this + suite's scope (e.g. needs infrastructure this test target doesn't have), or + genuinely not observable through the C ABI at all. Each terminal gap says + which, explicitly, so "gap" here never means "silently fine." + +Line coverage (`gcov`/`lcov`) is a secondary signal only (D8): a rewrite may +restructure lines but must still satisfy every row here. + +Test IDs are GoogleTest `Suite.Case` names as registered in CTest; run one with +`ctest -R ` or `contract_tests --gtest_filter=`. Parametrized cases +carry a `/` (or `/__r`) suffix per instance. + +> **Note on the Unit / Integ. columns.** They classify each test by *substrate* +> per decision D6 (pure lookups and Memory round trips are unit; on-disk +> backends are integration). This taxonomy is now enforced as CTest **labels** +> (`CMakeLists.txt`): every case carries `contract`, plus `unit` or `integration` +> by substrate, so `ctest -L unit` runs the fast hermetic subset (no on-disk +> backend) and `ctest -L integration` runs the on-disk tier. The label split +> needs CMake ≥ 3.22 (`gtest_discover_tests(TEST_FILTER …)`); on the declared +> 3.21 floor it falls back to the single `contract` label. + +## Coverage status — as of all six implemented issues (#2 scaffold, #5 +## introspection, #3 round-trip matrix, #6 capability-gated, #4 structured +## data, #7 plugins, #8 ownership sweep) + +The suite covers the **data-path, introspection, structured-data surface, +plugin-management surface, and Plugin-author audience** end to end and pins +the defects those issues surfaced (nineteen xfails, each with a paired +current-behavior tripwire — issue #8 resolved every open ownership/coverage +question by test but found no new genuine defects, so it left the xfail count +unchanged; issue #63 retired one by fixing it, HDF5's occurrence-wide +`al_delete_data` widening). Issue #8 (`6-ownership-sweep.md`) closed every remaining row: + +- **Ownership**: `al_context_info`, `al_get_occurrences`'s + `*occurrences_list`, and `al_list_filled_paths`'s list+strings were already + pinned malloc/free by issues #4/#6 (`ContextInfo.*`, `Occurrences.*`, + `CapabilityMatrix.ListFilledPathsPositiveOrRefused`). + `al_build_uri_from_legacy_parameters`'s `*uri` gets its own explicit pin + here (`UriOwnership.CallerFreesBuiltUri`), rather than only incidental + free()s inside the `al_contract::build_uri` test helper. +- **MAXDIM**: resolved empirically — plugin-parameter `dim`/`size` on + `al_setvalue_parameter_plugin` is **not** bounded by MAXDIM; the core + passes it through to the plugin unchecked + (`PluginTest.SetValueGenericAcceptsDimAboveMaxdim`). +- **Plugin-author audience (Part 3)**: low-level reentry (`PluginReentry.*`), + action-lifecycle data interception + (`PluginTest.BoundPluginIntercepts{Write,Read}InsteadOfBackend`), provenance + + `al_write_plugins_metadata` + (`PluginTest.WritePluginsMetadataStoresBoundPluginProvenance`), and readback + binding (`ReadbackPlugins.*`, with `test_plugin_fixture.cpp` extended to + opt into readback capability for one path via + `AL_CONTRACT_PLUGIN_READBACK_PATH`) are now all covered. +- **Two rows stay `gap`, by design, not omission**: Backend `getVersion` + drift and `initDataInterpolationComponent` — see their rows in Part 2 for + why each is a legitimate terminal gap rather than a deferred one. + +--- + +# Part 1 — User (HLI implementer) audience + +## Cluster 1 — Pulse lifecycle (`FUNCTIONALITY_INVENTORY.md:86-149`) + +| Capability | Inventory ref | Unit | Integ. | Test(s) — and residual | Status | +|---|---|:--:|:--:|---|---| +| `al_begin_dataentry_action` opens a data entry from a URI | :90-107 | ✓ (Memory) | ✓ (HDF5, ASCII) | `FORCE_CREATE_PULSE` asserted OK in every suite. `OPEN_PULSE` fails when absent / `FORCE_OPEN_PULSE` creates when absent / `OPEN_PULSE` succeeds once present / `CREATE_PULSE` refuses an existing pulse (HDF5, Memory): `Backends/DataEntryModes.*`. MDSplus: joined too — see Part 4. UDA: all four modes now have the same lifecycle verdict (`UdaBreadthTest.{OpenPulse*,ForceOpenPulse*,CreatePulse*,ForceCreatePulse*}`) — see Part 5 | covered | +| ↳ ASCII's `CREATE_PULSE` has no existence guard — **silently overwrites/truncates an existing pulse** instead of refusing it | :90-107; src/ascii_backend.cpp:107-157 | ✓ | — | `ModeKnownDefects.DISABLED_AsciiCreatePulseFailsWhenAlreadyExists` + tripwire `ModeKnownDefects.AsciiCreatePulseCurrentlyOverwritesSilently` | **xfail** | +| `al_close_pulse` closes (`CLOSE_PULSE`) / erases (`ERASE_PULSE`) | :109-116 | ✓ (Memory) | ✓ (HDF5, ASCII) | `CLOSE_PULSE` asserted `code==0` in every suite. UDA: `CLOSE_PULSE` retains an openable remote pulse; read-bearing sessions separately leak a server-side handle through a URI mismatch — see Part 5 | covered (indirect) | +| ↳ `ERASE_PULSE` is not implemented by any always-on backend — **behaves identically to `CLOSE_PULSE`** (none of HDF5/ASCII/Memory's `closePulse` reference their `mode` parameter); a later plain `OPEN_PULSE` on the "erased" pulse still succeeds | :109-116; src/hdf5/{hdf5_reader,hdf5_writer}.cpp closePulse, src/ascii_backend.cpp:165-169, src/memory_backend.h:505-509 | ✓ (Memory) | ✓ (HDF5, ASCII) | `ErasePulseKnownDefects.DISABLED_{Hdf5,Ascii,Memory}EraseMakesPulseUnopenable` + tripwires `ErasePulseKnownDefects.{Hdf5,Ascii,Memory}EraseCurrentlyLeavesPulseOpenable`. UDA carries the same defect across the remote boundary: `UdaBreadthTest.DISABLED_ErasePulseMakesRemotePulseUnopenable` + tripwire `UdaBreadthTest.ErasePulseCurrentlyLeavesRemotePulseOpenable` (Part 5) | **xfail** | +| `al_context_info` describes a context (pulse/operation/arraystruct); **caller frees `*info`** (verified `malloc`-based) | :118-126 | ✓ | ✓ | `ContextInfo.{NullContextReturnsLiteralString, PulseContextDescribesItsUri, OperationContextDescribesDataobjectAndAccessmode, ArraystructContextDescribesPathAndIndex}` (frees via `free()`) | covered | +| `al_get_backendID` returns the active `BACKEND` for a context | :128-135 | — | ✓ | `GetBackendId.ReturnsTheBackendUsedToOpen` (HDF5 only) | covered | +| ↳ no context-type check — a non-pulse context is `static_cast` (not `dynamic_cast`) to `DataEntryContext*` with no validation, so it **silently "succeeds" with an undefined-behavior value** instead of erroring (confirmed empirically: it does not crash) | :128-135; src/al_lowlevel.cpp al_get_backendID | — | ✓ | `GetBackendIdKnownDefects.DISABLED_WrongContextTypeReturnsError` + tripwire `GetBackendIdKnownDefects.WrongContextTypeCurrentlySucceedsViaUnsafeCast` (HDF5 only) | **xfail** | +| `al_build_uri_from_legacy_parameters` builds a URI from legacy params; **caller frees `*uri`** (verified malloc-based, src/al_context.cpp:241-261) | :137-149 | ✓ | ✓ | `al_contract::build_uri` (asserts OK, frees) drives HDF5·Memory·ASCII in all on-disk suites; ownership pinned explicitly by `UriOwnership.CallerFreesBuiltUri` | covered | +| ↳ must also address the always-on FLEXBUFFERS backend — **throws (`getURIBackend` has no case)** | :137-149; src/al_context.cpp:280 | ✓ | — | `RoundTripKnownDefects.DISABLED_BuildUriSupportsFlexbuffers` + tripwire `RoundTripKnownDefects.BuildUriFlexbuffersCurrentlyFails` | **xfail** | + +## Cluster 2 — Core data access (`FUNCTIONALITY_INVENTORY.md:153-314`) + +The bulk of the storage contract (issue #3) plus the capability-gated ops +(issue #6). Round-trip oracle = self-consistency (decision D5); synthetic opaque +data, zero DD artifacts. The round-trip matrix is parametrized over the +always-on tier (decision D4) {HDF5, Memory, ASCII, Flexbuffers} × {CHAR, +INTEGER, DOUBLE, COMPLEX} × {scalar → 7-D}; each cell is classified once +(D2/D4) as a plain round trip, a documented refusal (paired-negative), or a +genuine defect (expected-fail). + +| Capability | Inventory ref | Unit | Integ. | Test(s) — and residual | Status | +|---|---|:--:|:--:|---|---| +| `al_begin_global_action` starts a GLOBAL read/write op | :159-175 | ✓ (Memory) | ✓ (HDF5, ASCII) | Vehicle for every round trip: `RoundTrip.*`, `RoundTripMatrix.*`, `VersionSentinel.*`, `CapabilityMatrix.*` (WRITE_OP + READ_OP). `datapath` partial-get is UDA-only (out of always-on scope). MDSplus joins `CapabilityMatrix` only, not `RoundTripMatrix`/`VersionSentinel` (opaque synthetic paths) — see Part 4. UDA: real-DD-path breadth matrix, cache-mode/`datapath` unique surface — see Part 5 | covered | +| `al_begin_slice_action` starts a time-slice op (READ interp; WRITE append via `UNDEFINED_TIME`) | :177-194 | ✓ (Memory) | ✓ (HDF5) | HDF5·Memory positive / ASCII·Flexbuffers refused `BACKEND_ERR`: `CapabilityMatrix.SliceReadPositiveOrRefused`, `CapabilityMatrix.SliceWriteBeginPositiveOrRefused`. Interp modes + missing-interp refusal (`CONTEXT_ERR`): `Hdf5TimeDependent.SliceInterpolationModes`, `Hdf5TimeDependent.SliceReadWithoutInterpModeIsRejected`. MDSplus: see Part 4 (real interpolation round trip, plus struct-aware slice interpolation as a unique surface). UDA: `CLOSEST_INTERP` on a real DD dynamic leaf round-trips correctly in remote mode — see Part 5 | covered | +| ↳ WRITE append via `UNDEFINED_TIME` must accumulate — **only the last slice persists in this build** | :177-194; al_lowlevel.h:300-316 | — | ✓ | `Hdf5SliceAppend.DISABLED_AppendedSlicesAllPersist` + tripwire `Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior` | **xfail** | +| `al_begin_timerange_action` starts a time-range op (with/without resampling `dtime`) | :196-221 | ✓ (Memory) | ✓ (HDF5) | HDF5 positive (no-resample + LINEAR resample): `Hdf5TimeDependent.TimeRangeReadWithoutResampling`, `…WithResampling`; Memory·ASCII·Flexbuffers refused `LOWLEVEL_ERR`: `CapabilityMatrix.TimeRangeReadPositiveOrRefused`. MDSplus: also refused, unconditionally — see Part 4. UDA: capability genuinely granted (server 1.8.0 > 1.4.0) but the read then fails — `OperationContext`'s TIMERANGE_OP ctor never sets base `interpmode` — xfail, see Part 5 | covered | +| `al_begin_arraystruct_action` starts an AOS op (top-level + nested), write→iterate→read | :223-233 | ✓ (Memory) | ✓ (HDF5) | `Backends/AosMatrix.{TopLevelWriteIterateRead,NestedWriteIterateRead}/{HDF5,Memory}`; Flexbuffers refused (paired-negative, same read-refusal as the round-trip matrix): `…/Flexbuffers`. MDSplus: joined too, but a divergence (synthetic paths) — see Part 4. UDA: traversal mechanism (size + iteration) is covered, but a *dynamic* leaf nested inside a struct_array returns the HDF5 absent-scalar sentinel instead of what was written — xfail, see Part 5 | covered | +| ↳ ASCII's AOS **read** always reports size 0, for any AOS, regardless of what was written — `beginAction`'s `READ_OP` setup consumes the whole file into a random-access lookup map before the AOS-size lookup's sequential cursor ever runs | :223-233; src/ascii_backend.cpp:213-235,656-682 | ✓ | — | `AosKnownDefects.DISABLED_AsciiAosReadReportsWrittenSize` + tripwire `AosKnownDefects.AsciiAosReadCurrentlyReportsZero`; matrix cells skip via `Backends/AosMatrix.*/ASCII` | **xfail** | +| `al_end_action` ends any context | :235-242 | ✓ (Memory) | ✓ (HDF5, ASCII) | asserted OK in every suite | covered (indirect) | +| `al_write_data` → `al_read_data` round trip preserves value + shape (INTEGER/DOUBLE/COMPLEX scalar→7-D on HDF5·Memory·ASCII; CHAR scalar/1-D/2-D on HDF5·ASCII, all ranks on Memory) | :244-263 | ✓ (Memory) | ✓ (HDF5, ASCII) | `Backends/RoundTripMatrix.ReadEqualsWrite/*`; generator `al_contract.h` `synth_value`/`synth_buffer`/`shape_for_rank`. MDSplus excluded from this fixture (opaque synthetic paths) — see Part 4's real-DD-path breadth matrix instead. UDA likewise excluded, for the same reason — see Part 5's real-DD-path breadth matrix | covered | +| ↳ Flexbuffers must-refuse column (serializer, not a pulse store): write accepted, read refused, every datatype × shape | :244-263 / D4 | — | ✓ | `…/Flexbuffers_*_r{0..7}` (paired-negative) | covered | +| ↳ CHAR > 2-D refused (documented "not implemented") on HDF5·ASCII | :244-263 / D4 | — | ✓ | `…/{HDF5,ASCII}_CHAR_r{3..7}` (paired-negative) | covered | +| ↳ CHAR scalar (dim 0) must round-trip — **HDF5 crashes** | :244-263 | — | ✓ | `RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips` + tripwire `RoundTripKnownDefectsDeath.Hdf5CharScalarCurrentlyCrashes` | **xfail** | +| ↳ numeric at MAXDIM (rank 7) must round-trip — **ASCII corrupts (DOUBLE/COMPLEX) / aborts (INTEGER)** | :244-263 | — | ✓ | `RoundTripKnownDefects.DISABLED_Ascii{Integer,Double,Complex}MaxdimRoundTrips` + tripwires `RoundTripKnownDefectsDeath.AsciiIntegerMaxdimCurrentlyAborts`, `RoundTripKnownDefects.Ascii{Double,Complex}MaxdimCurrentlyCorrupts` | **xfail** | +| `al_read_data` addresses a field relative to the passed context, or from the dataobject root with a prepended `/` | :244-263; al_lowlevel.h:390-413 | — | ✓ (HDF5) | `Hdf5AbsoluteFieldPaths.*` — both spellings of one address are read back and compared, at nonzero indices on both levels of a nested AOS (`time_slice` → `constraints/j_phi`), for a scalar leaf, an array leaf, ancestor targets (only the ancestor's cursor retained), root targets (no index applied) and the operation context itself; plus the plugin twin (`…AgreesThroughPluginEntryPoint`), cache/iteration stability and an absent-data control. HDF5 used to prepend the current AOS context's tensorized path to an already-rooted name, so every absolute read returned `EMPTY_*` with `code == 0` — stored data reported as absent (issue #65); the resolution rule is recorded in `docs/adr/0002-absolute-field-path-resolution.md`. Memory·ASCII·Flexbuffers·MDSplus·UDA: not joined to this fixture — terminal gap, the diagnosis and fix are HDF5-only (issue #65 scope) and each remaining backend resolves field paths in its own layout | covered | +| `al_delete_data` at signal / structure / DATAOBJECT-root granularity | :265-272 | ✓ (Memory) | ✓ (HDF5) | Support is still uneven per backend (see xfail rows below), but HDF5 now implements all three: `Backends/DeleteMatrix.{LeafDeleteRemovesJustTheLeaf,StructureDeleteRemovesWholeSubtree}/{HDF5,Memory}`, `…/RootDeleteRemovesWholeOccurrence/HDF5`. HDF5 used to ignore `path` and widen every call to the whole occurrence — fixed in issue #63, whose HDF5-layout-specific consequences (which files and master-file links survive which granularity, and that a same-prefix sibling is not collateral) are pinned separately in `Hdf5Delete.*`, since they are invisible to this backend-agnostic matrix. The decided meaning of `path` and the per-backend matrix are recorded in `docs/adr/0001-al-delete-data-path-semantics.md`. MDSplus: not joined to this fixture — terminal gap, see Part 4 (issue #18). UDA: remote delete is cleanly refused (the reference plugin has no `deleteData` handler); remote write over the same path falsely reports success instead of the same refusal — xfail, see Part 5 | covered | +| ↳ HDF5 honors `path`: a non-empty path deletes that node and its subtree only, an empty path the whole DATAOBJECT (issue #63 — it previously widened every call to the occurrence and left `master.h5` holding a dangling external link) | :265-272; src/hdf5/hdf5_writer.cpp `deleteData` / `deleteSubtree` / `deleteOccurrence` | — | ✓ | `Hdf5Delete.{LeafDeleteKeepsOccurrenceFileAndMasterLink,RootDeleteRemovesOccurrenceFileAndMasterLink,SamePrefixSiblingsSurviveASubtreeDelete,DeletingAnAbsentPathChangesNothing,ManySuccessiveLeafDeletesAllTakeEffect}` — the last one covers the HLI's actual usage (imas-python `delete_children()` issues one delete per DD leaf), which the old code silently no-op'd after the first call closed the IDS group | covered | +| ↳ Memory has no code path for DATAOBJECT-root delete — **a root-granularity delete is silently a no-op** | :265-272; src/memory_backend.cpp deleteData (no root/occurrence case) | ✓ | — | `DeleteKnownDefects.DISABLED_MemoryRootDeleteClearsWholeIds` + tripwire `DeleteKnownDefects.MemoryRootDeleteCurrentlyDoesNothing`; matrix cell skips via `Backends/DeleteMatrix.RootDeleteRemovesWholeOccurrence/Memory` | **xfail** | +| ↳ ASCII's `deleteData` is a literal no-op (empty body) at every granularity; Flexbuffers' is too but is not independently ABI-observable (it already refuses every in-session read regardless of delete, so a read-based oracle can't distinguish "delete did nothing" from "this backend never round-trips reads") | :265-272; src/ascii_backend.cpp:648-652, src/flexbuffers_backend.cpp:387-389 | ✓ | ✓ | One tripwire covers every granularity on ASCII, since an empty function body has no path-dependent branch to differ: `DeleteKnownDefects.DISABLED_AsciiDeleteRemovesLeaf` + tripwire `DeleteKnownDefects.AsciiDeleteIsCurrentlyANoOp`; matrix cells skip via `Backends/DeleteMatrix.*/ASCII` and `…/Flexbuffers` | **xfail** | +| `al_iterate_over_arraystruct` advances the AOS cursor | :274-282 | ✓ (Memory) | ✓ (HDF5) | Exercised as an asserted dependency of every AOS write/read in `Backends/AosMatrix.*` (top-level and nested, multi-step) | covered (indirect) | +| `al_get_occurrences` lists non-empty occurrences; caller-frees `*occurrences_list` (verified `malloc`-based on both implementing backends) | :284-292 | ✓ (Memory) | ✓ (HDF5, ASCII) | HDF5 and ASCII implement it for real, each against its own occurrence-naming convention (neither transforms `dataobjectname`, so the caller supplies the backend-native form — HDF5: `"_"`; ASCII: `"/"`): `Occurrences.{Hdf5,Ascii}ListsWrittenOccurrences`. Memory/Flexbuffers refuse unconditionally (not implemented): `Occurrences.{Memory,Flexbuffers}Refuses`. MDSplus: also real, same `/` convention — see Part 4 (`Occurrences.MdsplusListsWrittenOccurrences`). UDA: also real over remote mode, HDF5's `_` convention — see Part 5 | covered | +| `al_list_filled_paths` lists filled leaf paths; **caller frees list + strings** | :294-314 | ✓ (Memory) | ✓ (HDF5) | HDF5 positive (leaves discoverable, list freed) / Memory·ASCII·Flexbuffers refused `BACKEND_ERR`: `CapabilityMatrix.ListFilledPathsPositiveOrRefused`. MDSplus: refused `BACKEND_ERR` too, same as Memory·ASCII·Flexbuffers — see Part 4. UDA: real over remote mode, restricted to `backend=hdf5` — see Part 5 | covered | + +### Equilibrium seed (issue #4 — decision D5) + +The deterministic, in-repo single-version equilibrium-seed generator (a +scalar, a timebase-carrying 2-D array, and a constraints AOS — no committed +binary blobs; oracle = a content hash recomputed from the generator functions +themselves, `fixtures/equilibrium_seed.h` — moved outside tests/contract/ by +issue #51 so a future benchmarks/ target can link it directly), round-tripped +on the three +backends where AOS content is actually readable back within a session. + +| Capability | Inventory ref | Unit | Integ. | Test(s) | Status | +|---|---|:--:|:--:|---|---| +| Realistic AOS/timebase/HDF5-tensorization shape round-trips exactly | D5 | ✓ (Memory) | ✓ (HDF5, ASCII) | `Backends/EquilibriumSeedMatrix.RoundTripHashMatches/{HDF5,Memory,ASCII}`. Since issue #33 the seed is DD-4.1.1-conformant (real `time_slice`/`profiles_1d`/`constraints` AOS), so **MDSplus and UDA now round-trip it too** (Parts 4/5), no longer skipped. The oracle is a *structural* hash (path/datatype/rank/every-extent/AOS-index/values); `EquilibriumSeedStructuralHash.*` pins that an extent transpose or dropped/reordered AOS element fails | covered | + +## Cluster 3 — Plugin management (`FUNCTIONALITY_INVENTORY.md:318-430`) + +The plugins issue (#7, step 5) — the defect-heavy corner. The register / bind / +unbind / unregister state machine and the `al_setvalue_*` calls are driven +through the C ABI against a real, in-repo loadable plugin +(`test_plugin_fixture.cpp` → `alcontract_plugin.so`, the classic red-green +fixture); every behavior was characterized against the built library, then +classified once (D2). Four defects are pinned as expected-fail with paired +current-behavior tripwires: the two setvalue null-derefs (double + generic, +joining the scaffold's int one), the registered-but-never-bound plugin left +un-destroyed on unregister, and the `dlopen`-failure swallowed assert. The whole +suite is `unit` (in-process registry, no on-disk backend). Readback binding +and `al_write_plugins_metadata`, deferred at the time this paragraph was +written, are now covered — issue #8 extended `test_plugin_fixture.cpp` with +an env-var-gated readback capability and drove a real put→write-metadata→ +unregister→bind_readback_plugins cycle over a Memory-backend pulse (see +their rows below and Part 3). + +| Capability | Inventory ref | Unit | Integ. | Test(s) — and residual | Status | +|---|---|:--:|:--:|---|---| +| `al_register_plugin` / `al_unregister_plugin` lifecycle (register happy path; register-twice throws; unregister-unknown throws; **framework-gated**) | :325-360 | ✓ | — | `PluginTest.{RegisterMakesPluginRegistered, RegisterTwiceReturnsError, UnregisterNeverRegisteredNameReturnsError, UnregisterBoundPluginDestroysIt, RegisterWithFrameworkDisabledReturnsError}` | covered | +| ↳ unregister of a **registered-but-never-bound** plugin must destroy it — **it is left un-destroyed** (`unregisterPlugin` destroy+erase run only inside the `boundPlugins` loop) | :347-354; src/al_lowlevel.cpp:382-431 | ✓ | — | `PluginTest.DISABLED_UnregisterNeverBoundPluginDestroysIt` + tripwire `PluginTest.UnregisterNeverBoundPluginLeavesItRegistered_CurrentBehavior` | **xfail** | +| ↳ `register` on an existing-but-unloadable `.so` must report the **dlopen** failure — the failure is guarded only by an `assert`, stripped under `-DNDEBUG` (misleading downstream "Cannot load symbol create"; would `SIGABRT` in an assert-enabled build) | :355-360; src/al_lowlevel.cpp:350-357 | ✓ | — | `PluginTest.DISABLED_RegisterUnloadableSharedLibReportsDlopenFailure` + tripwire `PluginTest.RegisterUnloadableSharedLibSwallowsAssert_CurrentBehavior` | **xfail** | +| `al_bind_plugin` / `al_unbind_plugin` (bind registered OK; bind-unregistered throws; double-bind throws; unbind-unbound silent no-op) | :362-376 | ✓ | — | `PluginTest.{BindRegisteredPluginSucceeds, BindUnregisteredPluginReturnsError, DoubleBindSamePathReturnsError, UnbindNeverBoundPathIsSilentNoOp}` | covered | +| `al_bind_readback_plugins` / `al_unbind_readback_plugins` (auto-register + auto-bind from stored metadata before a get; auto-unregister after) | :378-389 | ✓ | — | `ReadbackPlugins.BindReadbackPluginsAutoRegistersAndBindsFromStoredMetadata` — `test_plugin_fixture.cpp` extended to opt into readback capability for one path via `AL_CONTRACT_PLUGIN_READBACK_PATH`; the bind is confirmed indirectly (double-bind now errors) since there is no direct `al_is_plugin_bound` ABI | covered | +| `al_is_plugin_registered` boolean query (true/false; framework-gated) | :391-397 | ✓ | — | `PluginTest.{RegisterMakesPluginRegistered, IsPluginRegisteredIsFalseForNeverRegisteredName, IsPluginRegisteredWithFrameworkDisabledReturnsError}` | covered | +| `al_setvalue_parameter_plugin` (generic typed variant) on a registered plugin | :399-420 | ✓ | — | `PluginTest.SetValueGenericOnRegisteredPluginSucceeds` | covered | +| ↳ generic variant on an **unregistered** name must error, not crash | :399-417 | ✓ | — | `KnownDefects.DISABLED_SetValueGenericUnregisteredPluginReturnsError` + tripwire `KnownDefectsDeath.SetValueGenericUnregisteredPluginCurrentlyCrashes` (SIGSEGV) | **xfail** | +| `al_setvalue_int_scalar_parameter_plugin` on a registered plugin reaches `setParameter` with the right value | :399-417 | ✓ | — | `PluginTest.SetValueIntScalarOnRegisteredPluginReachesPlugin` (asserts the value via the plugin's parameter log) | covered | +| ↳ int-scalar variant on an **unregistered** name must error, not crash | :399-417 | ✓ | — | `KnownDefects.DISABLED_SetValueIntScalarUnregisteredPluginReturnsError` + tripwire `KnownDefectsDeath.SetValueIntScalarUnregisteredPluginCurrentlyCrashes` (SIGSEGV) | **xfail** | +| `al_setvalue_double_scalar_parameter_plugin` on a registered plugin reaches `setParameter` with the right value | :399-417 | ✓ | — | `PluginTest.SetValueDoubleScalarOnRegisteredPluginReachesPlugin` | covered | +| ↳ double-scalar variant on an **unregistered** name must error, not crash | :399-417 | ✓ | — | `KnownDefects.DISABLED_SetValueDoubleScalarUnregisteredPluginReturnsError` + tripwire `KnownDefectsDeath.SetValueDoubleScalarUnregisteredPluginCurrentlyCrashes` (SIGSEGV) | **xfail** | +| `al_write_plugins_metadata` (stores bound put-capable plugins' provenance + any readback contribution under `ids_properties/plugins/node`) | :422-430 | ✓ | — | `PluginTest.WritePluginsMetadataStoresBoundPluginProvenance` (Memory backend; verifies stored `path`, `put_operation[]` provenance fields, and an empty `readback[]` for a non-readback-opted-in bind) | covered | + +## Cluster 4 — Introspection / diagnostics (`FUNCTIONALITY_INVENTORY.md:434-459`) (issue #5 — the thin unit tier) + +Pure lookups: no context, no backend, zero DD artifacts (D5/D6). All **intended +contract** (D2) — nothing here is a defect, including the silent `""`. + +| Capability | Inventory ref | Unit | Integ. | Test(s) | Status | +|---|---|:--:|:--:|---|---| +| `const2str(id)` maps a known constant to its symbolic name | :443-444 | ✓ | — | `Introspection.Const2StrMapsKnownConstantsToTheirNames` | covered | +| `const2str` silently returns `""` for an unmapped id (intended) | :451-452 | ✓ | — | `Introspection.Const2StrReturnsEmptyForUnmappedId` | covered | +| `const2str` returns `""` for defined-but-unmapped constants (`TIMERANGE_OP`, `FLEXBUFFERS_BACKEND`) | :75-79, :451-454 | ✓ | — | `Introspection.Const2StrReturnsEmptyForDefinedButUnmappedConstants` | covered | +| `err2str(id)` maps a known error code to its name | :445-446 | ✓ | — | `Introspection.Err2StrMapsKnownErrorCodesToTheirNames` | covered | +| `err2str` silently returns `""` for an unmapped id | :451-452 | ✓ | — | `Introspection.Err2StrReturnsEmptyForUnmappedId` | covered | +| `const2str` / `err2str` are backed by separate maps (two namespaces) | :443-446 | ✓ | — | `Introspection.Const2StrAndErr2StrUseSeparateMaps` | covered | +| `getALVersion()` returns the compiled AL version string | :447 | ✓ | — | `Introspection.GetALVersionReturnsCompiledVersionMacro`, `…IsDottedNumeric` | covered | +| `getDDVersion()` returns the deprecated sentinel `"!!DEPRECATED!!"` (intended) | :455-459 | ✓ | — | `Introspection.GetDDVersionReturnsDeprecatedSentinel` | covered | + +### Version sentinel (issue #5 — decision D5) + +The tripwire: the core stores `ids_properties/version_put/data_dictionary` as +opaque data and interprets no DD version. A current-behavior test Task 3's +version-negotiation work will watch. + +| Capability | Inventory ref | Unit | Integ. | Test(s) | Status | +|---|---|:--:|:--:|---|---| +| `version_put/data_dictionary` round-trips verbatim; writing it does **not** change `getDDVersion()` | CLAUDE.md; :455-459 | ✓ (Memory) | ✓ (HDF5) | `Backends/VersionSentinel.VersionPutRoundTripsOpaquelyAndIsNotInterpreted` | covered | + +--- + +# Part 2 — Backend implementer audience (`FUNCTIONALITY_INVENTORY.md:482-690`) + +Exercised **through the C ABI** (decision D1 forbids linking the C++ `Backend` +classes), so these mirror Part 1 rows from the storage layer's side. Where a +Part-1 op above already drives the backend method, this is "covered (indirect)" +and points at the same test. + +| Cluster / Capability | Inventory ref | Unit | Integ. | Test(s) — and residual | Status | +|---|---|:--:|:--:|---|---| +| **A** `initBackend` factory selects the backend from the URI | :496-514 | ✓ (Memory) | ✓ (HDF5, ASCII) | every always-on backend instantiated via `build_uri` in `RoundTripMatrix`. The unrecognized-backend refusal has an active verdict (issue #47): `BackendFactory.*` (test_backend_factory.cpp) pins the exact C-ABI status for an unknown backend name in a URI (`UNKNOWN_ERR`, all four open modes, no context handed out, no filesystem artifact) and for an unknown positive legacy numeric ID via `al_build_uri_from_legacy_parameters` (`LOWLEVEL_ERR`, no URI synthesized). MDSplus is instantiated too, via its own URI in every Part 4 fixture — see Part 4 | covered | +| **B** `getVersion` (installed vs stored, drift check) | :520-540 | — | ✓ (HDF5) | Tested (issue #36): `Hdf5VersionDrift.*` (test_hdf5_version_drift.cpp) — matching stored version opens; stored `999.0` (major) and `1.999` (minor boundary) are refused with the exact observed status. The mismatched fixture is produced out-of-band by the isolated `hdf5_fixture_tool` (rewrites master.h5's `HDF5_BACKEND_VERSION`); `contract_tests` itself stays HDF5-API-free and asserts only through the C ABI. Empirical nuance the pins record: for HDF5 the generic `LOWLEVEL_ERR` comparison (src/al_lowlevel.cpp) is **shadowed** — `HDF5BackendFactory` accepts exactly "1.0" and `openPulse` throws `BACKEND_ERR` ("No backend writer with version: …") for any other stored value, before the generic check runs; every non-compiled stored version is thereby refused (stricter than the generic rule). **MDSplus: also closed** — see Part 4's version-drift check (issue #16, reworked by #35 to keep the raw-MDSplus mutation in a fixture tool) | covered | +| **B** `openPulse` / `closePulse` | :542-552 | ✓ (Memory) | ✓ (HDF5, ASCII) | via `al_begin_dataentry_action` / `al_close_pulse` in all suites | covered (indirect) | +| **C** `beginAction` / `endAction` | :558-574 | ✓ (Memory) | ✓ (HDF5, ASCII) | via `al_begin_global_action` / `al_end_action` | covered (indirect) | +| **C** `writeData` / `readData` (0=not-found vs 1=success inner convention) | :576-589 | ✓ (Memory) | ✓ (HDF5, ASCII) | via `al_write_data` / `al_read_data` — `RoundTripMatrix` (+ the CHAR-scalar / ASCII-maxdim xfails above). MDSplus: see Part 4's real-DD-path breadth matrix (issue #15) instead of `RoundTripMatrix`. UDA: same — see Part 5's real-DD-path breadth matrix instead of `RoundTripMatrix` | covered | +| **C** `deleteData` | :591-599 | ✓ (Memory) | ✓ (HDF5) | via `al_delete_data` — see Cluster 2 row above and its two remaining xfail rows (Memory root, ASCII/Flexbuffers no-op); HDF5 is covered at all three granularities since issue #63. MDSplus: terminal gap — see Part 4 (issue #18). UDA: remote delete cleanly refused, remote write falsely reports success — xfail, see Part 5 | covered (indirect) | +| **C** `beginArraystructAction` | :601-606 | ✓ (Memory) | ✓ (HDF5) | via `al_begin_arraystruct_action` — see Cluster 2 row above. MDSplus: joined (divergence) plus its own unique struct-aware slice/segment-write surface — see Part 4. UDA: traversal mechanism covered, a dynamic leaf nested inside a struct_array returns the wrong value — xfail, see Part 5 | covered (indirect) | +| **D** `get_occurrences` | :612-616 | — | ✓ | via `al_get_occurrences` — `Occurrences.*`. MDSplus: see Part 4. UDA: see Part 5 | covered (indirect) | +| **D** `list_filled_paths` (per-backend: HDF5 real, others throw) | :618-640 | ✓ (Memory) | ✓ (HDF5) | via `al_list_filled_paths` — `CapabilityMatrix.ListFilledPathsPositiveOrRefused` pins both the HDF5 impl and the unconditional throw on Memory·ASCII·Flexbuffers. MDSplus: throws unconditionally too, same as Memory·ASCII·Flexbuffers — see Part 4. UDA: real over remote mode, restricted to `backend=hdf5` — see Part 5 | covered | +| **E** `supportsTimeDataInterpolation` / `supportsTimeRangeOperation` | :644-690 | ✓ (Memory) | ✓ (HDF5) | asserted through their sole ABI consequence (op accepted vs refused per backend): `CapabilityMatrix.{TimeRange,Slice,SliceWriteBegin}*`. The empirically-corrected fact that **Memory supports slice** (D2 over the issue's assumption) is encoded here. MDSplus: `supportsTimeDataInterpolation` unconditionally `true`, `supportsTimeRangeOperation` unconditionally `false` — see Part 4 | covered (indirect) | +| **E** `initDataInterpolationComponent` (framework-driven) | :644-690 | — | — | Confirmed unconditionally invoked from `Backend::create()` for any backend that supports slice or timerange (src/al_backend.cpp:88-89), i.e. on every relevant `al_begin_dataentry_action` already exercised elsewhere in this suite — but it has no side effect distinguishable through the C ABI beyond enabling the slice/timerange machinery `Hdf5TimeDependent.*`/`CapabilityMatrix.*` already cover. **Terminal gap**: genuinely not separately ABI-observable, not merely untested. Applies identically to MDSplus (also slice-capable) — same terminal gap, not a separate one | gap | + +--- + +# Part 3 — Plugin author audience (`FUNCTIONALITY_INVENTORY.md:708-895`) + +Entirely deferred to the plugins issue (step 5). Listed as explicit gap rows so +the plugins issue has a concrete checklist rather than a blank slate. + +| Cluster / Capability | Inventory ref | Unit | Integ. | Test(s) | Status | +|---|---|:--:|:--:|---|---| +| **1** Provenance metadata (`getName`/`getVersion`/`getCommit`/…) | :739-759 | ✓ | — | Read back from stored metadata: `PluginTest.WritePluginsMetadataStoresBoundPluginProvenance` asserts `name`/`version`/`commit`/`repository` match `ContractTestPlugin`'s provenance; `ReadbackPlugins.*` additionally exercises `getReadbackVersion` being checked for self-consistency against `getVersion` | covered | +| **2** Parameter configuration (`setParameter` / `setParameters`) | :763-780 | ✓ | — | `setParameter` is reached (with the exact value) via `al_setvalue_*` on the registered fixture plugin: `PluginTest.SetValue{IntScalar,DoubleScalar}OnRegisteredPluginReachesPlugin` (asserted through the plugin's parameter log). The User-side unregistered-name crash is xfail'd in Part 1 Cluster 3. `setParameters` (bulk) has no C-ABI seam of its own (only the single-parameter `al_setvalue_*` functions exist) — it is invoked internally as part of `al_bind_readback_plugins` (`ReadbackPlugins.*` exercises the call, with an empty `parameters` string) but a caller cannot address it directly, so a residual "test bulk setParameters directly" is not achievable through the C ABI (D1). **Terminal gap** for the direct-call residual, not deferred | covered (indirect) | +| **3** Action lifecycle & data interception (`begin_*_action`, `read_data`/`write_data`, `node_operation`) | :784-824 | ✓ | — | `al_write_data`/`al_read_data` check `LLplugin::getBoundPlugins` before touching the backend (al_lowlevel.cpp:1685-1693,1721-1731): `PluginTest.BoundPluginInterceptsWriteInsteadOfBackend` proves a bound plugin's inert `write_data()` absorbs a write (the value never reaches the Memory backend, contrasted with an unbound sibling field that round-trips normally); `PluginTest.BoundPluginInterceptsReadInsteadOfBackend` proves the symmetric case for `read_data()`. `node_operation`'s PUT/GET classification is exercised as an asserted dependency of `findPutOperationPlugins`/`findGetOperationPlugins` in the `WritePluginsMetadata`/`ReadbackPlugins` tests. `begin_*_action` hooks on a *regular* bound plugin remain inert-only (`ContractTestPlugin`'s are no-ops) — no test drives a plugin that does real work in them, since none of the existing behaviors need one | covered (indirect) | +| **4** Readback metadata (`getReadback*`) | :827-850 | ✓ | — | `ReadbackPlugins.BindReadbackPluginsAutoRegistersAndBindsFromStoredMetadata` — `test_plugin_fixture.cpp`'s `ContractTestPlugin` extended with `AL_CONTRACT_PLUGIN_READBACK_PATH` to opt a single path into readback capability, self-consistent with its own provenance (so the version-match check in `bind_readback_plugins` passes) | covered | +| **5** Low-level reentry (`al_plugin_*`); `al_plugin_begin_timerange_action` is broken (declaration/definition mismatch) | :854-895 | ✓ | — | `PluginReentry.{WriteDataReentersTheBackendDirectly,ReadDataAlsoReentersTheBackendDirectly}` call `al_plugin_begin_global_action`/`al_plugin_write_data`/`al_plugin_read_data`/`al_plugin_end_action` directly (no plugin object needed — they bypass dispatch straight to the backend) and cross-check against the ordinary `al_write_data`/`al_read_data` path. `al_plugin_begin_timerange_action` is excluded — the reentry bug is tracked separately as an upstream GitHub issue, not re-characterized here | covered | + +--- + +## xfail bookkeeping — every xfail now has a paired tripwire + +The D2 discipline is "correct-contract `DISABLED_` test **plus** a paired +current-behavior tripwire, so the xfail can't rot." All twenty xfail rows +satisfy it — a fix to any underlying defect turns its tripwire red, forcing +whoever fixed it to enable the paired `DISABLED_` correct-contract test: + +| Defect | Correct-contract (`DISABLED_`) | Current-behavior tripwire | +|---|---|---| +| HDF5 CHAR scalar crashes | `RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips` | `RoundTripKnownDefectsDeath.Hdf5CharScalarCurrentlyCrashes` | +| ASCII rank-7 INTEGER aborts | `…DISABLED_AsciiIntegerMaxdimRoundTrips` | `RoundTripKnownDefectsDeath.AsciiIntegerMaxdimCurrentlyAborts` | +| ASCII rank-7 DOUBLE corrupts (or crashes) | `…DISABLED_AsciiDoubleMaxdimRoundTrips` | `RoundTripKnownDefectsDeath.AsciiDoubleMaxdimCurrentlyCorrupts` | +| ASCII rank-7 COMPLEX corrupts (or crashes) | `…DISABLED_AsciiComplexMaxdimRoundTrips` | `RoundTripKnownDefectsDeath.AsciiComplexMaxdimCurrentlyCorrupts` | +| `build_uri` can't address FLEXBUFFERS | `…DISABLED_BuildUriSupportsFlexbuffers` | `RoundTripKnownDefects.BuildUriFlexbuffersCurrentlyFails` | +| slice append doesn't accumulate | `Hdf5SliceAppend.DISABLED_AppendedSlicesAllPersist` | `Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior` | +| plugin setvalue (int) unregistered-name crash | `KnownDefects.DISABLED_SetValueIntScalarUnregisteredPluginReturnsError` | `KnownDefectsDeath.SetValueIntScalarUnregisteredPluginCurrentlyCrashes` | +| plugin setvalue (double) unregistered-name crash | `KnownDefects.DISABLED_SetValueDoubleScalarUnregisteredPluginReturnsError` | `KnownDefectsDeath.SetValueDoubleScalarUnregisteredPluginCurrentlyCrashes` | +| plugin setvalue (generic) unregistered-name crash | `KnownDefects.DISABLED_SetValueGenericUnregisteredPluginReturnsError` | `KnownDefectsDeath.SetValueGenericUnregisteredPluginCurrentlyCrashes` | +| unregister leaves a never-bound plugin registered | `PluginTest.DISABLED_UnregisterNeverBoundPluginDestroysIt` | `PluginTest.UnregisterNeverBoundPluginLeavesItRegistered_CurrentBehavior` | +| `register` swallows the dlopen failure (NDEBUG-stripped assert) | `PluginTest.DISABLED_RegisterUnloadableSharedLibReportsDlopenFailure` | `PluginTest.RegisterUnloadableSharedLibSwallowsAssert_CurrentBehavior` | +| ASCII `CREATE_PULSE` overwrites instead of refusing | `ModeKnownDefects.DISABLED_AsciiCreatePulseFailsWhenAlreadyExists` | `ModeKnownDefects.AsciiCreatePulseCurrentlyOverwritesSilently` | +| `ERASE_PULSE` == `CLOSE_PULSE` on HDF5 | `ErasePulseKnownDefects.DISABLED_Hdf5EraseMakesPulseUnopenable` | `ErasePulseKnownDefects.Hdf5EraseCurrentlyLeavesPulseOpenable` | +| `ERASE_PULSE` == `CLOSE_PULSE` on ASCII | `…DISABLED_AsciiEraseMakesPulseUnopenable` | `ErasePulseKnownDefects.AsciiEraseCurrentlyLeavesPulseOpenable` | +| `ERASE_PULSE` == `CLOSE_PULSE` on Memory | `…DISABLED_MemoryEraseMakesPulseUnopenable` | `ErasePulseKnownDefects.MemoryEraseCurrentlyLeavesPulseOpenable` | +| `al_get_backendID` accepts a non-pulse context via unchecked `static_cast` | `GetBackendIdKnownDefects.DISABLED_WrongContextTypeReturnsError` | `GetBackendIdKnownDefects.WrongContextTypeCurrentlySucceedsViaUnsafeCast` | +| ASCII AOS read always reports size 0 | `AosKnownDefects.DISABLED_AsciiAosReadReportsWrittenSize` | `AosKnownDefects.AsciiAosReadCurrentlyReportsZero` | +| Memory root delete is a no-op | `DeleteKnownDefects.DISABLED_MemoryRootDeleteClearsWholeIds` | `DeleteKnownDefects.MemoryRootDeleteCurrentlyDoesNothing` | +| ASCII delete is a no-op at every granularity | `DeleteKnownDefects.DISABLED_AsciiDeleteRemovesLeaf` | `DeleteKnownDefects.AsciiDeleteIsCurrentlyANoOp` | + +--- + +# Part 4 — MDSplus backend (compile-guarded tier) (issue #12) + +**Progress summary (issue #18): zero `gap` rows remain in Part 4 — every one +of its 18 rows is terminal (12 `covered`/`covered (indirect)`, 3 +`divergence`, 1 `xfail`, 2 `terminal-gap`).** The one capability with no +MDSplus row at all as of issue #17 — `al_delete_data` — is closed here as an +explicit `terminal-gap` row (see the `DeleteMatrix` row below) rather than +left silently absent; every other row already carried a terminal verdict. +The corresponding shared always-on rows in Part 1/Part 2 above now each +carry a one-line "MDSplus: see Part 4" note. + +**Progress: the MDSplus-unique surface landed (issue #17), closing Part 4's +last deferred row, on top of the version-drift check (issue #16), the +real-DD-path datatype × rank breadth (issue #15), the parity sweep (issue +#14), and the tracer bullet (issue #13).** MDSplus is a parameter/case in all +five fixtures issue #14 named — `DataEntryModes`, `CapabilityMatrix`, +`Occurrences`, `AosMatrix`, `EquilibriumSeedMatrix` — with every resulting row +triaged to a terminal status (`covered` or `divergence`; no genuine defect +surfaced, so no `xfail` this round). Issue #15 adds a dedicated real-DD-path +breadth matrix (`test_mdsplus_real_paths.cpp`, `Mdsplus/MdsplusRealPathMatrix.*`) +covering every `{CHAR, INTEGER, DOUBLE, COMPLEX} x rank {0..7}` cell the DD +actually contains — the one parity area `RoundTripMatrix` structurally cannot +join MDSplus for (its opaque synthetic paths have no model-tree node). Issue +#16 adds the version-drift check (`test_mdsplus_version_drift.cpp`, +`MdsplusVersionDrift.*`), pinning both the matching-version and +mismatched-version cases through the public C ABI. Issue #17 adds the +MDSplus-unique surface (`test_mdsplus_unique_surface.cpp`) — struct-aware +slice interpolation (`covered`), timerange resampling (`covered`, but with no +unique surface — see below), segment-backed dynamic writes (`covered`, plus +one genuine `xfail` crash defect it surfaced), and the timebase cache +(`covered`) — see its own subsection below for details. + +**Finding (issue #16): this closes Part 2 row B's "gap" for MDSplus, but not +for HDF5.** Part 2 row B left the version-drift check a `gap` because forcing +the mismatch needs writing a different backend-version value into the opened +pulse after creation — for HDF5 that means poking its on-disk backend-version +attribute directly via the HDF5 C API, which that suite originally had no +include/link wiring for. MDSplus hits the identical shape of problem — the +mismatch needs overwriting the pulse's stored `VERSION:BACK_MAJOR` tree node +after creation, also unreachable through the C ABI. Both are now solved with +the same producer/observer split: all raw-API fixture mutation lives in +standalone, build-gated setup tools (`mdsplus_fixture_tool` for the tree +node, issue #35; `hdf5_fixture_tool` for the HDF5 attribute, issue #36) that +the tests invoke as subprocesses, so `contract_tests` itself carries no +MDSplus or HDF5 API dependency and every asserted behavior stays on the +public C ABI. **Part 2 row B is closed for HDF5 too** (see Part 2). + +Build-gated by `AL_CONTRACT_HAVE_MDSPLUS` (`tests/contract/CMakeLists.txt`, +defined only when `AL_BACKEND_MDSPLUS=ON`), so every MDSplus case in +`test_mdsplus.cpp`, `test_mdsplus_real_paths.cpp`, `test_mdsplus_version_drift.cpp`, +`test_mdsplus_unique_surface.cpp`, `test_pulse_lifecycle.cpp`, +`test_structured_data.cpp`, and `test_capability_gated.cpp` compiles out +entirely otherwise. Runtime-skipped (`GTEST_SKIP()`, never failed, per D4) +when `MDSPLUS_MODELS_PATH` is unset. CTest label `mdsplus` (`ctest -L mdsplus`) +covers the whole parity set plus the breadth matrix, the version-drift check, +and the unique surface (issue #16 extended the label's `TEST_FILTER` +carve-out in `CMakeLists.txt` with `MdsplusVersionDrift*`, and added a +test-only `find_package(MDSplus)` + include/link wiring onto `contract_tests`, +gated behind the same `AL_BACKEND_MDSPLUS` option; issue #17 extends the same +carve-out with `MdsplusStructAwareSliceInterpolation*`, +`MdsplusTimerangeResampling*`, `MdsplusSegmentBackedWrites*`, +`MdsplusSliceWriteKnownDefects*`, `MdsplusSliceWriteKnownDefectsDeath*`, and +`MdsplusTimebaseCache*` -- no new build plumbing needed, since every case in +the file, including its crash-class death test, goes through the public C +ABI only and adds no raw MDSplus C++ API dependency). +Characterization environment: `docker/mdsplus/` (aarch64, DD 4.1.1 baked model +tree — see its README for the characterization-discovered deviation from the +issue's Ubuntu 22.04 starting point). + +## Characterization-discovered facts (issue #14) + +Resolved empirically against the real baked DD-4.1.1 model tree (issue #12's +explicit open items), not assumed: + +- **Occurrence-naming convention**: a *third* convention, distinct from HDF5's + `_` and ASCII's `/` — except it turns out to be the exact + same slash form as ASCII, `/`. `mdsconvertPath`'s `SEPARATORS` + (`src/mdsplus/mdsplus_backend.cpp:25`) tokenizes on `/`, so + `"equilibrium/2"` resolves to the real, pre-baked structural child node the + model tree already contains for occurrence 2 — occurrence slots are static + model structure, never dynamically created. Confirmed: + `Occurrences.MdsplusListsWrittenOccurrences` writes occurrence 0 and + occurrence 2 (as `"equilibrium/2"`) and gets both back from + `al_get_occurrences`. +- **`get_occurrences` is real** (`src/mdsplus/mdsplus_backend.cpp:4887-4954`): + it walks the IDS node's numeric-named children and reports an occurrence as + "filled" iff its `ids_properties/homogeneous_time` has nonzero length — so a + write that sets only a data field, without `homogeneous_time`, is invisible + to `get_occurrences` even though the data is genuinely there. +- **`list_filled_paths` is NOT real**: it throws unconditionally + (`src/mdsplus/mdsplus_backend.cpp:4956-4958`, "not implemented in the + MDSplus Backend") — `BACKEND_ERR`, matching every other non-HDF5 always-on + backend. Confirmed via `CapabilityMatrix.ListFilledPathsPositiveOrRefused/Mdsplus`. +- **`supportsTimeDataInterpolation()` is unconditionally `true`** + (`src/mdsplus/mdsplus_backend.h:255-257`) and slice read/write genuinely + round-trip (confirmed: a `CLOSEST_INTERP` slice read returns the exact + written value). **`supportsTimeRangeOperation()` is unconditionally `false`** + (`src/mdsplus/mdsplus_backend.cpp:5444-5446`) → `LOWLEVEL_ERR`, matching + Memory/ASCII/Flexbuffers. +- **MDSplus requires real, DD-conformant paths for anything beyond a plain + scalar** (issue #12 Q2's premise, now with concrete evidence): a synthetic + field name with no corresponding model-tree node throws `%TREE-W-NNF, Node + Not Found` — immediately for a plain (non-timed) `al_write_data`, but + **deferred to `al_end_action`** for a timed write or an AOS flush (the value + is buffered in an in-memory `MDSplus::Apd`/segment first). This is why + `AosMatrix`'s and `EquilibriumSeedMatrix`'s synthetic-path bodies fail + MDSplus at flush time even though their early write calls report success — + see the divergence rows below. A real DD-conformant AOS + (`equilibrium/time_slice/global_quantities/ip`, confirmed separately during + characterization) round-trips through the identical begin/write/iterate/end + sequence without error, so this is specific to non-conformant paths, not a + general MDSplus AOS Limit. + +| Cluster / Capability | Test(s) | Status | +|---|---|---| +| Tracer bullet: one equilibrium scalar (`vacuum_toroidal_field/r0`, reusing `equilibrium_seed.h`) write→read self-consistency round trip through the C ABI against a real MDSplus tree | `MdsplusTracerBullet.ScalarSurvivesWriteThenRead` | covered | +| Parity: `DataEntryModes` mode matrix (`OPEN_PULSE` absent/present, `FORCE_OPEN_PULSE`, `CREATE_PULSE` guard) — `openPulse`'s mode switch guards `CREATE_PULSE` against an existing `ids_001.tree` exactly like HDF5/Memory (`src/mdsplus/mdsplus_backend.cpp:4507-4510`); no ASCII-style defect | `Backends/DataEntryModes.*/Mdsplus` (4 cases) | covered | +| Parity: `CapabilityMatrix` — slice read/write positive (real interpolation round trip), timerange refused (`LOWLEVEL_ERR`), `list_filled_paths` refused (`BACKEND_ERR`) | `Backends/CapabilityMatrix.{TimeRangeReadPositiveOrRefused,SliceReadPositiveOrRefused,SliceWriteBeginPositiveOrRefused,ListFilledPathsPositiveOrRefused}/Mdsplus` (4 cases) | covered | +| Parity: `Occurrences` — real implementation, `/` naming convention (see characterization facts above) | `Occurrences.MdsplusListsWrittenOccurrences` | covered | +| Parity: `AosMatrix` (top-level + nested write→iterate→read) — **divergence, not a defect**: MDSplus resolves every path against its real DD-baked model tree, so this fixture's synthetic field names (`"elements"`/`"val"`, `"outer"`/`"inner"`) have no corresponding node; `al_begin_arraystruct_action` and the per-element writes report success (buffered), but the flush at `al_end_action` throws `%TREE-W-NNF, Node Not Found`. Real DD-conformant AOS paths round-trip through the identical sequence (see characterization facts above) — the fixture's synthetic-path design simply cannot transfer to a model-tree-backed backend, the same reason `RoundTripMatrix` already excludes MDSplus (issue #12 Q5) | `Backends/AosMatrix.{TopLevelWriteIterateRead,NestedWriteIterateRead}/Mdsplus` (both `GTEST_SKIP()`, `AosExpect::Divergence`) | divergence | +| Parity: `EquilibriumSeedMatrix` (composite scalar + top-level timebase array + real `time_slice`/`profiles_1d`/`constraints` AOS, structural-hash oracle) — **covered (issue #33), replacing the former divergence-and-skip.** The seed is now DD-4.1.1-conformant: `vacuum_toroidal_field/r0` (FLT_0D), top-level `time` (FLT_1D), and a real `time_slice` struct_array whose elements carry `time`, `profiles_1d/psi` (FLT_1D), `global_quantities/ip`, and a real `constraints/ip/{measured,weight}` subtree — every path validated against the baked-from `IDSDef.xml`. MDSplus resolves them against its DD-baked model tree and round-trips the whole composite through the same real-AOS write/read idiom `test_mdsplus_unique_surface.cpp` already proves. The oracle is a *structural* hash (path identity + datatype + rank + every extent + AOS size/index + values), and the test asserts exact rank/extents field-by-field before hashing, so an extent transpose or a dropped/reordered AOS element fails (pinned hermetically by `EquilibriumSeedStructuralHash.*`) | `Backends/EquilibriumSeedMatrix.RoundTripHashMatches/Mdsplus` | covered | +| Parity: `DeleteMatrix` (leaf / structure / DATAOBJECT-root granularity) — **characterized on real DD-4.1.1 paths (issue #34), replacing the former terminal gap.** `test_mdsplus_delete.cpp` seeds real `equilibrium/code/*` STR_0D leaves plus an unrelated `vacuum_toroidal_field/r0` sibling through the public C ABI, deletes at each granularity, reopens, and asserts exactly what survives — the same real-path approach `MdsplusRealPathMatrix` (issue #15) used to close the RoundTrip gap. Verdicts differ by granularity: **leaf = covered** (`al_delete_data("code/name")` clears just that leaf; every sibling keeps its value); **structure and root = xfail** — `al_delete_data("code")` and `al_delete_data("")` both throw `%TREE-W-NNF, Node Not Found` because `MDSplusBackend::deleteData`'s STRUCTURE branch (`mdsplus_backend.cpp:2147`) looks up the `:static`/`.timed_aos` array-of-structures storage children, which a plain (non-AOS) structure and the DATAOBJECT root have no node for; the delete is a no-op and every seeded path survives. Both xfails are pinned per D2 (`DISABLED_StructureDeleteRemovesWholeSubtree` / `DISABLED_RootDeleteRemovesWholeOccurrence` correct-contract tests, confirmed red today, + active `…CurrentlyThrowsNodeNotFound` tripwires) | `MdsplusRealPathDelete.{LeafDeleteRemovesJustTheLeaf,StructureDeleteCurrentlyThrowsNodeNotFound,RootDeleteCurrentlyThrowsNodeNotFound}` (+ 2 `DISABLED_` correct-contract) | covered (leaf) / xfail (structure, root) | + +### Real-DD-path datatype × rank breadth (issue #15) + +Where the rows above join MDSplus to the *existing* fixtures' synthetic +shapes, this matrix instead curates one **real** DD-4.1.1 path per +`{CHAR, INTEGER, DOUBLE, COMPLEX} x rank {0..7}` cell — the axis +`RoundTripMatrix` already sweeps on the always-on tier, but against opaque +paths MDSplus cannot resolve (issue #12 Q2/Q5). The curated set is the shared +`al_contract::real_dd::catalog()` (`real_dd_path_catalog.{h,cpp}`, issue #46): +the IDS, struct_array chain, leaf, and terminal-gap DD facts are identical for +MDSplus and UDA and live in exactly one place, so the two matrices cannot +silently diverge; only each backend's own storage-model verdicts stay local +(`RealDdPathCatalog.*` plus the per-backend `*RealPathCatalog.CoversEveryCatalogKeyOnce` +meta-tests fail if a matrix drops a cell). Path curation method: the +`imas-dd` MCP tool CLAUDE.md names was not present in this session's MCP +configuration, so the same question it would answer was resolved by walking +the actual baked-from artifact directly — +`build-mdsplus/_deps/data-dictionary-src/IDSDef.xml` (the DD-4.1.1 source +`ALBuildDataDictionary.cmake` downloads and the model tree is compiled from) +— which is still empirical characterization against the real shipped +artifact (D2), just queried by hand instead of through the MCP wrapper. The +18 covered cells collectively span 10 distinct real IDSs (`equilibrium`, +`b_field_non_axisymmetric`, `amns_data`, `magnetics`, `temporary`, +`balance_of_plant`, `bolometer`, `gyrokinetics_local`, `waves`, +`runaway_electrons` — some, like `gyrokinetics_local`, backing more than one +cell), confirming the model tree's whole-DD bake, not one container. + +**Characterization-discovered facts:** + +- **Five (type, rank) cells exist in the DD only inside a struct_array** — + INTEGER rank 3, DOUBLE rank 6, and COMPLEX ranks 1/3/5. Each is reachable + through the identical `al_begin_arraystruct_action` idiom `AosMatrix` + already proved round-trips against MDSplus for real DD-conformant paths (a + single element is written, no breadth beyond one element — AOS breadth + itself stays `AosMatrix`/#14's job): INTEGER r3 and DOUBLE r6 use the DD's + own generic self-test IDS, `temporary` (`constant_integer3d/value`, + `constant_float6d/value`, one AOS level); COMPLEX r3/r5 use + `runaway_electrons` (`distribution/markers/orbit_integrals[_instant]/values`, + one AOS level, since `distribution` itself is a plain structure, not an + AOS); COMPLEX r1 is the deepest real case found anywhere in the whole DD — + `waves`' `coherent_wave/full_wave/e_field/plus/values` needs **three** + nested AOS levels (`coherent_wave`, `full_wave`, `e_field/plus`) — and + round-trips through the same idiom generalized to three descents. All five + passed as plain round trips; no genuine defect surfaced from the AOS + descent itself. +- **CHAR's rank axis is one off from the DD's own `STR_ND` suffix**: the + C-ABI's `dim` parameter for CHAR_DATA models a *string* at dim=1 (DD + `STR_0D`) and an *array of strings* at dim=2 (DD `STR_1D`) — dim=0 (a bare + scalar char) has no DD analogue at all. Confirmed empirically by CHAR r0's + divergence below. +- **CHAR r0 against a real STR_0D node is a divergence, not a crash**: + writing a dim=0 (bare scalar char) value to `equilibrium`'s `code/name` + (a real STR_0D node) and reading it back at dim=0 fails with "Wrong + dimension of Data returned by backend: expected CHAR_DATA in 0D but got + CHAR_DATA in 1D" — MDSplus's real text node is inherently string-shaped + (1-D), so it correctly refuses the mismatched rank instead of silently + misreading it. This contrasts with HDF5's genuine CHAR-scalar crash + (`RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips`): MDSplus's + behavior is a clean, typed refusal against a real DD node, and RoundTripMatrix's + synthetic "dim=0 CHAR" shape simply has no DD counterpart to transfer to — + the same divergence pattern as `AosMatrix`/`EquilibriumSeedMatrix` above, + just surfacing on a single scalar cell instead of a whole fixture. +- **Thirteen (type, rank) cells are terminal-gap — the DD contains no field + of that type at that rank, at any nesting depth, anywhere in DD 4.1.1**: + CHAR ranks 3–7 (only `STR_0D`/`STR_1D` exist), INTEGER ranks 4–7 (max is + `INT_3D`), DOUBLE rank 7 (max is `FLT_6D`), COMPLEX rank 0 (no scalar + complex type at all) and ranks 6–7 (max is `CPX_5D`). Each cell's own test + instance records this via `GTEST_SKIP()` with the specific empirical + reason, rather than being silently absent from the matrix. + +| Cluster / Capability | Test(s) | Status | +|---|---|---| +| Real-path breadth: 13 (type, rank) cells with a plain leaf field (no AOS needed) round-trip against real DD-4.1.1 paths spanning `equilibrium`, `b_field_non_axisymmetric`, `amns_data`, `magnetics`, `balance_of_plant`, `bolometer`, `gyrokinetics_local` — CHAR r1/r2, INTEGER r0–r2, DOUBLE r0–r5, COMPLEX r2/r4 | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/{CHAR_r1,CHAR_r2,INTEGER_r0,INTEGER_r1,INTEGER_r2,DOUBLE_r0..DOUBLE_r5,COMPLEX_r2,COMPLEX_r4}` (13 cases; the fixture curates 5 more AOS-nested cells separately below, 18 covered in total) | covered | +| Real-path breadth: 5 (type, rank) cells reachable only inside a struct_array — INTEGER r3, DOUBLE r6 (`temporary`, 1 AOS level), COMPLEX r3/r5 (`runaway_electrons`, 1 AOS level), COMPLEX r1 (`waves`, 3 nested AOS levels, the deepest real case in the whole DD) — round-trip via the same `al_begin_arraystruct_action` idiom `AosMatrix` already proved against MDSplus | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/{INTEGER_r3,DOUBLE_r6,COMPLEX_r1,COMPLEX_r3,COMPLEX_r5}` | covered | +| ↳ CHAR r0 (a bare scalar char) against a real STR_0D node (`equilibrium`'s `code/name`) — **divergence, not a defect**: MDSplus's real text node is inherently string-shaped (1-D) and correctly refuses a dim=0 read-back ("expected CHAR_DATA in 0D but got CHAR_DATA in 1D") instead of silently misreading it; contrast HDF5's genuine crash on the same synthetic shape (see characterization facts above) | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/CHAR_r0` (`GTEST_SKIP()`) | divergence | +| ↳ 13 (type, rank) cells the DD contains nowhere at any nesting depth: CHAR r3–r7, INTEGER r4–r7, DOUBLE r7, COMPLEX r0, COMPLEX r6–r7 (see characterization facts above for the per-type ceiling each hits) | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/{CHAR_r3..CHAR_r7,INTEGER_r4..INTEGER_r7,DOUBLE_r7,COMPLEX_r0,COMPLEX_r6,COMPLEX_r7}` (13 cases, each `GTEST_SKIP()` with its specific reason) | terminal-gap | +| Unique: struct-aware slice interpolation, timerange resampling, segments, timebase cache | see the "MDSplus-unique surface" subsection below | covered / xfail (issue #17) | + +### MDSplus-unique surface (issue #17) + +`test_mdsplus_unique_surface.cpp` — behavior with no HDF5-tier analogue, +because it depends on MDSplus's real segment-based time-series storage, not +just its real-DD-path model tree. Real path used throughout: +`equilibrium/time_slice` (a `timebasepath="time"` struct_array — each element +carries its own `time` leaf, matching DD 4.1.1's own `coordinate1 +"time_slice(itime)/time"`) and its `global_quantities/ip` leaf, the same path +named as the round-tripping proof in the `AosMatrix`/`EquilibriumSeedMatrix` +divergence notes above and in `test_mdsplus_real_paths.cpp`'s header. + +- **Struct-aware slice interpolation**: `al_begin_arraystruct_action` on a + SLICE_OP context reaches `MDSplusBackend::readSliceApd` + (`mdsplus_backend.cpp:3359`) instead of the plain-leaf `readSlice`; for + `LINEAR_INTERP` it calls `interpolateStruct` (`mdsplus_backend.cpp:3675`), + which interpolates every numeric leaf of the struct, not just one — + confirmed by writing two leaves (`time`, `global_quantities/ip`) inside the + same AOS element and observing both come back correctly interpolated from + one call. HDF5's slice mode has no such capability (it only ever + interpolates a single scalar leaf). +- **Timerange resampling**: no unique MDSplus surface exists. + `supportsTimeRangeOperation()` is unconditionally `false` + (`mdsplus_backend.cpp:5444-5446`), so `al_lowlevel.cpp`'s capability gate + refuses `al_begin_timerange_action` with `LOWLEVEL_ERR` before the backend + is reached at all — already pinned as parity by + `CapabilityMatrix.TimeRangeReadPositiveOrRefused/Mdsplus` + (`test_capability_gated.cpp`). This file re-asserts the same refusal + directly against its own real DD path so the finding is self-contained. +- **Segment-backed dynamic writes**: `MDSplusBackend::beginWriteArraystruct` + dispatches to `writeDynamicApd` (`mdsplus_backend.cpp:2688`) with + `append=true` specifically for a SLICE_OP write against a timebase-carrying + AOS (`mdsplus_backend.cpp:5257,5264,5317,5324`) — genuinely appending one + more element's serialized `MDSplus::Apd` as a new segment, rather than the + whole-container rewrite a GLOBAL_OP write does + (`mdsplus_backend.cpp:5243,5245,5292,5295`). Confirmed by appending five + elements one WRITE session at a time and reading every one back + independently — in contrast to HDF5's own scalar slice-append defect + (`Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior`, + `test_capability_gated.cpp`), MDSplus keeps all of them. One caveat found + empirically: the very first element of a brand-new AOS must still be + established via an ordinary GLOBAL write — appending it via SLICE_OP hits + the same first-segment fragility as the crash defect below (writeDynamicApd + can't create a segment from nothing under `append=true`); once the AOS has + its first segment, further SLICE_OP appends succeed. A narrower shape — a + bare top-level scalar leaf slice-appended against a *separate* external + timebase field, mirroring `Hdf5SliceAppend`'s own idiom verbatim — was tried + first and empirically throws `%TREE-E-NOSEGMENTS` the first time either + field is written with no pre-existing segment in the other; dropped in + favor of the AOS-append form, which is the one this codebase's write + dispatch already treats as the deliberate, first-class append path. Whether + the scalar-leaf shape is a distinct, fixable defect is left unresolved — + out of scope for this pass. +- **New defect found (xfail)**: `MDSplusBackend::writeData`'s SLICE_OP branch + (`mdsplus_backend.cpp:4586-4591`) dereferences `size[0]` unconditionally — + `if(size[0] > 1) writeTimedData(...); else writeSlice(...);` — but the C ABI + passes `size == nullptr` for any dim=0 (scalar) write. Writing **any** + scalar field inside a SLICE_OP write action therefore segfaults MDSplus, + unconditionally — discovered when this file's first draft tried the exact + idiom `Hdf5SliceAppend` uses to seed `homogeneous_time` inside its + slice-write loop (fine on HDF5, crashes on MDSplus). Pinned per D2: + `MdsplusSliceWriteKnownDefects.DISABLED_ScalarWriteWithinSliceActionSucceeds` + (correct contract) + + `MdsplusSliceWriteKnownDefectsDeath.ScalarWriteWithinSliceActionCurrentlyCrashes` + (current-behavior death-test tripwire). +- **Timebase cache**: `MDSplusBackend` caches computed segment/timebase-index + mappings per node (`segmentIdxMap`, `mdsplus_backend.h:283`), populated by + `getSegmentIdxFromSliceIdx` (`mdsplus_backend.cpp:3221`) and reused by every + subsequent `getApdSliceAt` call (e.g. both lookups a single `LINEAR_INTERP` + struct-aware read needs). Every touch site shows it is invalidated + unconditionally (a whole-map clear, not scoped to the written node) at the + *start* of every write dispatch (`writeData`, `mdsplus_backend.cpp:4577`; + `beginWriteArraystruct`, `mdsplus_backend.cpp:4634`) and again at the *end* + of an AOS write (`mdsplus_backend.cpp:5342`) and at `deleteData` + (`mdsplus_backend.cpp:2141`) — so no write path through the C ABI can leave + a stale segment map for a later read. Confirmed by warming the cache with a + slice-interpolated read, forcing an intervening write (an unrelated GLOBAL + scalar write, then a SLICE_OP append growing the very AOS whose segment map + was cached), and showing a repeated slice read afterwards is neither + corrupted by the invalidation nor stale to the new layout. + +| Cluster / Capability | Test(s) | Status | +|---|---|---| +| Struct-aware slice interpolation (CLOSEST/PREVIOUS/LINEAR, whole-struct) | `MdsplusStructAwareSliceInterpolation.{ClosestPicksNearerElement,PreviousPicksLastElementAtOrBelow,LinearInterpolatesWholeStruct}` | covered | +| Timerange resampling — no unique MDSplus surface, refused unconditionally | `MdsplusTimerangeResampling.RefusedWithNoUniqueSurface` | covered | +| Segment-backed dynamic writes — incremental AOS appends all persist | `MdsplusSegmentBackedWrites.AppendedElementsAllPersist` | covered | +| ↳ a dim=0 scalar `al_write_data` inside a SLICE_OP action segfaults MDSplus (`size[0]` dereferenced unconditionally against a `nullptr` size) | `MdsplusSliceWriteKnownDefects.DISABLED_ScalarWriteWithinSliceActionSucceeds` + tripwire `MdsplusSliceWriteKnownDefectsDeath.ScalarWriteWithinSliceActionCurrentlyCrashes` | **xfail** | +| Timebase/segment-index cache survives an intervening invalidating write without staleness or corruption | `MdsplusTimebaseCache.SurvivesInterveningWritesWithoutStaleness` | covered | + +### Version-drift check (issue #16) + +`al_begin_dataentry_action` (`src/al_lowlevel.cpp:868-883`) compares +`backend->getVersion(NULL)` (the compiled backend's own version) against +`backend->getVersion(pctx)` (the version stored in the pulse being opened) on +`OPEN_PULSE`/`FORCE_OPEN_PULSE` and throws `LOWLEVEL_ERR` on a mismatch. For +MDSplus, the stored value is `VERSION:BACK_MAJOR`/`BACK_MINOR` +(`src/mdsplus/mdsplus_backend.cpp`'s `saveVersion`, called once at pulse +creation from the compiled `MDSPLUS_BACKEND_MAJOR`/`MINOR` constants, currently +1/1). Forcing the mismatch case needs a value in the pulse different from +what today's build would write — done by the standalone, build-gated +`mdsplus_fixture_tool` (issue #35), which opens the pulse's "ids" tree +through the raw MDSplus C++ API (``) and overwrites +`VERSION:BACK_MAJOR` after creation, mirroring the `ids_path`/`setDataEnv` +environment-variable dance the backend itself uses internally. The test +invokes the tool as a subprocess and asserts exclusively through `al_*` +APIs: `contract_tests` has no direct MDSplus include or link dependency, so +a rewrite satisfying the C ABI is judged by exactly the C ABI. The identical +split now also closes the HDF5 side (Part 2 row B, issue #36). + +| Cluster / Capability | Test(s) | Status | +|---|---|---| +| Matching case: a pulse created and reopened by the same compiled backend carries the version it just wrote, so the drift check's comparison is a no-op and `OPEN_PULSE` succeeds | `MdsplusVersionDrift.MatchingVersionOpensCleanly` | covered | +| Mismatching case: `VERSION:BACK_MAJOR` is overwritten (via the raw MDSplus tree API) to a value the compiled backend can never match; reopening through the C ABI hits `al_lowlevel.cpp`'s `(ver.first != sver.first)` branch and fails with `LOWLEVEL_ERR` (message contains "Compatibility"), never a silent open | `MdsplusVersionDrift.MismatchedBackendVersionRefusesOpen` | covered | + +--- + +# Part 5 — UDA backend (compile-guarded tier) (PRD #21) + +Same shape as Part 4: a compile-guarded characterization tier for a backend the +core has no compile-time coupling to, exercised entirely through the public C +ABI (decision D1). Unlike MDSplus, UDA is a **remote client** to a reference +stack it does not embed (a real `uda_server` running the `IMAS` server plugin, +linked against a server-side IMAS-Core); the tier's tests build and drive that +stack from `docker/uda/`, never asserting on server internals — the subject +under test is always the IMAS-Core UDA client. And unlike MDSplus, UDA is +read-only in remote mode (the reference `IMAS` server plugin has no +`writeData`/`deleteData` handler — confirmed empirically, see +`docker/uda/README.md`), so parity arrives via a seed-then-reopen fixture +(seed through the plain HDF5 backend, reopen the identical path through UDA) +rather than a write→read round trip. + +Build-gated by `AL_CONTRACT_HAVE_UDA` (`tests/contract/CMakeLists.txt`, defined +when `AL_BACKEND_UDA` or `AL_BACKEND_UDAFAT` is `ON`), so every UDA case in +`test_uda.cpp` compiles out entirely otherwise. Runtime-skipped +(`GTEST_SKIP()`, never failed, per D4) when `UDA_HOST` is unset — an ordinary +local build or an always-on CI leg without the reference stack standing behind +it never sees these tests fail. CTest label `uda` (`ctest -L uda`) selects the +whole tier. + +## Pinned reference stack (issue #23's spike, `docker/uda/`) + +| Component | Source | Pin | +|-----------|--------|-----| +| UDA server | [`ukaea/uda`](https://github.com/ukaea/uda) | tag **2.9.3** | +| `IMAS` UDA server plugin | [`iterorganization/UDA-Plugins`](https://github.com/iterorganization/UDA-Plugins) | tag **1.8.0**, commit **`ede25b921081d8fc2d66c5b5ca152c664b50ee78`** | +| Data Dictionary | `imas-data-dictionary` (PyPI wheel) | **4.1.1** | +| Base image | `ubuntu:24.04` | arm64 | + +Gate decision (issue #23): **FULL-STACK TIER CONFIRMED** — the `IMAS` server +plugin is built and registered linked against the workspace's own IMAS-Core +(`docker/uda/run.sh` fails loudly on the mapping-only `NO_IMAS` degradation the +PRD forbids), not a stub. The fallback tier (client-side-only, remote rows +blocked-by-environment) was not invoked. + +## Characterization-discovered facts (issue #24) + +- **UDA validates every remote read path against the DD schema it loads at + startup** (`$IDSDEF_PATH`/`$IMAS_PREFIX/include/IDSDef.xml`, + `src/uda/uda_xml.cpp`), the same shape of constraint MDSplus enforces via its + baked model tree (`CLAUDE.md`, TRACEABILITY.md Part 4) — but arrived at + differently: MDSplus resolves against a compiled binary tree, UDA against the + DD XML text at runtime. A real DD-conformant path (`vacuum_toroidal_field/r0`) + round-trips through the seed-then-reopen fixture; a synthetic/generic one does + not — see the divergence row below. +- **`ids_properties/homogeneous_time` is a hard precondition for any remote + read** (`UDABackend::readData`'s `cache_mode=none` path, + `src/uda/uda_backend.cpp:646-665`, calls `get_homogeneous_flag` before + resolving any other field and throws if it is absent) — both fixtures below + seed it explicitly ahead of the field(s) under test, a fixture-setup detail, + not part of either test's assertion. + +| Capability | Test(s) | Status | +|---|---|---| +| Tracer bullet (issue #23): one equilibrium scalar (`vacuum_toroidal_field/r0`), seeded through the plain HDF5 backend, reads back byte-identical through the UDA backend in remote mode, across the real reference stack | `UdaSmokeRoundTrip.ScalarSeededViaHdf5ReadsBackThroughUda` | covered | +| Read-only parity fixture (issue #24): the full equilibrium-seed composite (static scalar + top-level timebase array + real `time_slice`/`profiles_1d`/`constraints` AOS, `equilibrium_seed.h`, issues #4/#33/D5), seeded through HDF5 and reopened through UDA, asserted via the seed's structural hash oracle — **covered (issue #33), replacing the former divergence.** The seed is now DD-4.1.1-conformant, so it round-trips fully through UDA remote mode: the static scalar, the `time` timebase array, and every dynamic leaf nested in the `time_slice` struct_array (`time`, `profiles_1d/psi`, `global_quantities/ip`, `constraints/ip/{measured,weight}`). This does **not** contradict `UdaAosKnownDefects` (which pins a dynamic-leaf-inside-AOS reading absent): that fixture seeds the AOS with an *empty* timebase and no per-element `time`, whereas this seed writes the real timebasepath (`time`) and a per-element `time`, and a well-formed DD-conformant time_slice AOS is empirically the case UDA resolves correctly. Verified stable across repeated runs of the docker/uda/ reference stack; the structural hash (rank/extents/AOS-index-aware) makes it a trustworthy parity oracle | `UdaEquilibriumSeedParity.FullSeedRoundTripsThroughUda` | covered | + +**Progress (issue #25, parity breadth): zero blank rows remain in Part 5.** +Every C-ABI-reachable read-side capability from `FUNCTIONALITY_INVENTORY.md` +Part 1 now has a UDA verdict at a terminal status. `test_uda_real_paths.cpp` +adds the real-DD-path datatype × rank breadth matrix (issue #15's curated set, +reused as-is, adapted to the seed(HDF5)-then-reopen(UDA) shape); `test_uda_ +breadth.cpp` adds AOS traversal, occurrences, `list_filled_paths`, pulse +open-mode/error-behavior parity, and slice/time-range reads. Two genuine new +defects were discovered and pinned (xfail, D2 correct-contract + tripwire), +not fixed — both explained in full below. + +## Real-DD-path datatype × rank breadth (issue #25, reusing issue #15's set) + +`test_uda_real_paths.cpp`, `Uda/UdaRealPathMatrix.*` — the UDA analogue of +`test_mdsplus_real_paths.cpp`: **the identical curated path/rank/aos_chain set, +now the shared `al_contract::real_dd::catalog()`** (`real_dd_path_catalog.{h,cpp}`, +issue #46 — one source of truth for both matrices, same 32 cells, same 10 +IDSs), adapted to seed(HDF5)-then-reopen(UDA) instead of MDSplus's direct +write→read. `terminal_gap_reason` lives in the shared catalog (a DD-4.1.1 fact, +independent of backend); UDA-only `divergence_reason`/`known_defect_reason` +verdicts stay local to `test_uda_real_paths.cpp` where the two backends diverge +for different reasons on the same cell. `UdaRealPathCatalog.CoversEveryCatalogKeyOnce` +fails if the UDA matrix ever drops or duplicates a catalog cell. + +**Characterization-discovered facts:** + +- **13 terminal-gap cells carry over unchanged** from the MDSplus matrix (CHAR + r3–r7, INTEGER r4–r7, DOUBLE r7, COMPLEX r0, COMPLEX r6–r7) — the DD itself + contains no field of that (type, rank) at any nesting depth, independent of + backend. +- **CHAR r0 is a terminal gap, blocked by fixture setup**: UDA's fixture never + reaches the remote-backend question because seeding this cell requires + writing a dim=0 CHAR value through the *plain HDF5 backend*. That reproduces + the pre-existing, independently pinned + `RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips` / + `RoundTripKnownDefectsDeath.Hdf5CharScalarCurrentlyCrashes` defect + (TRACEABILITY.md Part 1) on this real DD leaf too. The UDA cell is not run, + to avoid crashing before UDA participates; that makes this row an explicit + terminal gap rather than a legitimate storage-model divergence. +- **18 cells round-trip cleanly**, including all 5 AOS-nested cells that are + *not* dynamic leaves (INTEGER r3, DOUBLE r6 via `temporary`'s static + self-test fields) and every plain (non-AOS) cell of every datatype, + including COMPLEX r2/r4 — confirming UDA's remote get() correctly forwards + static, non-AOS, and statically-nested-in-AOS fields alike. +- **NEW DEFECT discovered (xfail): a *dynamic* (time-varying) DD leaf nested + inside a struct_array silently returns empty/absent data through UDA remote + mode + `backend=hdf5`, instead of what was written.** Confirmed empirically + against the reference stack by comparing the request trace's `dynamic_flags` + field across passing and failing cells: `temporary/constant_integer3d/value` + and `temporary/constant_float6d/value` (both AOS-nested, `dynamic_flags=0`) + round-trip cleanly, while `waves/coherent_wave/full_wave/e_field/plus/values` + (COMPLEX r1, 3 AOS levels), `runaway_electrons/distribution/markers/ + orbit_integrals_instant/values` (COMPLEX r3) and `…/orbit_integrals/values` + (COMPLEX r5) — all `dynamic_flags=1` — come back with `read_shape={}` / + zero elements. The same wall independently reproduces on a DOUBLE leaf + outside this matrix, `equilibrium/time_slice/global_quantities/ip` + (`test_uda_breadth.cpp`'s `UdaAosKnownDefects`), which additionally shows + the *scalar* failure mode: a rank-0 dynamic leaf inside an AOS comes back + with `code==0` but the HDF5 "absent" sentinel value (`-9.0e40`) instead of + what was written, rather than an empty array. Root cause is not yet + isolated to a specific line (out of this issue's "pin, don't fix" scope), + but the shape strongly suggests the server-side `IMAS` plugin's HDF5-backend + translation of a struct_array element index does not compose with a + dynamic (time-tensorized) leaf's own leading time-index dimension. Each of + the three COMPLEX cells is pinned against its exact real DD path, rank, and + AOS nesting by the parameterized pair `Uda/UdaRealPathMatrixKnownDefects. + DISABLED_DynamicComplexLeafInsideAosRoundTrips/{COMPLEX_r1,COMPLEX_r3, + COMPLEX_r5}` + tripwires `Uda/UdaRealPathMatrixKnownDefects. + DynamicComplexLeafInsideAosCurrentlyReadsEmpty/{COMPLEX_r1,COMPLEX_r3, + COMPLEX_r5}` (`test_uda_real_paths.cpp`). The DOUBLE-scalar + `UdaAosKnownDefects` pair remains an independent value-transport pin, not a + substitute for these exact matrix cases or the active traversal proof below. + +| Cluster / Capability | Test(s) | Status | +|---|---|---| +| Real-path breadth: 15 (type, rank) cells with a plain leaf field (no AOS) or a *static* AOS-nested field round-trip against real DD-4.1.1 paths spanning `equilibrium`, `b_field_non_axisymmetric`, `amns_data`, `magnetics`, `balance_of_plant`, `bolometer`, `gyrokinetics_local`, `temporary` — CHAR r1/r2, INTEGER r0–r3, DOUBLE r0–r6, COMPLEX r2/r4 | `Uda/UdaRealPathMatrix.RealPathRoundTrip/{CHAR_r1,CHAR_r2,INTEGER_r0..INTEGER_r3,DOUBLE_r0..DOUBLE_r6,COMPLEX_r2,COMPLEX_r4}` (15 cases) | covered | +| ↳ CHAR r0 (a bare scalar char) — the UDA verdict cannot be exercised because fixture setup would reproduce the already-pinned HDF5 CHAR-scalar crash (`RoundTripKnownDefects`/Part 1) before UDA participates | `Uda/UdaRealPathMatrix.RealPathRoundTrip/CHAR_r0` (`GTEST_SKIP()`, not run) | terminal-gap | +| ↳ 13 (type, rank) cells the DD contains nowhere at any nesting depth (same facts as MDSplus's Part 4 breadth matrix): CHAR r3–r7, INTEGER r4–r7, DOUBLE r7, COMPLEX r0, COMPLEX r6–r7 | `Uda/UdaRealPathMatrix.RealPathRoundTrip/{CHAR_r3..CHAR_r7,INTEGER_r4..INTEGER_r7,DOUBLE_r7,COMPLEX_r0,COMPLEX_r6,COMPLEX_r7}` (13 cases, each `GTEST_SKIP()`) | terminal-gap | +| ↳ 3 (type, rank) cells hit the new dynamic-leaf-inside-AOS defect: COMPLEX r1 (`waves`, 3 AOS levels), COMPLEX r3/r5 (`runaway_electrons`, 1 AOS level) | Breadth cells: `Uda/UdaRealPathMatrix.RealPathRoundTrip/{COMPLEX_r1,COMPLEX_r3,COMPLEX_r5}` (`GTEST_SKIP()`, known-defect). Exact correct-contract pair: `Uda/UdaRealPathMatrixKnownDefects.DISABLED_DynamicComplexLeafInsideAosRoundTrips/{COMPLEX_r1,COMPLEX_r3,COMPLEX_r5}` + tripwires `Uda/UdaRealPathMatrixKnownDefects.DynamicComplexLeafInsideAosCurrentlyReadsEmpty/{COMPLEX_r1,COMPLEX_r3,COMPLEX_r5}` | **xfail** | + +## AOS traversal, occurrences, list_filled_paths, pulse modes/errors, slice/time-range (issues #25 + #37) + +`test_uda_breadth.cpp` — every remaining C-ABI-reachable read-side capability +and pulse-lifecycle mode from `FUNCTIONALITY_INVENTORY.md` Part 1 not already +covered by the tracer bullet, the equilibrium-seed parity fixture, or the +real-path matrix above. Read cases use the seed(HDF5)-then-reopen(UDA) shape; +the lifecycle cases drive the remote UDA URI directly through the C ABI. + +**Characterization-discovered facts:** + +- **AOS traversal (top-level) has a discriminating active C-ABI proof**: + `temporary/constant_integer3d` is a real DD-4.1.1 struct_array with a static + `value` leaf known to round-trip through UDA. Three elements with distinct, + order-sensitive values are seeded through HDF5 in physical index order + 0→2→1 (so setup does not mirror the asserted traversal), then read in order + 0→1→2 around successive `al_iterate_over_arraystruct(..., 1)` calls after + reopening through UDA. + `UdaBreadthTest.AosTraversalAdvancesAcrossDistinctStaticElements` asserts the + exact sequence, so a successful no-op, repeated element, or reordered + element fails. It also ends the AOS action and verifies through the same + public function that the invalid context returns an error status. +- **The dynamic-leaf-inside-AOS value defect remains separately pinned**: + `equilibrium/time_slice` correctly reports its written size (3), but every + element's `global_quantities/ip` value comes back as the HDF5 absent-scalar + sentinel (`-9.0e40`) instead of what was written. The canonical D2 pair is + `UdaAosKnownDefects.DISABLED_DynamicLeafInsideAosRoundTrips` + tripwire + `UdaAosKnownDefects.DynamicLeafInsideAosCurrentlyReturnsSentinel`. Nested + (multi-level) traversal is also exercised by the real-path matrix's static + AOS cells (INTEGER r3, DOUBLE r6). +- **`al_get_occurrences` is real through UDA remote mode** (the reference + `IMAS` server plugin implements `getOccurrences`, confirmed via the PRD's + static finding): occurrence 0 and occurrence 2 (HDF5's own `_` + naming convention, seeded via HDF5 exactly like + `Occurrences.Hdf5ListsWrittenOccurrences`) both list correctly through the + UDA client, ownership (`malloc`'d list, caller frees) unchanged. +- **`al_list_filled_paths` is real through UDA remote mode**, restricted to + `backend=hdf5` (`UDABackend::list_filled_paths` throws for any other + backend value — this fixture already uses `backend=hdf5` throughout, so the + restriction is never hit): every seeded leaf (`ids_properties/ + homogeneous_time`, `vacuum_toroidal_field/r0`) is discoverable, ownership + (caller frees the list and every string) unchanged. +- **`OPEN_PULSE` genuinely round-trips to the server at open time** — this + corrects an initial assumption from static reading that UDA's `openPulse` + is purely local URI/env parsing. The debug trace shows `al_begin_ + dataentry_action` itself issuing `IMAS::open(...)`; when the remote pulse + dir does not exist, the server-side HDF5 backend refuses immediately + ("HDF5 master file not found"), which the UDA client re-throws through the + C ABI as a non-zero `al_status_t`. This is genuine **parity** with the + on-disk backends' `DataEntryModes.OpenPulseFailsWhenAbsent` contract + (Part 1) — UDA remote mode does not weaken it. +- **All four open modes are forwarded with their documented lifecycle + distinctions through UDA remote mode.** `FORCE_OPEN_PULSE` creates an absent + remote HDF5 pulse that a later `OPEN_PULSE` can reopen; `CREATE_PULSE` + refuses an existing pulse; and `FORCE_CREATE_PULSE` accepts an existing + pulse. Each lifecycle check closes through `CLOSE_PULSE` and reopens through + the same UDA URI, using only the public C ABI. +- **NEW DEFECT discovered (xfail): `ERASE_PULSE` behaves exactly like + `CLOSE_PULSE` through the reference stack.** `al_close_pulse(..., + ERASE_PULSE)` reports success, but a later `OPEN_PULSE` on the same remote + URI also succeeds. This carries the server-side HDF5 backend's already-known + ignore-the-close-mode defect across the UDA boundary; it is pinned here as + an exact UDA verdict by `UdaBreadthTest. + DISABLED_ErasePulseMakesRemotePulseUnopenable` + tripwire `UdaBreadthTest. + ErasePulseCurrentlyLeavesRemotePulseOpenable`. +- **Reading a real DD leaf that was never written (but whose IDS/occurrence + was seeded) succeeds and returns the HDF5 absent-scalar sentinel** + (`-9.0e40`), exactly matching the local HDF5 backend's own documented + contract (`al_lowlevel.cpp`'s `Lowlevel::setDefaultValue`, the same + sentinel `RoundTripMatrix`'s generator is careful never to *write*, + TRACEABILITY.md Part 1) — transparent parity, not a UDA-specific behavior, + since the remote `get()` forwards straight through to the server-side + HDF5 backend's own read semantics. +- **Reading a real DD IDS that was never written at all (no occurrence ever + created) fails cleanly** — the server-side HDF5 backend has no file for + that IDS at all, distinct from "leaf absent within an existing IDS file" + above. +- **Slice reads round-trip correctly through UDA remote mode**: a real DD + dynamic top-level leaf (`vacuum_toroidal_field/b0`, coordinate1 `time`) with + `CLOSEST_INTERP` at a UDA `al_begin_slice_action` correctly resolves to the + nearer sample — note this leaf is *not* nested inside a struct_array, so it + does not hit the AOS defect above; `supportsTimeDataInterpolation()`'s + server-version gate (`IMAS::version()` 1.8.0 > 1.4.0) already permits it. +- **NEW DEFECT discovered (xfail), distinct from the AOS one above and not + UDA-specific in origin: `al_begin_timerange_action`'s `OperationContext` + constructor (`src/al_context.cpp`, the `(ctx, dataobject, access, range, + tmin, tmax, dtime, interp)` overload) never assigns the base `interpmode` + member — only `time_range.interpolation_method`** — unlike the GLOBAL_OP + and SLICE_OP constructors, which both set it. Every always-on backend's + `readData()` ignores `getInterpmode()` for a TIMERANGE_OP context (they + consult `time_range` instead), so the uninitialized member is silently + never observed on any other backend — until UDA's remote `readData()` + directive-builder (`src/uda/uda_backend.cpp`) calls `op_ctx-> + getInterpmode()` *unconditionally*, regardless of rangemode, to fill the + outgoing directive's `interp=` field. Confirmed empirically: the garbage + value trips `uda_utilities.hpp`'s `InterpMode` convertor's default case, + throwing `std::runtime_error("unknown interp mode: ")`, + surfaced through the C ABI as `al_plugin_read_data: unknown interp mode: + ...`. Pinned: `UdaSliceAndTimeRange. + DISABLED_TimeRangeReadWithoutResamplingThroughReopenSucceeds` + tripwire + `UdaSliceAndTimeRange. + TimeRangeReadWithoutResamplingCurrentlyFailsWithUnknownInterpMode`. + +| Cluster / Capability | Test(s) | Status | +|---|---|---| +| AOS traversal, top-level (reported size + exact three-element forward sequence; invalid ended context returns an error; nested traversal proven separately by the real-path matrix's static AOS cells) | `UdaBreadthTest.AosTraversalAdvancesAcrossDistinctStaticElements` | covered | +| ↳ a *dynamic* leaf nested inside a struct_array returns the HDF5 absent-scalar sentinel instead of what was written | `UdaAosKnownDefects.DISABLED_DynamicLeafInsideAosRoundTrips` + tripwire `UdaAosKnownDefects.DynamicLeafInsideAosCurrentlyReturnsSentinel` | **xfail** | +| `al_get_occurrences` real through UDA remote mode, HDF5's `_` storage naming and UDA's `/` public naming pinned; every reported occurrence is reopened and read | `UdaBreadthTest.OccurrencesListsWrittenOccurrencesThroughReopen` | covered | +| `al_list_filled_paths` real through UDA remote mode (`backend=hdf5` only), ownership pinned | `UdaBreadthTest.ListFilledPathsThroughReopen` | covered | +| `al_begin_dataentry_action` mode matrix: `OPEN_PULSE` refuses an absent remote pulse; `FORCE_OPEN_PULSE` creates one; `CREATE_PULSE` refuses an existing one; `FORCE_CREATE_PULSE` accepts an existing one — genuine parity with the on-disk lifecycle contract | `UdaBreadthTest.{OpenPulseFailsWhenRemotePathAbsent,ForceOpenPulseCreatesRemotePulseWhenAbsent,CreatePulseRefusesExistingRemotePulse,ForceCreatePulseAcceptsExistingRemotePulse}` | covered | +| `al_close_pulse(..., CLOSE_PULSE)` retains the remote pulse for a later `OPEN_PULSE` | asserted by the reopen checks in `UdaBreadthTest.{ForceOpenPulseCreatesRemotePulseWhenAbsent,CreatePulseRefusesExistingRemotePulse,ForceCreatePulseAcceptsExistingRemotePulse}` | covered (indirect) | +| ↳ `al_close_pulse(..., ERASE_PULSE)` reports success but leaves the remote pulse openable, carrying the server-side HDF5 close-mode defect across the UDA boundary | `UdaBreadthTest.DISABLED_ErasePulseMakesRemotePulseUnopenable` + tripwire `UdaBreadthTest.ErasePulseCurrentlyLeavesRemotePulseOpenable` | **xfail** | +| Reading an unwritten leaf on a seeded IDS returns the HDF5 absent-scalar sentinel — transparent parity with the local HDF5 backend's own contract, not UDA-specific | `UdaBreadthTest.ReadOfUnwrittenLeafOnSeededIdsReturnsHdf5AbsentSentinel` | covered | +| Reading a never-written IDS fails cleanly | `UdaBreadthTest.ReadFailsForNeverWrittenIds` | covered | +| Slice read (`CLOSEST_INTERP`) on a real DD dynamic top-level leaf round-trips correctly through UDA remote mode | `UdaSliceAndTimeRange.SliceReadThroughReopen` | covered | +| ↳ time-range read (no resampling) fails with an "unknown interp mode" exception — `OperationContext`'s TIMERANGE_OP constructor never initializes the base `interpmode` member, only observable because UDA's remote directive-builder reads it unconditionally | `UdaSliceAndTimeRange.DISABLED_TimeRangeReadWithoutResamplingThroughReopenSucceeds` + tripwire `UdaSliceAndTimeRange.TimeRangeReadWithoutResamplingCurrentlyFailsWithUnknownInterpMode` | **xfail** | + +## UDA-unique surface (issues #26 + #27): cache modes, runtime DD loading, datapath, capability negotiation, fetch mode, write/delete pins + +`test_uda_unique_surface.cpp` — one dedicated unique-surface file covering all +ten areas, against the reference stack (UDA server 2.9.3 / `IMAS` plugin 1.8.0 +/ DD 4.1.1). A second, older DD version (**3.42.0**, `IDSDEF_PATH_OLDER`) was +added to `docker/uda/Dockerfile` for the wrong-version-DD row below; it is a +real, independently-sourced pin (`imas-data-dictionary==3.42.0` from PyPI), +recorded here alongside the header's existing stack identifiers. + +**Characterization-discovered facts:** + +- **URI options accepted/rejected:** `cache_mode` rejects anything outside + `{none, ids, struct}` at open time, purely client-side, before any network + round trip (`UDABackend::process_options`). `verbose` toggles debug tracing + on stdout (captured directly via `CaptureStdout`, not inferred). `plugin` + naming a name the reference stack never registered fails at open — the + client's `init` directive reaches a real server with no such plugin. + `init_args` is accepted but has **no observable effect** against the + reference `IMAS` plugin: its `init()` handler (`imas_plugin.cpp`, + `handle_request`) takes no arguments at all, only tracking a per-connection + `_init` bool — confirmed by reading the plugin's own dispatch, then + empirically (a read with arbitrary `init_args` behaves identically to one + without). `dd_version` override is forwarded verbatim on every directive but + is never consulted by any backend on either side of the wire (client schema + resolution is driven by the file loaded at `$IDSDEF_PATH`; the server's HDF5 + backend is DD-agnostic) — accepted, no observable effect. A host that + resolves but a port nothing listens on fails to connect, distinct from every + post-connection request failure characterized above. +- **NEW DEFECT discovered (xfail): cache mode changes a physics value for a + top-level field.** `none` and `ids` agree on any field; `ids` populates the + whole (or datapath-scoped) IDS at `beginAction`, while `struct` populates + lazily, per struct_array, only inside `beginArraystructAction`. A static leaf + nested inside a struct_array therefore returns the identical value under all + three modes. A plain top-level field, however, is never populated under + `cache_mode=struct`, so it silently reads back as the absent-leaf sentinel + even though it was genuinely written. That violates issue #26's requirement + that cache modes never alter data. Pinned: + `UdaUniqueSurfaceTest.DISABLED_CacheModeStructPreservesTopLevelField` + + tripwire + `UdaUniqueSurfaceTest.CacheModeStructCurrentlyReturnsAbsentSentinelForTopLevelField`. +- **NEW DEFECT discovered (xfail), found while characterizing "cache cleared + on close": closing a UDA remote-mode session leaks a server-side pulse + handle.** Root cause traced by comparing the exact directives exchanged + (`verbose=1`): `UDABackend::openPulse`/`closePulse` both strip `cache_mode` + and `verbose` before building the `uri=` argument sent to the server, so + `IMAS::open(...)` and `IMAS::close(...)` agree on the same key — but two of + the *read*-triggering directive builders don't follow that convention: + `readData`'s `cache_mode=None` branch and `populate_cache` (used by `ids` + and `struct` alike, both in `src/uda/uda_backend.cpp`) each build their own + `IMAS::get(uri=...)` from the same query object but only ever remove + `"backend"` — never `cache_mode`/`verbose` — so their `uri=` differs from + the one `open`/`close` agreed on. (`beginArraystructAction`'s own `None`-mode + branch does strip both, matching `open`/`close` — this is specifically a + `readData`/`populate_cache` inconsistency, not a blanket "every `get()` path" + one.) The reference `IMAS` plugin's `get()` handler treats an unrecognized + `uri=` as a new pulse and implicit-opens it (`imas_plugin.cpp`), creating a + second, real server-side HDF5 file handle that the client's `close()` + directive can never reach (it only ever sends the stripped key) — leaked for + the lifetime of the server's per-connection process. HDF5's default file + locking then blocks a later *local* open of the identical on-disk file until + that server process exits. Reproduces identically whether the read went + through `readData`'s `None`-mode path or `populate_cache`'s `ids`/`struct`- + mode path (both hit the same unstripped-uri bug), so this is not an + `ids`-specific finding despite living in the cache-mode area. Pinned: + `UdaUniqueSurfaceTest.DISABLED_ClosingUdaSessionReleasesEveryServerSideHandle` + + tripwire `UdaUniqueSurfaceTest.ClosingUdaSessionCurrentlyLeaksServerSideHandle`. +- **Client-cache invalidation across close/reopen is a terminal gap (issue + #38).** No stable public-C-ABI oracle exists against the pinned stack: the + required seed-A/read/close/mutate-to-B/reopen sequence cannot reach its + mutation step because the separately pinned server-handle leak keeps the + per-IDS HDF5 file locked. A fresh fixture process with + `HDF5_USE_FILE_LOCKING=FALSE` still fails to reopen that external IDS file + for writing (observed for `cache_mode=none`); the process therefore cannot + produce B for a new UDA context to observe. The C ABI provides no control to + restart the reference server/connection between those steps, and remote UDA + writes are independently unsupported. Do not treat + `UDABackend::closePulse`'s `cache_.clear()` as a behavioral verdict. The + paired server-handle-leak xfail immediately above remains distinct; when it + is fixed or the stack gains a restart seam, this row must become the + A→B→reopen test for `none`, `ids`, and `struct`. +- **Runtime DD loading — present / absent / wrong-version, all through + `al_begin_dataentry_action`** (the constructor loads `IDSDef.xml` + unconditionally, before any network access): present is the reference + stack's default (DD 4.1.1, restated here as the area-3 baseline). Absent has + two distinct failure messages, matching issue #23's empirical findings: + neither `$IDSDEF_PATH` nor `$IMAS_PREFIX` set → "neither IMAS_PREFIX or + IDSDEF_PATH environmental variable is set"; `$IDSDEF_PATH` set but the file + missing → "IDSDef.xml not found at either $IDSDEF_PATH or + $IMAS_PREFIX/include/IDSDef.xml". **Wrong-version acceptance is a defect**: + pointing `$IDSDEF_PATH` at a real, structurally valid, but older DD (3.42.0) + than the reference stack's own 4.1.1 loads without + complaint — `load_xml()`/`get_dd_version()` never compare the loaded file's + version against anything (not the data's actual DD version, not the + server's). A real path stable across both schemas + (`vacuum_toroidal_field/r0`) round-trips exactly as if the "correct" DD had + been loaded — nothing signals that a version-drifted schema is in use. Same + practical risk class NORTH_STAR.md flags for COCOS-sign-flip-bearing paths: + this mechanism checks path existence only, never semantic version + agreement. Pinned: + `UdaUniqueSurfaceTest.DISABLED_DdWrongVersionIsRejected` + tripwire + `UdaUniqueSurfaceTest.DdWrongVersionCurrentlyLoadsSilentlyWithNoCrossVersionCheck`. +- **`datapath` genuinely hides data, not just reorders fetch priority.** + With `cache_mode=ids` and a non-empty `datapath`, `populate_cache` only + requests paths under `/`'s subtree + (`UDABackend::beginAction`, `uda_backend.cpp:1016-1032`). A real, written + field *outside* that subtree is not in the cache and is not one of the two + homogeneous-time/version preconditions, so `readData` falls through to + `return 0` — the ordinary absent-leaf contract — even though the plain + HDF5 backend underneath genuinely has it. This is the evidence backing the + `FUNCTIONALITY_INVENTORY.md`/`CLAUDE.md` claim (corrected by this issue) + that UDA is the one living exception to "no backend uses `datapath`". +- **NEW DEFECT discovered (xfail): version-drift check inertness, confirmed + from source**: + `al_lowlevel.cpp`'s open-time comparison + (`getVersion(NULL)` vs. `getVersion(pctx)`) can never fire for UDA — both + sides are hardcoded `{0, 0}` placeholders (`UDABackend::getVersion`, + `uda_backend.h`'s `UDA_BACKEND_VERSION_MAJOR/MINOR` and the "temporary + placeholder" non-null-ctx branch), so `(0!=0)||(0<0)` is always false + regardless of what is genuinely stored remotely. An unavailable stored + version must not be treated as verified compatibility. Pinned with a + genuinely MISMATCHED stored fixture (issue #39): the pulse's + `HDF5_BACKEND_VERSION` is rewritten to `999.0` out-of-band by + `hdf5_fixture_tool` (issue #36's isolated producer; fixture preparation + only — every asserted behavior stays on the public C ABI). Observed against + the reference stack: the mismatched pulse is REFUSED, but by the + SERVER-side HDF5 open (the server plugin's own IMAS-Core hits + `HDF5BackendFactory`'s "No backend writer with version: 999.0"), forwarded + to the client as exactly `UNKNOWN_ERR` (-1); the client-side check itself + stays inert — its own `LOWLEVEL_ERR` "Compatibility …" refusal never + appears, which is what the active tripwire pins. Pinned: + `UdaUniqueSurfaceTest.DISABLED_OpenRefusesMismatchedStoredBackendVersion` + (client-side refusal, correct contract) + tripwire + `UdaUniqueSurfaceTest.VersionDriftCheckCurrentlyDefersToServerSideRefusal`, + with baseline `UdaUniqueSurfaceTest.MatchingStoredVersionOpensThroughUda`. +- **`supportsTimeRangeOperation()` capability negotiation confirmed against + the reference server**: the reference plugin reports `1.8.0` (issue #23), + so `1.8.0 > 1.4.0` grants the capability — + `UdaUniqueSurfaceTest.TimeRangeCapabilityGrantedByReferenceServerVersion180` + asserts the exact begin-action status, `s.code == 0`, rather than merely + excluding the capability-refusal text. The separately pinned + uninitialized-interpmode defect occurs only on the subsequent read + (`UdaSliceAndTimeRange`, above). +- **Fetch mode: download, local-backend handoff, cache reuse, all confirmed + end-to-end (issue #27)** — the `BYTES` server plugin fetch mode needs ships + and is registered **by default** in `ukaea/uda`'s own build (corrects this + file's `docker/uda/README.md` spike note that it wasn't registered; that was + an unverified assumption, not an empirical finding). `UDABackend`'s + constructor lists the remote pulse's files and downloads `master.h5` via + `BYTES::read`, then delegates every subsequent `Backend` virtual straight to + a freshly constructed local backend over the download — confirmed by a + correct scalar read-back. A reopen against the same cache reuses it without + re-downloading, confirmed directly via `download_file`'s own verbose trace + lines (`"...cache directory already exists"`, `"...cached local file + already exists"`), not inferred. +- **`local_cache` overrides only the cache *root*, not the whole path**: with + an explicit `local_cache`, `fetch_files` still joins it with the remote + path's `relative_path()` (`uda_backend.cpp:428-429`) — the same nesting the + default `$TMPDIR/uda-cache-of-$USER/` formula applies, just + under a different root. +- **The stale-cache / write-divergence pin, end-to-end (issue #27)**: a write + through fetch mode "succeeds" (`UDABackend::writeData` delegates straight to + the local backend once `access_local_` is set, `uda_backend.cpp:1107-1109` + — there is no upload path back to the server at all), but lands only on the + local cache copy. Confirmed three ways in one test: (1) the write reports + success; (2) reopening the identical pulse via ordinary *remote* mode shows + the server-side data is genuinely unchanged; (3) reopening *fetch* mode + again still serves the divergent local value, because `download_file`'s + early-exists-return (area 7) never re-fetches the true server-side value — + the divergence is permanent until the cache dir is manually cleared. (Pinned + against a brand-new field, not the already-seeded scalar: overwriting + pre-existing data through a fresh `WRITE_OP` session hits a separate, HDF5- + backend-wide Limit — `HDF5Writer::write_ND_Data` unconditionally calls + `H5Dcreate2` for any dataset not already tracked in *this session's* + in-memory map, with no `H5Lexists` check against the file + (`hdf5_writer.cpp:417-423`) — orthogonal to UDA and out of this issue's + scope; exercising it here would have conflated two different defects.) +- **NEW DEFECT discovered (xfail): remote write and delete diverge in how + they surface the same underlying dispatch failure (issue #27)** — a finding + only visible by driving the actual client library calls, not by probing the + server with `uda_cli` directly (as issue #23's spike did): the reference + `IMAS` server plugin has no `writeData`/`deleteData` handler at all; both + raw directives return `[handle_request]: Unknown function requested!`. + `UDABackend::deleteData` issues its directive via `uda::Client::get()`, + whose error path *does* propagate that failure — caught as a + `uda::UDAException`, re-thrown as an `ALException`, surfacing at + `al_delete_data` as a non-zero `al_status_t` carrying that exact text: the + intended, correctly-refused contract, confirmed and covered. + `UDABackend::writeData` issues its directive via `uda::Client::put()` + instead, which does **not** throw on an in-band server dispatch failure + (only on transport-level faults) — so `al_write_data` reports + `al_status_t.code == 0` (false success) while nothing is persisted. This is + a more dangerous pin than a clean refusal: the caller is told the write + succeeded with no signal that the server never received it, confirmed by + reopening via remote mode afterward and finding the value unchanged. + Pinned: `UdaUniqueSurfaceTest.DISABLED_RemoteWriteFailsWithDispatchError` + + tripwire + `UdaUniqueSurfaceTest.RemoteWriteCurrentlyReportsSuccessButNeverPersists`. + +| Cluster / Capability | Test(s) | Status | +|---|---|---| +| URI option surface: `cache_mode` invalid value throws; `verbose` toggles debug tracing (captured directly); `plugin` naming an unregistered plugin fails at open; `init_args` accepted with no observable effect against the reference plugin; `dd_version` override accepted with no observable effect; wrong port fails to connect | `UdaUniqueSurfaceTest.{InvalidCacheModeThrows,VerboseTrueEmitsDebugTracingOnStdout,VerboseAbsentEmitsNoDebugTracing,PluginOptionNamingUnregisteredPluginFailsAtOpen,InitArgsAcceptedAndIgnoredByReferencePlugin,DdVersionOverrideAcceptedWithNoObservableEffect,WrongPortFailsToConnectDistinctlyFromRequestFailures}` | covered | +| Cache-mode invisibility: `none`/`ids`/`struct` agree on a field all three can reach (a static AOS-nested leaf) | `UdaUniqueSurfaceTest.CacheModeNoneIdsStructAgreeForAosCoveredField` | covered | +| ↳ `struct` mode reports success but returns the absent sentinel for a genuinely written top-level field, so cache-mode selection changes the physics value | `UdaUniqueSurfaceTest.DISABLED_CacheModeStructPreservesTopLevelField` + tripwire `UdaUniqueSurfaceTest.CacheModeStructCurrentlyReturnsAbsentSentinelForTopLevelField` | **xfail** | +| ↳ closing a UDA session leaks a server-side pulse handle (uri-stripping mismatch between `open`/`close` and every `get()`-directive builder), blocking a later local reopen of the same file | `UdaUniqueSurfaceTest.DISABLED_ClosingUdaSessionReleasesEveryServerSideHandle` + tripwire `UdaUniqueSurfaceTest.ClosingUdaSessionCurrentlyLeaksServerSideHandle` | **xfail** | +| ↳ client cache invalidation across close/reopen: the required A→B fixture mutation is blocked by that distinct leaked server handle; neither the C ABI nor the pinned stack offers a server/connection restart seam | issue #38's attempted C-ABI fixture mutation (fresh process, `HDF5_USE_FILE_LOCKING=FALSE`) deterministically fails before B can be written; do not infer a verdict from source inspection | **terminal gap** | +| Runtime DD loading: present baseline and absent DD (both failure messages) | `UdaUniqueSurfaceTest.{DdPresentLoadsAndOpenSucceeds,DdAbsentNeitherEnvVarSetFailsWithClearMessage,DdAbsentFileMissingAtIdsDefPathFailsWithClearMessage}` | covered | +| ↳ wrong-version DD loads silently with no semantic cross-version check | `UdaUniqueSurfaceTest.DISABLED_DdWrongVersionIsRejected` + tripwire `UdaUniqueSurfaceTest.DdWrongVersionCurrentlyLoadsSilentlyWithNoCrossVersionCheck` | **xfail** | +| `datapath` partial-get via `cache_mode=ids`: in-scope field round-trips, out-of-scope field silently reads as absent | `UdaUniqueSurfaceTest.DatapathScopesCachePopulationFieldOutsideScopeReadsAsAbsent` | covered | +| Version-drift check inertness: the client's own drift check never fires (both sides hardcoded placeholders); a pulse whose stored backend version (`999.0`, rewritten out-of-band by `hdf5_fixture_tool`) can never match is refused only by the forwarded SERVER-side HDF5 error (`UNKNOWN_ERR`, "No backend writer with version: 999.0") | `UdaUniqueSurfaceTest.DISABLED_OpenRefusesMismatchedStoredBackendVersion` + tripwire `UdaUniqueSurfaceTest.VersionDriftCheckCurrentlyDefersToServerSideRefusal` (baseline: `MatchingStoredVersionOpensThroughUda`) | **xfail** | +| Server-version-gated `supportsTimeRangeOperation()`: reference plugin 1.8.0 > 1.4.0 grants the capability; the exact `al_begin_timerange_action` verdict is `s.code == 0` | `UdaUniqueSurfaceTest.TimeRangeCapabilityGrantedByReferenceServerVersion180` | covered | +| ↳ Boundary (`<=1.4.0` refused, `>1.4.0` accepted): the pinned reference stack exposes only its fixed 1.8.0 server-owned `IMAS::version()` result, and the public C ABI has no reported-version override or injectable server fixture. The positive side is covered above; retain this explicit gap until a second pinned server/plugin image or a public test seam exists. | reference-stack limitation (issue #40) | **terminal gap** | +| Fetch mode: download, local-backend handoff, correct read-back, cache reuse on reopen (confirmed via `download_file`'s own verbose trace) | `UdaUniqueSurfaceTest.FetchModeDownloadsHandsOffToLocalBackendAndReusesCacheOnReopen` | covered | +| `local_cache` overrides the cache root only — the remote path is still nested underneath it, same as the default formula | `UdaUniqueSurfaceTest.FetchModeLocalCacheOptionOverridesDefaultCacheDir` | covered | +| ↳ stale-cache / write-divergence pin: a fetch-mode write succeeds locally only, server-side pulse (reopened via remote mode) is unchanged, divergent local copy persists across close/reopen | `UdaUniqueSurfaceTest.FetchModeWriteDivergesFromServerAndStalePersistsAcrossReopen` | covered | +| Remote delete pinned unsupported (correctly refused): no `deleteData` handler on the reference plugin, `al_delete_data` surfaces the server's exact dispatch-failure text | `UdaUniqueSurfaceTest.RemoteDeleteFailsWithUnknownFunctionRequested` | covered | +| ↳ remote write reports **false success** instead of the same refusal: `uda::Client::put()`'s error path does not propagate the dispatch failure deleteData's `get()` surfaces, so `al_write_data` returns `code == 0` while nothing is persisted (confirmed by reopening and finding the value unchanged) | `UdaUniqueSurfaceTest.DISABLED_RemoteWriteFailsWithDispatchError` + tripwire `UdaUniqueSurfaceTest.RemoteWriteCurrentlyReportsSuccessButNeverPersists` | **xfail** | + +**Progress (issue #27): UDA's unique surface is now fully characterized — +areas 1-6 (issue #26) plus fetch mode and the write/delete pins (areas 7-10) +close out `test_uda_unique_surface.cpp`. One new genuine defect discovered and +pinned (xfail, D2 correct-contract + tripwire): remote write silently reports +success without persisting, unlike remote delete's clean refusal. The +`docker/uda/` spike's assumption that the `BYTES` plugin fetch mode needs +wasn't registered on this reference stack did not hold up empirically — it +ships and registers by default in `ukaea/uda`'s own build — and is corrected +in `docker/uda/README.md` alongside this issue's findings. + +**Progress summary (issue #28): zero blank rows remain in Part 5 — every one +of its 31 rows is terminal (19 `covered`/`covered (indirect)`, 1 +`divergence`, 9 `xfail`, 2 `terminal-gap`), each xfail row's `DISABLED_` +correct-contract test and current-behavior tripwire both verified present in +`test_uda_real_paths.cpp`/`test_uda_breadth.cpp`/ +`test_uda_unique_surface.cpp`, and every divergence +row's reasoning is backed by prose above its table, not just the one-line +cell. The pinned stack identifiers above (UDA server 2.9.3, UDA-Plugins +1.8.0 at commit `ede25b921081d8fc2d66c5b5ca152c664b50ee78`, DD 4.1.1, +plus the older DD 3.42.0 pin) match `docker/uda/` +verbatim. The corresponding shared always-on rows in Part 1/Part 2 above now +each carry a one-line "UDA: … see Part 5" note, mirroring Part 4's own +issue-#18 closure (Cluster 3 plugin management and Cluster 4 +introspection/diagnostics carry no such note for either backend — both +clusters are backend-agnostic registry/lookup operations with no `Backend` +virtual dispatch, exercised once in Part 1's own tier, not a gap). diff --git a/tests/contract/al_contract.h b/tests/contract/al_contract.h new file mode 100644 index 00000000..85a0eb8c --- /dev/null +++ b/tests/contract/al_contract.h @@ -0,0 +1,356 @@ +// Contract-test harness for the public C ABI (include/al_lowlevel.h). +// +// This header is the fixture/generator layer of the Task-2 contract suite +// (see TEST_STRATEGY.md). It exists to make the *machine* — temp pulse dirs, +// URI construction, data-entry lifecycle, and typed round-trip helpers — +// trivial and reusable, so the tests themselves read as behavior assertions +// against the ABI rather than boilerplate. +// +// Everything here binds only to the `extern "C"` surface (al_lowlevel.h + +// al_const.h), per decision D1: no C++ internals, no Python bindings. + +#ifndef AL_CONTRACT_H +#define AL_CONTRACT_H + +#include "al_contract_abi.h" +#include "al_contract_legacy_storage.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#if defined(AL_CONTRACT_HAVE_UDA) +#include +#include +#include +#include +#endif +#endif + +namespace al_contract { + +// --------------------------------------------------------------------------- +// Status assertion helpers +// --------------------------------------------------------------------------- +// al_status_t carries {code, message}; code == 0 means success. These wrap the +// pair so a failing ABI call reports its own diagnostic message, not just a +// bare code mismatch. + +inline ::testing::AssertionResult IsOk(const al_status_t& s) { + if (s.code == 0) { + return ::testing::AssertionSuccess(); + } + return ::testing::AssertionFailure() + << "al_status_t.code=" << s.code << " message=" << s.message; +} + +#define AL_ASSERT_OK(expr) ASSERT_TRUE(::al_contract::IsOk(expr)) +#define AL_EXPECT_OK(expr) EXPECT_TRUE(::al_contract::IsOk(expr)) + +// A plugin name that is never backed by a .so and never registered — shared by +// the plugin negative/crash tests (test_plugins.cpp, test_known_defects.cpp). +inline constexpr const char* kUnregisteredPluginName = + "no_such_plugin_ever_registered"; + +// --------------------------------------------------------------------------- +// MDSplus runtime skip (TEST_STRATEGY.md D4): absent/unconfigured is skipped, +// never failed. A macro, not a function -- GTEST_SKIP() expands to a `return` +// that must unwind the *calling* SetUp()/TEST body directly; wrapping it in an +// ordinary function would only return from that function, leaving the caller +// to run on unskipped. Shared by every MDSplus-parametrized fixture +// (test_mdsplus.cpp, test_pulse_lifecycle.cpp, test_capability_gated.cpp, +// test_structured_data.cpp) so the check has exactly one wording. +// --------------------------------------------------------------------------- +#define AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED() \ + do { \ + const char* models_path = std::getenv("MDSPLUS_MODELS_PATH"); \ + if (!models_path || !*models_path) { \ + GTEST_SKIP() << "MDSPLUS_MODELS_PATH is unset -- MDSplus " \ + "characterization tests are skipped, not " \ + "failed (TEST_STRATEGY.md D4)."; \ + } \ + } while (0) + +// --------------------------------------------------------------------------- +// UDA runtime skip (TEST_STRATEGY.md D4, PRD #21 user story 16): the UDA tier +// is a *client* to a remote UDA reference stack (docker/uda/). When that stack +// is not standing behind the client the tests skip, never fail -- an ordinary +// local build (no server) and always-on CI legs are unaffected. The gate checks +// both UDA_HOST and a short TCP connection to the configured UDA port, avoiding +// the client's long timeout when a configured server is down. Same macro-not- +// function rationale as the MDSplus skip above: GTEST_SKIP() must unwind the +// calling SetUp()/TEST body. +// --------------------------------------------------------------------------- +inline bool uda_reference_server_reachable(const char* host, const char* port) { +#if defined(AL_CONTRACT_HAVE_UDA) && !defined(_WIN32) + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_flags = AI_NUMERICSERV; + + addrinfo* addresses = nullptr; + if (getaddrinfo(host, port, &hints, &addresses) != 0) return false; + + constexpr auto kConnectBudget = std::chrono::milliseconds(100); + const auto deadline = std::chrono::steady_clock::now() + kConnectBudget; + bool reachable = false; + for (addrinfo* address = addresses; address != nullptr; + address = address->ai_next) { + const auto remaining = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()); + if (remaining.count() <= 0) break; + + const int fd = socket(address->ai_family, address->ai_socktype, + address->ai_protocol); + if (fd < 0) continue; + + const int flags = fcntl(fd, F_GETFL, 0); + if (flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0) { + if (connect(fd, address->ai_addr, address->ai_addrlen) == 0) { + reachable = true; + } else if (errno == EINPROGRESS) { + pollfd candidate{fd, POLLOUT, 0}; + if (poll(&candidate, 1, static_cast(remaining.count())) > 0) { + int socket_error = 0; + socklen_t size = sizeof(socket_error); + reachable = + getsockopt(fd, SOL_SOCKET, SO_ERROR, &socket_error, &size) == + 0 && + socket_error == 0; + } + } + } + close(fd); + if (reachable) break; + } + freeaddrinfo(addresses); + return reachable; +#else + // UDA contract tests are currently supported only on POSIX hosts. + return host && *host && port && *port; +#endif +} + +#define AL_CONTRACT_SKIP_IF_UDA_UNCONFIGURED() \ + do { \ + const char* uda_host = std::getenv("UDA_HOST"); \ + const char* uda_port_env = std::getenv("UDA_PORT"); \ + const char* uda_port = \ + uda_port_env && *uda_port_env ? uda_port_env : "56565"; \ + if (!uda_host || !*uda_host) { \ + GTEST_SKIP() << "UDA_HOST is unset -- UDA characterization tests " \ + "are skipped, not failed: no reference stack is " \ + "reachable (TEST_STRATEGY.md D4, docker/uda/)."; \ + } \ + if (!::al_contract::uda_reference_server_reachable(uda_host, uda_port)) { \ + GTEST_SKIP() << "UDA reference server at " << uda_host << ':' \ + << uda_port << " is unreachable -- UDA characterization " \ + "tests are skipped, not failed " \ + "(TEST_STRATEGY.md D4, docker/uda/)."; \ + } \ + } while (0) + +// Build the base of a UDA remote-mode URI ("imas://:/uda") from the +// UDA_HOST / UDA_PORT env the reference stack exports. The parser only treats a +// URI as UDA when it carries an authority (host non-empty), which requires the +// "imas://host:port/..." form -- NOT "imas:uda://..." (that leaves the authority +// unparsed; see include/uri_parser.h parse_authority). Port defaults to UDA's +// canonical 56565 when UDA_PORT is unset. +inline std::string uda_uri_base() { + const char* host = std::getenv("UDA_HOST"); + const char* port = std::getenv("UDA_PORT"); + return std::string("imas://") + (host ? host : "localhost") + ":" + + (port && *port ? port : "56565") + "/uda"; +} + +// Shared addressing for the UDA characterization machine. Keep operation +// lifecycles in the tests themselves; only centralize the URI shapes that every +// HDF5-seed/UDA-reopen fixture must agree on. +inline std::string hdf5_uri_for(const std::string& pulse_dir) { + return "imas:hdf5?path=" + pulse_dir; +} + +inline std::string uda_hdf5_uri_for( + const std::string& pulse_dir, const std::string& cache_mode = "none", + const std::string& extra_query = "") { + std::string uri = uda_uri_base() + "?backend=hdf5&cache_mode=" + + cache_mode + "&path=" + pulse_dir; + if (!extra_query.empty()) uri += "&" + extra_query; + return uri; +} + +// --------------------------------------------------------------------------- +// BackendCase descriptor for value-parametrized tests (TEST_P). +// --------------------------------------------------------------------------- +struct BackendCase { + int id; // BACKEND enum value from al_const.h + const char* name; // human-readable label for the ctest name suffix + bool on_disk; // needs the legacy //// tree +}; + +// GoogleTest prints this for the parametrized test-name suffix. +inline void PrintTo(const BackendCase& b, std::ostream* os) { *os << b.name; } + +// --------------------------------------------------------------------------- +// Compatibility name for the existing contract tests. The implementation is +// shared with benchmarks through fixtures/al_contract_legacy_storage.h. +using TempBase = LegacyPulseDirectory; + +// --------------------------------------------------------------------------- +// URI construction through the legacy-parameter ABI. +// --------------------------------------------------------------------------- +// Uses al_build_uri_from_legacy_parameters so the URI is whatever the core +// itself produces for a given backend id — we don't hand-format schemes. +// +// Exception: the always-on FLEXBUFFERS backend. al_build_uri_from_legacy_parameters +// -> getURIBackend (src/al_context.cpp:280) has no FLEXBUFFERS_BACKEND case and +// throws "not yet implemented", even though the *parse* side accepts the +// "flexbuffers" scheme (al_context.cpp:214). So for that one backend we build +// the same legacy query URI directly — mirroring the exact format +// build_uri_from_legacy_parameters emits (al_context.cpp:252), including its +// version[0] single-char quirk — so the storage round trip is still exercised. +// The builder gap itself is a genuine defect, tracked separately as a +// known-defect test (see test_known_defects.cpp); it is NOT papered over here. +inline std::string build_uri(int backend_id, const std::string& base, + const PulseId& id) { + if (backend_id == FLEXBUFFERS_BACKEND) { + return "imas:flexbuffers?user=" + base + ";pulse=" + + std::to_string(id.pulse) + ";run=" + std::to_string(id.run) + + ";database=" + id.database + ";version=" + + (id.version.empty() ? std::string() : id.version.substr(0, 1)); + } + char* uri = nullptr; + al_status_t s = al_build_uri_from_legacy_parameters( + backend_id, id.pulse, id.run, base.c_str(), id.database.c_str(), + id.version.c_str(), "", &uri); + EXPECT_TRUE(IsOk(s)) << "al_build_uri_from_legacy_parameters failed"; + std::string out = (uri ? uri : ""); + free(uri); + return out; +} + +// --------------------------------------------------------------------------- +// Typed round-trip helpers over al_write_data / al_read_data. +// --------------------------------------------------------------------------- +inline al_status_t write_int_scalar(int ctx, const char* field, int value) { + // Scalar: dim=0, size=NULL (mirrors testlowlevel.cpp). + return al_write_data(ctx, field, "", &value, INTEGER_DATA, 0, nullptr); +} + +// Reads an INTEGER scalar into `out`. For dim=0 the core fills the caller's +// buffer, so we pass a pointer to our own int (no free needed). +inline al_status_t read_int_scalar(int ctx, const char* field, int* out) { + int size[MAXDIM] = {0}; + void* buf = out; + return al_read_data(ctx, field, "", &buf, INTEGER_DATA, 0, size); +} + +inline al_status_t write_char_array(int ctx, const char* field, + const std::string& value) { + int size[1] = {static_cast(value.size())}; + return al_write_data(ctx, field, "", + const_cast(value.c_str()), CHAR_DATA, 1, size); +} + +// Reads a CHAR array. For dim>=1 the core allocates the buffer and returns it +// via *data; the caller owns it, so we copy into std::string and free. +inline al_status_t read_char_array(int ctx, const char* field, + std::string* out) { + int size[MAXDIM] = {0}; + char* buf = nullptr; + al_status_t s = al_read_data(ctx, field, "", reinterpret_cast(&buf), + CHAR_DATA, 1, size); + if (s.code == 0 && buf != nullptr) { + out->assign(buf, static_cast(size[0])); + } + free(buf); + return s; +} + +// =========================================================================== +// Synthetic-data generator + generic typed round-trip (issue #3). +// =========================================================================== +// The bulk of the storage contract is proven by *round-trip self-consistency* +// over synthetic opaque data (decision D5): write a deterministic buffer, read +// it back, assert byte-for-byte equality. It needs zero DD artifacts, which is +// itself the proof of the version-free property. This block generalizes the +// two hand-written scalar/char helpers above to the whole +// datatype × shape space. +// +// DType/write_data/read_data/element_count (the subset equilibrium_seed.h also +// needs) live in al_contract_abi.h (issue #51), included above, so a build +// target outside tests/contract/ can link them without GoogleTest. The +// synthetic-buffer generator and its shape helper, used exclusively by this +// suite's own matrix tests, stay here. + +// The core's "this value means absent" sentinels (src/al_lowlevel.cpp:41-44). +// A *scalar* write whose value equals its sentinel is silently dropped by +// Lowlevel::data_has_non_zero_shape (al_lowlevel.cpp:759) — the generator must +// never emit one, or the round trip would compare against a default-filled +// read and give a false failure. (Arrays are gated on shape, not value, so +// element values there are unrestricted; we stay clear for all ranks anyway.) +inline constexpr int kEmptyInt = -999999999; +inline constexpr double kEmptyDouble = -9.0E40; + +// --- deterministic synthetic value for flat element index i ----------------- +// Distinct per index so a transposed / off-by-one round trip is caught, and +// guaranteed clear of every sentinel above. +template +T synth_value(std::size_t i); + +template <> +inline char synth_value(std::size_t i) { + // Printable ASCII 33..122, never '\0' (EMPTY_CHAR). + return static_cast('!' + static_cast(i % 90)); +} +template <> +inline int synth_value(std::size_t i) { + int v = static_cast(i) * 7 - 11; // spans negatives and positives + return v == kEmptyInt ? 1 : v; +} +template <> +inline double synth_value(std::size_t i) { + double v = static_cast(i) * 1.5 - 3.25; // exactly representable + return v == kEmptyDouble ? 1.0 : v; +} +template <> +inline std::complex synth_value>(std::size_t i) { + double re = static_cast(i) * 1.5 - 3.25; + double im = static_cast(i) * -0.75 + 2.0; + if (re == kEmptyDouble) re = 1.0; + if (im == kEmptyDouble) im = 1.0; + return {re, im}; // clear of EMPTY_COMPLEX == (-9e40,-9e40) +} + +// --- shape for a given rank ------------------------------------------------- +// rank 0 -> scalar (empty shape, one element). rank R -> R dims sized 2,3,2,3… +// so every dimension is >= 1 (a zero dim would itself read as "absent") while +// the 7-D total stays small (2*3*2*3*2*3*2 = 432 elements). +inline std::vector shape_for_rank(int rank) { + std::vector s; + s.reserve(static_cast(rank)); + for (int i = 0; i < rank; ++i) s.push_back((i % 2 == 0) ? 2 : 3); + return s; +} + +template +std::vector synth_buffer(std::size_t n) { + std::vector v; + v.reserve(n); + for (std::size_t i = 0; i < n; ++i) v.push_back(synth_value(i)); + return v; +} + +} // namespace al_contract + +#endif // AL_CONTRACT_H diff --git a/tests/contract/assign_contract_labels.cmake.in b/tests/contract/assign_contract_labels.cmake.in new file mode 100644 index 00000000..c6f1967f --- /dev/null +++ b/tests/contract/assign_contract_labels.cmake.in @@ -0,0 +1,97 @@ +# assign_contract_labels.cmake — post-discovery CTest label assignment +# (issue #31). +# +# Processed by ctest via the directory TEST_INCLUDE_FILES property, AFTER the +# gtest_discover_tests include has registered every test. It re-reads the +# generated registration file, classifies each test name, and assigns LABELS +# with a plain set_tests_properties call — the one mechanism that carries a +# multi-element label list identically across the supported CMake range. +# gtest_discover_tests' own PROPERTIES forwarding collapses a multi-element +# LABELS list to its first token on some CMake versions (empirically 3.28), +# and TEST_FILTER (the old multi-pass partition) does not exist on the +# declared 3.21 floor at all; parsing + relabeling depends on neither. +# +# The classification below mirrors the tier definitions documented in +# tests/contract/CMakeLists.txt: +# unit — pure lookups, in-RAM Memory backend instances, plugin ABI +# death tests, plugin lifecycle suites, URI ownership. +# mdsplus — the compile-gated MDSplus tier (single "mdsplus" label). +# uda — the compile-gated UDA tier (single "uda" label). +# integration — everything else (HDF5 / ASCII / Flexbuffers on disk). +# The if/elseif chain makes the partition disjoint by construction: exactly +# one branch fires per test. +# +# This file parses exactly the single discovery pass's output; if another +# gtest_discover_tests call is ever added for contract_tests, add its +# generated "_tests.cmake" file here. + +set(_al_contract_tests_file "@_AL_CONTRACT_DISCOVERED_TESTS_FILE@") + +if(EXISTS "${_al_contract_tests_file}") + file(STRINGS "${_al_contract_tests_file}" _al_add_test_lines REGEX "^add_test") + foreach(_al_line IN LISTS _al_add_test_lines) + # The generated add_test name quoting is version-dependent: bracket + # arguments ("add_test([=[Name]=] ...", CMake >= ~3.22), double quotes for + # names with special characters on older versions, bare words otherwise + # ("add_test( Name ...", 3.21). + set(_al_name "") + if(_al_line MATCHES "^add_test\\( *\\[=+\\[([^]]+)\\]") + set(_al_name "${CMAKE_MATCH_1}") + elseif(_al_line MATCHES "^add_test\\( *\"([^\"]+)\"") + set(_al_name "${CMAKE_MATCH_1}") + elseif(_al_line MATCHES "^add_test\\( *([^ ]+) ") + set(_al_name "${CMAKE_MATCH_1}") + endif() + if(NOT _al_name STREQUAL "") + if(_al_name MATCHES "^Introspection\\." OR + _al_name MATCHES "Memory" OR + _al_name MATCHES "^KnownDefects" OR + _al_name MATCHES "^Plugin" OR + _al_name MATCHES "^ReadbackPlugins\\." OR + _al_name MATCHES "^UriOwnership\\." OR + # issue #47: unknown-backend refusals at the factory boundary — no + # backend substrate is ever constructed, so the tier is hermetic + _al_name MATCHES "^BackendFactory\\." OR + # issue #46: the shared real-DD-path catalog's own invariants — pure + # in-RAM table checks, no backend substrate, always compiled + _al_name MATCHES "^RealDdPathCatalog\\." OR + # issue #33: equilibrium-seed structural-hash sensitivity checks — + # pure in-RAM hashing of records, no backend substrate + _al_name MATCHES "^EquilibriumSeedStructuralHash\\.") + set_tests_properties("${_al_name}" PROPERTIES LABELS "contract;unit") + elseif(_al_name MATCHES "^MdsplusTracerBullet" OR + # "/Mdsplus" as the whole instance suffix: pretty names end + # "/Mdsplus" on newer CMake but "/Mdsplus#GetParam()=..." on 3.21. + _al_name MATCHES "/Mdsplus(#|$)" OR + _al_name MATCHES "^Occurrences\\.Mdsplus" OR + _al_name MATCHES "^Mdsplus/MdsplusRealPathMatrix\\." OR + # issue #46: MDSplus half of the shared-catalog coverage meta-test + _al_name MATCHES "^MdsplusRealPathCatalog\\." OR + # issue #34: MDSplus real-DD-path delete characterization + _al_name MATCHES "^MdsplusRealPathDelete\\." OR + _al_name MATCHES "^MdsplusVersionDrift" OR + _al_name MATCHES "^MdsplusStructAwareSliceInterpolation" OR + _al_name MATCHES "^MdsplusTimerangeResampling" OR + _al_name MATCHES "^MdsplusSegmentBackedWrites" OR + _al_name MATCHES "^MdsplusSliceWriteKnownDefects" OR + _al_name MATCHES "^MdsplusTimebaseCache") + set_tests_properties("${_al_name}" PROPERTIES LABELS "mdsplus") + elseif(_al_name MATCHES "^UdaSmokeRoundTrip" OR + _al_name MATCHES "^UdaEquilibriumSeedParity" OR + _al_name MATCHES "^Uda/UdaRealPathMatrix\\." OR + _al_name MATCHES "^Uda/UdaRealPathMatrixKnownDefects\\." OR + # issue #46: UDA half of the shared-catalog coverage meta-test + _al_name MATCHES "^UdaRealPathCatalog\\." OR + _al_name MATCHES "^UdaBreadthTest" OR + _al_name MATCHES "^UdaAosKnownDefects" OR + _al_name MATCHES "^UdaSliceAndTimeRange" OR + _al_name MATCHES "^UdaUniqueSurfaceTest") + set_tests_properties("${_al_name}" PROPERTIES LABELS "uda") + else() + set_tests_properties("${_al_name}" PROPERTIES LABELS "contract;integration") + endif() + endif() + endforeach() + unset(_al_add_test_lines) +endif() +unset(_al_contract_tests_file) diff --git a/tests/contract/check_label_partition.sh b/tests/contract/check_label_partition.sh new file mode 100755 index 00000000..d1fe3bb9 --- /dev/null +++ b/tests/contract/check_label_partition.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# check_label_partition.sh — CI smoke check for the contract-suite CTest +# labels (issue #31). +# +# An empty `ctest -L unit` exits 0, so a label regression (version-dependent +# property forwarding, filter drift, a renamed suite) is silent unless +# something checks the selection itself. This verifies, machine-readably: +# 1. `ctest -N -L unit` and `ctest -N -L integration` both select a nonzero +# number of tests; +# 2. the two selections are disjoint (no test carries both labels); +# 3. the unit selection is hermetic — no filesystem-backed backend case. +# +# Usage: check_label_partition.sh +# e.g. check_label_partition.sh build/tests/contract +set -euo pipefail + +build_dir=${1:?usage: check_label_partition.sh } + +list_names() { + # `ctest -N` right-aligns test numbers ("Test #9:" vs "Test #99:"), so the + # whitespace before '#' is variable; strip the "(Disabled)" suffix too. + ctest --test-dir "${build_dir}" -N -L "$1" \ + | sed -n 's/^ *Test *#[0-9]*: //p' | sed 's/ (Disabled)$//' +} + +unit=$(list_names '^unit$') +integration=$(list_names '^integration$') +unit_count=$(printf '%s' "${unit}" | grep -c . || true) +integration_count=$(printf '%s' "${integration}" | grep -c . || true) +echo "unit: ${unit_count} tests, integration: ${integration_count} tests" + +fail=0 +if [ "${unit_count}" -eq 0 ]; then + echo "FAIL: 'ctest -L unit' selects zero tests — the fast hermetic tier is empty (issue #31)." + fail=1 +fi +if [ "${integration_count}" -eq 0 ]; then + echo "FAIL: 'ctest -L integration' selects zero tests — the on-disk tier is empty (issue #31)." + fail=1 +fi + +overlap=$(comm -12 <(printf '%s\n' "${unit}" | sort -u) \ + <(printf '%s\n' "${integration}" | sort -u) | grep . || true) +if [ -n "${overlap}" ]; then + echo "FAIL: tests carry both 'unit' and 'integration' labels:" + printf '%s\n' "${overlap}" + fail=1 +fi + +# -i: gtest pretty names spell backends differently per CMake version +# ("(HDF5, ..." on new CMake, "#GetParam()=ASCII" on 3.21, "Hdf5..." in +# suite names). +nonhermetic=$(printf '%s\n' "${unit}" | grep -iE 'hdf5|ascii|flexbuffers|mdsplus|uda' || true) +if [ -n "${nonhermetic}" ]; then + echo "FAIL: the unit selection contains filesystem-/server-backed cases:" + printf '%s\n' "${nonhermetic}" + fail=1 +fi + +if [ "${fail}" -ne 0 ]; then + exit 1 +fi +echo "OK: unit/integration labels are nonempty, disjoint, and the unit tier is hermetic." diff --git a/tests/contract/hdf5_fixture_tool.cpp b/tests/contract/hdf5_fixture_tool.cpp new file mode 100644 index 00000000..ede6cd67 --- /dev/null +++ b/tests/contract/hdf5_fixture_tool.cpp @@ -0,0 +1,71 @@ +// hdf5_fixture_tool — out-of-band stored-backend-version mutation for the +// HDF5 drift fixtures (issues #36 and #39). +// +// The contract suite asserts version-drift behavior exclusively through the +// public C ABI; preparing a MISMATCHED pulse, however, requires rewriting the +// HDF5_BACKEND_VERSION attribute the backend wrote into the master file — an +// operation no public API offers (by design). This standalone tool owns that +// single out-of-band step, so `contract_tests` itself never includes or links +// the HDF5 C API: the same producer/observer split the MDSplus drift tier +// uses (mdsplus_fixture_tool.cpp, issue #35). +// +// Usage: hdf5_fixture_tool +// directory holding an existing pulse's master.h5 +// (created beforehand through the C ABI by the caller) +// value to store, e.g. "999.0" or "1.999" +// +// Fixtures are always regenerated from the current backend at test run time — +// nothing opaque or host-specific is ever committed. + +#include + +#include +#include + +int main(int argc, char** argv) { + if (argc != 3) { + std::fprintf(stderr, + "usage: %s \n", argv[0]); + return 2; + } + const std::string master = std::string(argv[1]) + "/master.h5"; + const std::string version = argv[2]; + const char* attr_name = "HDF5_BACKEND_VERSION"; + + hid_t file_id = H5Fopen(master.c_str(), H5F_ACC_RDWR, H5P_DEFAULT); + if (file_id < 0) { + std::fprintf(stderr, "cannot open %s for writing\n", master.c_str()); + return 1; + } + + // The stored attribute's size is baked into its datatype, so replace the + // attribute wholesale instead of writing into the old one. + if (H5Aexists(file_id, attr_name) > 0 && + H5Adelete(file_id, attr_name) < 0) { + std::fprintf(stderr, "cannot delete existing %s\n", attr_name); + H5Fclose(file_id); + return 1; + } + + hid_t dataspace_id = H5Screate(H5S_SCALAR); + hid_t dtype_id = H5Tcopy(H5T_C_S1); + H5Tset_size(dtype_id, version.length() + 1); + H5Tset_cset(dtype_id, H5T_CSET_UTF8); + hid_t att_id = H5Acreate2(file_id, attr_name, dtype_id, dataspace_id, + H5P_DEFAULT, H5P_DEFAULT); + int rc = 0; + if (att_id < 0 || H5Awrite(att_id, dtype_id, version.c_str()) < 0) { + std::fprintf(stderr, "cannot write %s\n", attr_name); + rc = 1; + } + if (att_id >= 0) H5Aclose(att_id); + H5Tclose(dtype_id); + H5Sclose(dataspace_id); + H5Fclose(file_id); + + if (rc == 0) { + std::printf("stored %s=\"%s\" in %s\n", attr_name, version.c_str(), + master.c_str()); + } + return rc; +} diff --git a/tests/contract/mdsplus_fixture_tool.cpp b/tests/contract/mdsplus_fixture_tool.cpp new file mode 100644 index 00000000..78d80539 --- /dev/null +++ b/tests/contract/mdsplus_fixture_tool.cpp @@ -0,0 +1,68 @@ +// mdsplus_fixture_tool — out-of-band stored-backend-version mutation for the +// MDSplus drift fixture (issue #35). +// +// The contract suite's seam is the public C ABI (issues #1/#12); preparing a +// MISMATCHED pulse, however, requires overwriting the VERSION:BACK_MAJOR node +// the backend wrote into the tree at pulse creation — reachable only through +// the raw MDSplus C++ API (). This standalone, build-gated tool +// owns ALL direct MDSplus API access, so `contract_tests` itself neither +// includes nor links the MDSplus C++ libraries: it is a +// fixture producer, not part of the asserted contract. +// +// Usage: mdsplus_fixture_tool +// directory holding the pulse's "ids" tree files (created +// beforehand through the C ABI by the caller; shot 1) +// integer to store into VERSION:BACK_MAJOR, e.g. 99 +// +// MDSPLUS_MODELS_PATH must point at the baked model tree, exactly as for the +// backend itself. Fixtures are regenerated from pinned DD/model inputs at +// test run time — nothing opaque or host-specific is ever committed. + +#include + +#include +#include +#include + +int main(int argc, char** argv) { + if (argc != 3) { + std::fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + const std::string pulse_dir = argv[1]; + const int back_major = std::atoi(argv[2]); + + const char* models_path = std::getenv("MDSPLUS_MODELS_PATH"); + if (models_path == nullptr || *models_path == '\0') { + std::fprintf(stderr, "MDSPLUS_MODELS_PATH is not set\n"); + return 2; + } + + // Reproduces MDSplusBackend::setDataEnv's "ids_path" env-var dance + // (mdsplus_backend.cpp) well enough to open the same tree the backend + // itself opened through the C ABI. + const std::string ids_path = pulse_dir + ";" + models_path; + if (setenv("ids_path", ids_path.c_str(), 1) != 0) { + std::fprintf(stderr, "cannot set ids_path\n"); + return 1; + } + + try { + MDSplus::Tree tree("ids", /*shot=*/1, "NORMAL"); + MDSplus::TreeNode* node = tree.getNode("VERSION:BACK_MAJOR"); + MDSplus::Int32* value = new MDSplus::Int32(back_major); + node->putData(value); + MDSplus::deleteData(value); + delete node; + } catch (MDSplus::MdsException& exc) { + std::fprintf(stderr, + "could not poke VERSION:BACK_MAJOR via the MDSplus tree " + "API: %s\n", + exc.what()); + return 1; + } + + std::printf("stored VERSION:BACK_MAJOR=%d in %s\n", back_major, + pulse_dir.c_str()); + return 0; +} diff --git a/tests/contract/real_dd_path_catalog.cpp b/tests/contract/real_dd_path_catalog.cpp new file mode 100644 index 00000000..a87ae80d --- /dev/null +++ b/tests/contract/real_dd_path_catalog.cpp @@ -0,0 +1,85 @@ +// The shared real-DD-path catalog (issue #46). See real_dd_path_catalog.h for +// what belongs here (curated DD paths + terminal-gap DD facts) and what does +// not (backend-specific verdicts). Curated against DD 4.1.1 +// (build-mdsplus/_deps/data-dictionary-src/IDSDef.xml). + +#include "real_dd_path_catalog.h" + +namespace al_contract { +namespace real_dd { + +const std::vector& catalog() { + // Exactly one entry per (dt, rank) cell, in (dt, rank) order. The + // CoversEveryCellExactlyOnce meta-test (test_real_dd_path_catalog.cpp) is + // the guard that this stays a complete, duplicate-free kNumCells table. + static const std::vector kCatalog = { + // --- CHAR ----------------------------------------------------------- + {DType::Char, 0, "equilibrium", {}, "code/name", nullptr}, + {DType::Char, 1, "equilibrium", {}, "code/name", nullptr}, + {DType::Char, 2, "b_field_non_axisymmetric", {}, "control_surface_names", + nullptr}, + {DType::Char, 3, nullptr, {}, nullptr, + "DD 4.1.1 has no STR_2D+ at all (only STR_0D/STR_1D exist -- there is " + "no DD concept a CHAR dim>=3 could model)"}, + {DType::Char, 4, nullptr, {}, nullptr, "see rank 3"}, + {DType::Char, 5, nullptr, {}, nullptr, "see rank 3"}, + {DType::Char, 6, nullptr, {}, nullptr, "see rank 3"}, + {DType::Char, 7, nullptr, {}, nullptr, "see rank 3"}, + + // --- INTEGER -------------------------------------------------------- + {DType::Int, 0, "amns_data", {}, "z_n", nullptr}, + {DType::Int, 1, "magnetics", {}, "code/output_flag", nullptr}, + {DType::Int, 2, "magnetics", {}, "b_field_pol_probe_equivalent", + nullptr}, + {DType::Int, 3, "temporary", {"constant_integer3d"}, "value", nullptr}, + {DType::Int, 4, nullptr, {}, nullptr, + "DD 4.1.1 has no INT_4D+ anywhere (max is INT_3D, itself only inside a " + "struct_array)"}, + {DType::Int, 5, nullptr, {}, nullptr, "see rank 4"}, + {DType::Int, 6, nullptr, {}, nullptr, "see rank 4"}, + {DType::Int, 7, nullptr, {}, nullptr, "see rank 4"}, + + // --- DOUBLE --------------------------------------------------------- + {DType::Double, 0, "amns_data", {}, "a", nullptr}, + {DType::Double, 1, "balance_of_plant", {}, "gain_plant", nullptr}, + {DType::Double, 2, "bolometer", {}, "grid/volume_element", nullptr}, + {DType::Double, 3, "bolometer", {}, "power_density/data", nullptr}, + {DType::Double, 4, "gyrokinetics_local", {}, + "non_linear/fluxes_4d/particles_phi_potential", nullptr}, + {DType::Double, 5, "gyrokinetics_local", {}, + "non_linear/fluxes_5d/particles_phi_potential", nullptr}, + {DType::Double, 6, "temporary", {"constant_float6d"}, "value", nullptr}, + {DType::Double, 7, nullptr, {}, nullptr, + "DD 4.1.1 has no FLT_7D (the DD's own array-rank ceiling for DOUBLE is " + "6, one short of MAXDIM)"}, + + // --- COMPLEX -------------------------------------------------------- + {DType::Complex, 0, nullptr, {}, nullptr, + "DD 4.1.1 has no CPX_0D -- no scalar complex type exists at all"}, + {DType::Complex, 1, "waves", + {"coherent_wave", "full_wave", "e_field/plus"}, "values", nullptr}, + {DType::Complex, 2, "gyrokinetics_local", {}, + "non_linear/fields_zonal_2d/phi_potential_perturbed_norm", nullptr}, + {DType::Complex, 3, "runaway_electrons", {"distribution/markers"}, + "orbit_integrals_instant/values", nullptr}, + {DType::Complex, 4, "gyrokinetics_local", {}, + "non_linear/fields_4d/phi_potential_perturbed_norm", nullptr}, + {DType::Complex, 5, "runaway_electrons", {"distribution/markers"}, + "orbit_integrals/values", nullptr}, + {DType::Complex, 6, nullptr, {}, nullptr, + "DD 4.1.1 has no CPX_6D anywhere"}, + {DType::Complex, 7, nullptr, {}, nullptr, + "DD 4.1.1 has no CPX_7D anywhere"}, + }; + return kCatalog; +} + +std::string case_name(DType dt, int rank) { + static const char* const kNames[] = {"CHAR", "INTEGER", "DOUBLE", + "COMPLEX"}; + return std::string(kNames[static_cast(dt)]) + "_r" + + std::to_string(rank); +} + +} // namespace real_dd +} // namespace al_contract diff --git a/tests/contract/real_dd_path_catalog.h b/tests/contract/real_dd_path_catalog.h new file mode 100644 index 00000000..7d03bfd9 --- /dev/null +++ b/tests/contract/real_dd_path_catalog.h @@ -0,0 +1,90 @@ +// Shared real-DD-path catalog for the MDSplus (#15) and UDA (#25) breadth +// matrices (issue #46, TRACEABILITY.md Parts 4/5). +// +// Both real-path suites sweep the same {CHAR, INTEGER, DOUBLE, COMPLEX} x rank +// {0 .. MAXDIM} space against curated *real* DD-4.1.1 paths (a made-up field +// name has no node in the schema either backend validates against). The path +// for each (datatype, rank) cell -- the IDS, the struct_array chain to enter, +// the leaf, and whether DD 4.1.1 even has such a field -- is a fact about the +// DD, identical for both backends. This header is the single source of truth +// for that catalog so a path correction, a terminal-gap rationale, or a new +// (type, rank) cell is edited in exactly one place and the two matrices cannot +// silently diverge (issue #46). +// +// What stays OUT of the catalog, on purpose: backend-specific expected +// outcomes. A cell can be a plain pass on one backend and a storage-model +// divergence or a known xfail on the other (e.g. MDSplus's STR_0D dimension +// divergence vs UDA's dynamic-leaf-inside-AOS defect). Those verdicts live +// local to each backend suite; only the shared curated path lives here. +// +// Path curation method (carried over from test_mdsplus_real_paths.cpp's +// header): resolved by walking the actual baked-from artifact, +// build-mdsplus/_deps/data-dictionary-src/IDSDef.xml (the DD 4.1.1 source +// ALBuildDataDictionary.cmake downloads and the model tree is compiled from), +// to find, per (type, rank) cell, a real field of that data_type together with +// its struct_array ancestor chain -- empirical characterization against the +// real shipped artifact (TEST_STRATEGY D2). imas-dd MCP validation is used for +// catalog changes. +#ifndef AL_CONTRACT_REAL_DD_PATH_CATALOG_H +#define AL_CONTRACT_REAL_DD_PATH_CATALOG_H + +#include +#include + +namespace al_contract { +namespace real_dd { + +// The four C-ABI scalar datatypes the breadth matrices sweep. +enum class DType { Char, Int, Double, Complex }; + +// The DD version every curated path in this catalog was validated against. +inline constexpr const char* kCatalogDdVersion = "4.1.1"; + +// The swept space: kNumDTypes datatypes x ranks [kMinRank, kMaxRank]. +inline constexpr int kNumDTypes = 4; +inline constexpr int kMinRank = 0; +inline constexpr int kMaxRank = 7; // MAXDIM +inline constexpr int kNumRanks = kMaxRank - kMinRank + 1; +inline constexpr int kNumCells = kNumDTypes * kNumRanks; + +// One curated (datatype, rank) cell -- the single source of truth for the DD +// path a backend breadth matrix stores at that cell. +// +// `terminal_gap_reason` non-null means DD kCatalogDdVersion has no field of +// this (dt, rank) at any nesting depth (a fact about the DD, shared by every +// backend); `ids`/`leaf` are then null and `aos_chain` empty. Otherwise the +// path fields name a real DD-conformant field: `aos_chain` lists the +// struct_array field(s) to enter in order (each may itself be a multi-segment +// path through intervening plain structures, e.g. "e_field/plus"); empty means +// the leaf hangs directly off the GLOBAL op. +// +// The CHAR rank axis is the raw C-ABI `dim` argument, one off from the DD's +// STR_ND suffix (IMAS convention: a *string* is CHAR dim=1, an *array of +// strings* is CHAR dim=2; dim=0 is a bare scalar char reusing the STR_0D node). +// INTEGER/DOUBLE/COMPLEX map dim directly to their _ND suffix. +struct CatalogEntry { + DType dt; + int rank; + const char* ids; // null iff terminal gap + std::vector aos_chain; // struct_array chain to enter + const char* leaf; // null iff terminal gap + const char* terminal_gap_reason; // null iff a real path exists +}; + +// The full curated catalog: exactly one entry per (dt, rank) cell, kNumCells +// entries, curated against DD kCatalogDdVersion. +const std::vector& catalog(); + +// Canonical, stable parameter-name suffix for a cell, e.g. "DOUBLE_r3". Used +// verbatim as the INSTANTIATE_TEST_SUITE_P case-name in both backend suites, so +// existing test names / TRACEABILITY references stay stable across the #46 +// extraction. +std::string case_name(DType dt, int rank); +inline std::string case_name(const CatalogEntry& e) { + return case_name(e.dt, e.rank); +} + +} // namespace real_dd +} // namespace al_contract + +#endif // AL_CONTRACT_REAL_DD_PATH_CATALOG_H diff --git a/tests/contract/sanitizer_suppressions.txt b/tests/contract/sanitizer_suppressions.txt new file mode 100644 index 00000000..65e3f22a --- /dev/null +++ b/tests/contract/sanitizer_suppressions.txt @@ -0,0 +1,21 @@ +# LeakSanitizer suppressions — IMAS-Core contract suite +# ====================================================== +# Decision D7 (TEST_STRATEGY.md): the contract suite is also built with +# ASan/UBSan + LeakSanitizer in a dedicated, NON-BLOCKING CI leg +# (.github/workflows/sanitizers.yml). This file catalogues known/triaged leaks +# so the leg stays green on already-understood issues and only *new* leaks stand +# out. The leg is promoted to a hard gate only after the baseline is triaged. +# +# Referenced at run time via: +# LSAN_OPTIONS=suppressions=:print_suppressions=1 +# +# Format (one per line): +# leak: +# A frame matching any substring suppresses the whole leak report it appears in. +# Examples (commented — enable only with a written justification + a tracking +# issue, never to silence a leak the suite itself introduced): +# leak:H5FL_* # HDF5 free-list pools not reclaimed at exit +# leak:libhdf5 +# +# Intentionally empty of active entries: no baseline leak has been triaged yet. +# Add an entry ONLY with a comment naming the tracking issue and why it's safe. diff --git a/tests/contract/test_backend_factory.cpp b/tests/contract/test_backend_factory.cpp new file mode 100644 index 00000000..5be97b25 --- /dev/null +++ b/tests/contract/test_backend_factory.cpp @@ -0,0 +1,104 @@ +// Unknown-backend-ID coverage at the C-ABI factory boundary (issue #47). +// +// TRACEABILITY.md Part 2 marked Backend::initBackend's unrecognized-ID throw +// path (src/al_backend.cpp) covered only "by inspection". These tests give the +// refusal a real verdict through the public surface, at the two spots an +// unknown backend identifier can arrive from a caller: +// +// - al_begin_dataentry_action with a URI naming an unknown (or empty) +// backend: DataEntryContext::setBackendID (src/al_context.cpp) is the +// factory-boundary gate that refuses it before initBackend runs. A +// regression that "defaults" an unrecognized name to a real backend makes +// the calls below succeed and the tests fail. +// - al_build_uri_from_legacy_parameters with an unknown positive numeric +// backend ID: DataEntryContext::getURIBackend refuses to synthesize a URI. +// +// Everything asserted here is hermetic (no backend substrate is ever +// constructed), so the suite name is classified into the `unit` tier by +// assign_contract_labels.cmake.in. + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include + +namespace { + +constexpr int kUntouchedCtx = -424242; // sentinel: must survive failed calls + +bool dir_is_empty(const std::string& p) { + namespace fs = std::filesystem; + return fs::exists(p) && fs::is_directory(p) && + fs::begin(fs::directory_iterator(p)) == fs::end(fs::directory_iterator(p)); +} + +// =========================================================================== +// URI surface: unknown / empty backend names. +// =========================================================================== + +TEST(BackendFactory, UnknownBackendNameInUriReturnsCleanError) { + al_contract::TempBase tmp; + int ctx = kUntouchedCtx; + const std::string uri = "imas:nosuchbackend?path=" + tmp.str() + "/pulse"; + + al_status_t s = al_begin_dataentry_action(uri.c_str(), + alconst::force_create_pulse, &ctx); + + EXPECT_EQ(s.code, alerror::unknown_err) << "message: " << s.message; + EXPECT_NE(std::string(s.message).find("Unable to identify a backend"), + std::string::npos) + << "actual message: " << s.message; + // No context may be handed out and no artifact left on disk. + EXPECT_EQ(ctx, kUntouchedCtx); + EXPECT_TRUE(dir_is_empty(tmp.str())); +} + +TEST(BackendFactory, EmptyBackendNameInUriReturnsCleanError) { + al_contract::TempBase tmp; + int ctx = kUntouchedCtx; + const std::string uri = "imas:?path=" + tmp.str() + "/pulse"; + + al_status_t s = al_begin_dataentry_action(uri.c_str(), + alconst::force_create_pulse, &ctx); + + EXPECT_NE(s.code, 0); + EXPECT_EQ(ctx, kUntouchedCtx); + EXPECT_TRUE(dir_is_empty(tmp.str())); +} + +TEST(BackendFactory, UnknownBackendNameIsRefusedForEveryOpenMode) { + al_contract::TempBase tmp; + const int modes[] = {alconst::open_pulse, alconst::force_open_pulse, + alconst::create_pulse, alconst::force_create_pulse}; + const std::string uri = "imas:nosuchbackend?path=" + tmp.str() + "/pulse"; + for (int mode : modes) { + int ctx = kUntouchedCtx; + al_status_t s = al_begin_dataentry_action(uri.c_str(), mode, &ctx); + EXPECT_EQ(s.code, alerror::unknown_err) << "mode=" << mode; + EXPECT_EQ(ctx, kUntouchedCtx) << "mode=" << mode; + } + EXPECT_TRUE(dir_is_empty(tmp.str())); +} + +// =========================================================================== +// Legacy surface: unknown positive numeric backend ID. +// =========================================================================== + +TEST(BackendFactory, UnknownPositiveLegacyBackendIdIsRefused) { + std::string uri; + al_status_t s = al_build_uri_from_legacy_parameters( + /*backendID=*/424242, /*pulse=*/1, /*run=*/1, "user", "test", "3", + /*options=*/"", uri); + + EXPECT_EQ(s.code, alerror::lowlevel_err) << "message: " << s.message; + EXPECT_NE(std::string(s.message).find("getURIBackend"), std::string::npos) + << "actual message: " << s.message; + EXPECT_TRUE(uri.empty()) << "no URI may be synthesized, got: " << uri; +} + +} // namespace diff --git a/tests/contract/test_capability_gated.cpp b/tests/contract/test_capability_gated.cpp new file mode 100644 index 00000000..dd7846b2 --- /dev/null +++ b/tests/contract/test_capability_gated.cpp @@ -0,0 +1,448 @@ +// Capability-gated operation contract tests (issue #6 / TEST_STRATEGY.md §4 step 4). +// +// Some operations are only meaningful on backends that advertise the matching +// Cluster-E capability (FUNCTIONALITY_INVENTORY.md — supportsTimeDataInterpolation +// / supportsTimeRangeOperation). Per decision D4 each such op is tested two ways: +// * POSITIVE where the backend supports it — the op runs and, wherever there is +// an oracle, its returned data is asserted exactly (not merely code==0). +// * PAIRED-NEGATIVE where it does not — the op must produce the *documented +// refusal* (a specific al_status_t error code), never a crash or a silent +// success. This puts the capability matrix itself under test at the C ABI +// boundary (decision D1: we cannot link the C++ Backend flags directly, so we +// assert their only ABI-observable consequence). +// +// The support columns below were established empirically against the current +// library (each value has a verified round trip), because two of them contradict +// the issue's stated assumptions and must follow D2 ("assert the real contract, +// tag defects as xfail — never freeze folklore"): +// +// op / backend HDF5 Memory ASCII Flexbuffers +// slice (R/W) y y* n n +// timerange (R) y n n n +// list_filled_paths y n n n +// +// * Memory genuinely supports slice storage + closest/previous/linear slice +// reads (src/memory_backend.cpp has full SLICE_OP handling); it is NOT a +// paired-negative for slice, even though supportsTimeRangeOperation()==false. +// What Memory lacks is time-RANGE operations, gated separately at +// src/al_lowlevel.cpp:1053. +// +// Refusal codes are the documented, stable part of the contract (D4 "specific +// error code"): +// * timerange on an unsupporting backend -> LOWLEVEL_ERR +// (src/al_lowlevel.cpp:1053-1055, ALLowlevelException) +// * slice / list_filled_paths unsupported -> BACKEND_ERR +// (backend beginAction / list_filled_paths throw ALBackendException) + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include +#include +#include + +using al_contract::PulseId; + +namespace { + +// --- opaque paths (the core attaches no DD semantics; strings are opaque) ----- +constexpr const char* kIds = "magnetics"; +constexpr const char* kHomogTime = "ids_properties/homogeneous_time"; +constexpr const char* kTimebase = "time"; // homogeneous shared timebase +constexpr const char* kSignal = "ip"; // an opaque dynamic leaf + +// The seeded dynamic signal: ip = f(time), a 2-point time axis. Chosen so slice +// interpolation has a non-trivial, exactly-representable oracle +// (LINEAR at the midpoint = 15.0, not a round-off). +constexpr double kT0 = 1.0, kT1 = 2.0; +constexpr double kV0 = 10.0, kV1 = 20.0; + +// --------------------------------------------------------------------------- +// Backend capability descriptor for the always-on tier (decision D4). +// --------------------------------------------------------------------------- +struct CapBackend { + int id; + const char* name; + bool on_disk; // needs the legacy //// tree + bool slice; // al_begin_slice_action accepted + bool timerange; // supportsTimeRangeOperation() (time-range ops accepted) + bool listpaths; // al_list_filled_paths implemented +}; + +// GoogleTest prints this wherever a CapBackend is shown (failure diagnostics and +// the discovered test-name suffix), so both read "HDF5"/"Memory"/… not a byte dump. +inline void PrintTo(const CapBackend& b, std::ostream* os) { *os << b.name; } + +const CapBackend kBackends[] = { + {HDF5_BACKEND, "HDF5", /*on_disk=*/true, /*slice=*/true, + /*timerange=*/true, /*listpaths=*/true}, + {MEMORY_BACKEND, "Memory", /*on_disk=*/false, /*slice=*/true, + /*timerange=*/false, /*listpaths=*/false}, + {ASCII_BACKEND, "ASCII", /*on_disk=*/true, /*slice=*/false, + /*timerange=*/false, /*listpaths=*/false}, + {FLEXBUFFERS_BACKEND, "Flexbuffers", /*on_disk=*/true, + /*slice=*/false, /*timerange=*/false, /*listpaths=*/false}, +#ifdef AL_CONTRACT_HAVE_MDSPLUS + // Confirmed empirically (issue #14): seed_signal's fields ("ip" as a + // top-level dynamic leaf of "magnetics", timebase "time") are real DD + // paths, so this fixture is fully applicable, unlike AosMatrix/ + // EquilibriumSeedMatrix above. supportsTimeDataInterpolation() is + // unconditionally true (src/mdsplus/mdsplus_backend.h:255-257) and slice + // read genuinely round-trips (CLOSEST/read gave back the written value). + // supportsTimeRangeOperation() is unconditionally false + // (src/mdsplus/mdsplus_backend.cpp:5444-5446) -> LOWLEVEL_ERR, matching + // the always-on non-HDF5 backends. list_filled_paths throws + // unconditionally (src/mdsplus/mdsplus_backend.cpp:4956-4958) -> BACKEND_ERR. + {MDSPLUS_BACKEND, "Mdsplus", /*on_disk=*/true, /*slice=*/true, + /*timerange=*/false, /*listpaths=*/false}, +#endif +}; + +// Write the 2-point dynamic signal through an ordinary GLOBAL write. Slice/ +// timerange are READ-side capabilities here, so the fixture is seeded with a +// plain global write (already proven by the round-trip matrix on every backend), +// and multi-point time selection is exercised on read. Returns true iff every +// write succeeded, so a downstream refusal can never be blamed on a missing seed. +bool seed_signal(int pctx) { + int op = -1; + if (al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code != 0) { + return false; + } + int ht = 1; + double t[2] = {kT0, kT1}; + double v[2] = {kV0, kV1}; + int shape[1] = {2}; + bool ok = true; + ok &= al_write_data(op, kHomogTime, "", &ht, INTEGER_DATA, 0, nullptr).code == 0; + ok &= al_write_data(op, kTimebase, kTimebase, t, DOUBLE_DATA, 1, shape).code == 0; + ok &= al_write_data(op, kSignal, kTimebase, v, DOUBLE_DATA, 1, shape).code == 0; + ok &= al_end_action(op).code == 0; + return ok; +} + +// Read the single scalar a slice read collapses the signal to. +al_status_t read_slice_value(int op, double* out) { + void* buf = nullptr; + int size[MAXDIM] = {0}; + al_status_t s = al_read_data(op, kSignal, kTimebase, &buf, DOUBLE_DATA, 1, size); + if (s.code == 0 && buf != nullptr && size[0] >= 1) { + *out = static_cast(buf)[0]; + } + free(buf); + return s; +} + +// =========================================================================== +// Parametrized capability matrix: one instance per always-on backend. +// =========================================================================== +class CapabilityMatrix : public ::testing::TestWithParam { +protected: + void SetUp() override { +#ifdef AL_CONTRACT_HAVE_MDSPLUS + if (GetParam().id == MDSPLUS_BACKEND) AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); +#endif + } + + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; + + // Open a fresh, seeded data entry into *pctx. Fails the test FATALLY on any + // setup error — via ASSERT_* here plus ASSERT_NO_FATAL_FAILURE at the call + // site — so a paired-negative refusal assertion downstream can never be a + // false positive produced by a broken seed rather than the genuine + // documented refusal. (ASSERT_* is only usable in a void-returning function, + // hence the out-param instead of a returned pctx.) + void open_seeded(int* pctx) { + const CapBackend& b = GetParam(); + if (b.on_disk) base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(b.id, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + *pctx = -1; + ASSERT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, pctx).code, 0); + ASSERT_TRUE(seed_signal(*pctx)) << "seed write must succeed on every backend"; + } +}; + +// --- timerange: positive on HDF5, paired-negative (LOWLEVEL_ERR) elsewhere --- +// Directly asserts supportsTimeRangeOperation() through its sole ABI +// manifestation (the guard at src/al_lowlevel.cpp:1053). +TEST_P(CapabilityMatrix, TimeRangeReadPositiveOrRefused) { + const CapBackend b = GetParam(); + int pctx = -1; + ASSERT_NO_FATAL_FAILURE(open_seeded(&pctx)); + + int op = -1; + double dtime = 0.0; + int dtime_shape = 0; // 0 => no resampling: return the raw stored slices + al_status_t s = al_begin_timerange_action(pctx, kIds, READ_OP, kT0, kT1, + &dtime, &dtime_shape, + UNDEFINED_INTERP, &op); + if (b.timerange) { + AL_EXPECT_OK(s); + void* buf = nullptr; + int size[MAXDIM] = {0}; + AL_EXPECT_OK(al_read_data(op, kSignal, kTimebase, &buf, DOUBLE_DATA, 1, size)); + ASSERT_EQ(size[0], 2) << "no-resample timerange returns both stored slices"; + EXPECT_EQ(static_cast(buf)[0], kV0); + EXPECT_EQ(static_cast(buf)[1], kV1); + free(buf); + EXPECT_EQ(al_end_action(op).code, 0); + } else { + EXPECT_NE(s.code, 0) << "unsupporting backend must refuse timerange"; + EXPECT_EQ(s.code, LOWLEVEL_ERR) + << "documented refusal is LOWLEVEL_ERR (al_lowlevel.cpp:1053)"; + } + al_close_pulse(pctx, CLOSE_PULSE); +} + +// --- slice: positive on HDF5 + Memory, paired-negative (BACKEND_ERR) elsewhere - +TEST_P(CapabilityMatrix, SliceReadPositiveOrRefused) { + const CapBackend b = GetParam(); + int pctx = -1; + ASSERT_NO_FATAL_FAILURE(open_seeded(&pctx)); + + int op = -1; + // A time between the two slices; CLOSEST resolves to kT0 -> kV0. + al_status_t s = al_begin_slice_action(pctx, kIds, READ_OP, 1.4, CLOSEST_INTERP, &op); + if (b.slice) { + AL_EXPECT_OK(s); + double val = -1.0; + AL_EXPECT_OK(read_slice_value(op, &val)); + EXPECT_EQ(val, kV0) << "CLOSEST slice to t=1.4 is t=1.0 -> " << kV0; + EXPECT_EQ(al_end_action(op).code, 0); + } else { + EXPECT_NE(s.code, 0) << "unsupporting backend must refuse slice"; + EXPECT_EQ(s.code, BACKEND_ERR) + << "documented refusal is BACKEND_ERR (backend beginAction throws)"; + } + al_close_pulse(pctx, CLOSE_PULSE); +} + +// --- slice WRITE (append via UNDEFINED_TIME): accepted iff the backend slices -- +// The issue calls out append/replace via UNDEFINED_TIME specifically; this pins +// that begin-slice-for-write is accepted where supported and refused where not. +TEST_P(CapabilityMatrix, SliceWriteBeginPositiveOrRefused) { + const CapBackend b = GetParam(); + int pctx = -1; + ASSERT_NO_FATAL_FAILURE(open_seeded(&pctx)); + + int op = -1; + al_status_t s = al_begin_slice_action(pctx, kIds, WRITE_OP, UNDEFINED_TIME, + UNDEFINED_INTERP, &op); + if (b.slice) { + AL_EXPECT_OK(s); + EXPECT_EQ(al_end_action(op).code, 0); + } else { + EXPECT_EQ(s.code, BACKEND_ERR) << "slice write must be refused (BACKEND_ERR)"; + } + al_close_pulse(pctx, CLOSE_PULSE); +} + +// --- list_filled_paths: positive on HDF5, paired-negative (BACKEND_ERR) else --- +TEST_P(CapabilityMatrix, ListFilledPathsPositiveOrRefused) { + const CapBackend b = GetParam(); + int pctx = -1; + ASSERT_NO_FATAL_FAILURE(open_seeded(&pctx)); + + char** paths = nullptr; + int n = -1; + al_status_t s = al_list_filled_paths(pctx, kIds, &paths, &n); + if (b.listpaths) { + AL_EXPECT_OK(s); + ASSERT_GE(n, 0); + // The caller owns the list and every string (al_lowlevel.h:499). + std::set got; + for (int i = 0; i < n; ++i) { + got.insert(paths[i]); + free(paths[i]); + } + free(paths); + // Every leaf we seeded must be discoverable (order is unspecified). + EXPECT_TRUE(got.count(kHomogTime)) << "seeded leaf missing from listing"; + EXPECT_TRUE(got.count(kTimebase)) << "seeded timebase missing from listing"; + EXPECT_TRUE(got.count(kSignal)) << "seeded signal missing from listing"; + } else { + EXPECT_NE(s.code, 0) << "unsupporting backend must refuse list_filled_paths"; + EXPECT_EQ(s.code, BACKEND_ERR) + << "documented refusal is BACKEND_ERR (not implemented in this backend)"; + } + al_close_pulse(pctx, CLOSE_PULSE); +} + +INSTANTIATE_TEST_SUITE_P( + Backends, CapabilityMatrix, ::testing::ValuesIn(kBackends), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.name); + }); + +// =========================================================================== +// HDF5-only positive detail: slice interpolation modes + timerange resampling. +// =========================================================================== +// These carry the real interpolation oracle (CLOSEST / PREVIOUS / LINEAR and the +// with/without-dtime timerange forms the issue enumerates). They run only on the +// one always-on backend that supports interpolation; the paired-negative half is +// already covered by CapabilityMatrix above. +class Hdf5TimeDependent : public ::testing::Test { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; + int pctx_ = -1; + + void SetUp() override { + base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(HDF5_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + ASSERT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx_).code, 0); + ASSERT_TRUE(seed_signal(pctx_)); + } + void TearDown() override { + if (pctx_ >= 0) al_close_pulse(pctx_, CLOSE_PULSE); + } + + double slice_read(double time, int interp) { + int op = -1; + EXPECT_EQ(al_begin_slice_action(pctx_, kIds, READ_OP, time, interp, &op).code, 0); + double val = -1.0; + EXPECT_EQ(read_slice_value(op, &val).code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + return val; + } +}; + +TEST_F(Hdf5TimeDependent, SliceInterpolationModes) { + // CLOSEST: nearest sample time. + EXPECT_EQ(slice_read(1.4, CLOSEST_INTERP), kV0); // 1.4 nearer 1.0 + EXPECT_EQ(slice_read(1.6, CLOSEST_INTERP), kV1); // 1.6 nearer 2.0 + // PREVIOUS: last sample at/below the requested time. + EXPECT_EQ(slice_read(1.9, PREVIOUS_INTERP), kV0); + // LINEAR: interpolate between bracketing samples (midpoint -> mean). + EXPECT_EQ(slice_read(1.5, LINEAR_INTERP), (kV0 + kV1) / 2.0); +} + +TEST_F(Hdf5TimeDependent, TimeRangeReadWithoutResampling) { + int op = -1; + double dtime = 0.0; + int dtime_shape = 0; // no resampling + ASSERT_EQ(al_begin_timerange_action(pctx_, kIds, READ_OP, kT0, kT1, &dtime, + &dtime_shape, UNDEFINED_INTERP, &op).code, 0); + void* buf = nullptr; + int size[MAXDIM] = {0}; + ASSERT_EQ(al_read_data(op, kSignal, kTimebase, &buf, DOUBLE_DATA, 1, size).code, 0); + ASSERT_EQ(size[0], 2); + EXPECT_EQ(static_cast(buf)[0], kV0); + EXPECT_EQ(static_cast(buf)[1], kV1); + free(buf); + EXPECT_EQ(al_end_action(op).code, 0); +} + +TEST_F(Hdf5TimeDependent, TimeRangeReadWithResampling) { + // Resample onto an explicit 3-point grid; LINEAR fills the new midpoint. + int op = -1; + double grid[3] = {1.0, 1.5, 2.0}; + int grid_len = 3; + ASSERT_EQ(al_begin_timerange_action(pctx_, kIds, READ_OP, kT0, kT1, grid, + &grid_len, LINEAR_INTERP, &op).code, 0); + void* buf = nullptr; + int size[MAXDIM] = {0}; + ASSERT_EQ(al_read_data(op, kSignal, kTimebase, &buf, DOUBLE_DATA, 1, size).code, 0); + ASSERT_EQ(size[0], 3) << "resampled onto the 3-point target grid"; + EXPECT_EQ(static_cast(buf)[0], kV0); + EXPECT_EQ(static_cast(buf)[1], (kV0 + kV1) / 2.0); + EXPECT_EQ(static_cast(buf)[2], kV1); + free(buf); + EXPECT_EQ(al_end_action(op).code, 0); +} + +// A slice READ with UNDEFINED_INTERP is a documented refusal (an interp mode is +// required for reads). Pins the CONTEXT_ERR contract (src/al_context.cpp:347). +TEST_F(Hdf5TimeDependent, SliceReadWithoutInterpModeIsRejected) { + int op = -1; + al_status_t s = al_begin_slice_action(pctx_, kIds, READ_OP, 1.5, UNDEFINED_INTERP, &op); + EXPECT_NE(s.code, 0) << "a slice read without an interp mode must be refused"; + EXPECT_EQ(s.code, CONTEXT_ERR) << "documented refusal: 'Missing interpmode'"; +} + +// =========================================================================== +// Slice append via UNDEFINED_TIME — KNOWN DEFECT (decision D2). +// =========================================================================== +// The issue calls for slice "append/replace via UNDEFINED_TIME". A correct +// slicing backend must let repeated WRITE+UNDEFINED_TIME slices *accumulate* +// (the header documents UNDEFINED_TIME as "append a slice"). Empirically, in +// this build each append overwrites the previous — only the last-written slice +// survives — regardless of UNDEFINED_TIME vs a concrete time, or where +// homogeneous_time is written. Per D2 the correct contract is asserted (as an +// expected-fail), never frozen as folklore; a current-behavior tripwire pins +// today's overwrite so the xfail can't rot. Write-lifecycle ownership is +// issue #3; the slice *contract* is pinned here. +class Hdf5SliceAppend : public ::testing::Test { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; + int pctx_ = -1; + + void SetUp() override { + base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(HDF5_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + ASSERT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx_).code, 0); + // Append two slices: (t=1.0 -> 10.0) then (t=2.0 -> 20.0). + const double t[2] = {kT0, kT1}; + const double v[2] = {kV0, kV1}; + for (int k = 0; k < 2; ++k) { + int op = -1; + EXPECT_EQ(al_begin_slice_action(pctx_, kIds, WRITE_OP, UNDEFINED_TIME, + UNDEFINED_INTERP, &op).code, 0); + if (k == 0) { + int ht = 1; + al_write_data(op, kHomogTime, "", &ht, INTEGER_DATA, 0, nullptr); + } + int one[1] = {1}; + al_write_data(op, kTimebase, kTimebase, const_cast(&t[k]), + DOUBLE_DATA, 1, one); + al_write_data(op, kSignal, kTimebase, const_cast(&v[k]), + DOUBLE_DATA, 1, one); + EXPECT_EQ(al_end_action(op).code, 0); + } + } + void TearDown() override { + if (pctx_ >= 0) al_close_pulse(pctx_, CLOSE_PULSE); + } + + // Number of stored slices a no-resample timerange read finds. + int stored_slice_count() { + int op = -1; + double dtime = 0.0; + int dtime_shape = 0; + EXPECT_EQ(al_begin_timerange_action(pctx_, kIds, READ_OP, 0.0, 10.0, &dtime, + &dtime_shape, UNDEFINED_INTERP, &op).code, 0); + void* buf = nullptr; + int size[MAXDIM] = {0}; + EXPECT_EQ(al_read_data(op, kSignal, kTimebase, &buf, DOUBLE_DATA, 1, size).code, 0); + free(buf); + EXPECT_EQ(al_end_action(op).code, 0); + return size[0]; + } +}; + +// CORRECT-CONTRACT, expected-fail (DISABLED_): both appended slices must persist. +// Drop the DISABLED_ prefix the day append works — the suite then documents it. +TEST_F(Hdf5SliceAppend, DISABLED_AppendedSlicesAllPersist) { + EXPECT_EQ(stored_slice_count(), 2) + << "two WRITE+UNDEFINED_TIME slices must both be readable"; +} + +// CURRENT-BEHAVIOR tripwire: today only the last-written slice survives. When +// append is fixed this goes red, forcing whoever fixed it to enable the +// correct-contract test above. +TEST_F(Hdf5SliceAppend, OnlyLastAppendedSlicePersists_CurrentBehavior) { + EXPECT_EQ(stored_slice_count(), 1) + << "append currently overwrites; if this is now 2 the defect is fixed — " + "enable Hdf5SliceAppend.AppendedSlicesAllPersist"; +} + +} // namespace diff --git a/tests/contract/test_hdf5_absolute_paths.cpp b/tests/contract/test_hdf5_absolute_paths.cpp new file mode 100644 index 00000000..ae9809b1 --- /dev/null +++ b/tests/contract/test_hdf5_absolute_paths.cpp @@ -0,0 +1,544 @@ +// HDF5 absolute field-path reads (issue #65). +// +// al_read_data's documented contract (include/al_lowlevel.h, read-data block) +// is that a fieldpath is relative to the passed context, "dataobject absolute +// path can be specified with a prepended '/'". An absolute fieldpath is NOT a +// filesystem path and does NOT carry the IDS name: "/time_slice/.../field" +// addresses the field from the dataobject root. +// +// HDF5Reader::read_ND_Data used to honor only the relative half: it replaced +// every '/' with '&' (the tensorized dataset-name separator) and then +// unconditionally prepended the current array-structure context's tensorized +// path, so an absolute spelling produced a malformed name -- +// time_slice[]&constraints&j_phi[]&&time_slice&constraints&j_phi&reconstructed +// for a dataset actually stored as +// time_slice[]&constraints&j_phi[]&reconstructed +// H5Lexists could not find it, the reader returned 0, and al_plugin_read_data +// filled the caller's buffer with EMPTY_DOUBLE while reporting code 0. Stored +// data was therefore indistinguishable from genuinely absent data -- a silent +// wrong answer, the failure mode this suite exists to catch. +// +// Every test below drives only the public C ABI (decision D1). The fix is a +// pure path/selection change in the HDF5 read path: no DD-version knowledge, +// no schema lookup, no ABI change -- so these tests read the same values back +// through two *spellings* of one address and assert they agree. +// +// Scope, per the issue: HDF5 *reads*. Writes, al_delete_data and +// al_begin_arraystruct_action are deliberately exercised only in their +// relative form here; whether they honor an absolute path is a separate +// question and must be filed as its own finding, not assumed by these tests. +// +// Fixture shape -- every path is a real equilibrium DD-4.1.1 path (the +// equilibrium_seed.h convention, issue #33), validated with the imas-dd tool +// suite; the core still treats them as opaque strings: +// +// vacuum_toroidal_field/r0 FLT_0D root scalar +// ids_properties/version_put/data_dictionary STR_0D root DD stamp +// time FLT_1D homogeneous timebase +// time_slice AOS timebasepath "time" +// time FLT_0D +// global_quantities/ip FLT_0D ancestor-level leaf +// profiles_1d/psi FLT_1D ancestor-level array +// constraints/j_phi AOS nested, multi-segment +// reconstructed FLT_0D the issue's own leaf +// measured FLT_0D +// source STR_0D array leaf (CHAR 1D) +// +// Both AOS levels hold more than one element and every value is distinct per +// (slice, constraint) pair, so a resolution bug that silently reads element +// zero -- the easy way to "fix" this wrongly -- cannot pass. + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include + +using al_contract::PulseId; + +namespace { + +constexpr const char* kIds = "equilibrium"; + +// --- addresses, in both spellings ------------------------------------------- +constexpr const char* kR0 = "vacuum_toroidal_field/r0"; +constexpr const char* kR0Abs = "/vacuum_toroidal_field/r0"; +constexpr const char* kDdStamp = "ids_properties/version_put/data_dictionary"; +constexpr const char* kDdStampAbs = "/ids_properties/version_put/data_dictionary"; +constexpr const char* kTimebase = "time"; +constexpr const char* kTimeSlice = "time_slice"; +constexpr const char* kJPhi = "constraints/j_phi"; // multi-segment AOS +constexpr const char* kIp = "global_quantities/ip"; +constexpr const char* kIpAbs = "/time_slice/global_quantities/ip"; +constexpr const char* kPsi = "profiles_1d/psi"; +constexpr const char* kPsiAbs = "/time_slice/profiles_1d/psi"; +constexpr const char* kReconstructed = "reconstructed"; +constexpr const char* kReconstructedAbs = + "/time_slice/constraints/j_phi/reconstructed"; +constexpr const char* kMeasuredAbs = "/time_slice/constraints/j_phi/measured"; +constexpr const char* kSource = "source"; +constexpr const char* kSourceAbs = "/time_slice/constraints/j_phi/source"; +// Never written by the seed: the "genuinely absent" control. +constexpr const char* kWeightAbs = "/time_slice/constraints/j_phi/weight"; + +constexpr int kNSlices = 3; // > 1 so a nonzero outer cursor is reachable +constexpr int kNJPhi = 3; // > 1 so a nonzero inner cursor is reachable +constexpr int kRadialLen = 4; + +// --- deterministic content, distinct per (slice, constraint) ---------------- +double r0_value() { return 6.2; } +double slice_time(int i) { return 1.0 + 0.5 * i; } +double ip(int i) { return 100.0 + 7.0 * i; } +double reconstructed(int i, int j) { return 47.0 + 10.0 * i + j; } +double measured(int i, int j) { return 200.0 + 10.0 * i + j; } + +std::string source(int i, int j) { + return "src-" + std::to_string(i) + "-" + std::to_string(j); +} +std::string dd_stamp() { return "4.1.1"; } + +std::vector timebase() { + std::vector t; + for (int i = 0; i < kNSlices; ++i) t.push_back(slice_time(i)); + return t; +} +std::vector psi(int i) { + std::vector v; + for (int r = 0; r < kRadialLen; ++r) v.push_back(10.0 * i + r + 0.25); + return v; +} + +// --------------------------------------------------------------------------- +// Fixture: one freshly seeded on-disk HDF5 pulse per test. +// --------------------------------------------------------------------------- +class Hdf5AbsoluteFieldPaths : public ::testing::Test { +protected: + void SetUp() override { + base_.make_legacy_tree(pulse_); + const std::string uri = + al_contract::build_uri(HDF5_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &pctx_)); + ASSERT_NO_FATAL_FAILURE(write_seed()); + } + + void TearDown() override { + if (pctx_ != -1) al_close_pulse(pctx_, CLOSE_PULSE); + } + + // Writes the fixture shape above. Relative spellings only -- the write + // side's absolute handling is out of this issue's scope. + void write_seed() { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx_, kIds, "", WRITE_OP, &op)); + AL_ASSERT_OK(al_contract::write_data(op, kR0, {}, {r0_value()})); + AL_ASSERT_OK(al_contract::write_char_array(op, kDdStamp, dd_stamp())); + AL_ASSERT_OK( + al_contract::write_data(op, kTimebase, {kNSlices}, timebase())); + + int slices = kNSlices; + int ts = -1; + AL_ASSERT_OK( + al_begin_arraystruct_action(op, kTimeSlice, kTimebase, &slices, &ts)); + for (int i = 0; i < kNSlices; ++i) { + AL_ASSERT_OK( + al_contract::write_data(ts, "time", {}, {slice_time(i)})); + AL_ASSERT_OK(al_contract::write_data(ts, kIp, {}, {ip(i)})); + AL_ASSERT_OK( + al_contract::write_data(ts, kPsi, {kRadialLen}, psi(i))); + + int constraints = kNJPhi; + int jp = -1; + AL_ASSERT_OK( + al_begin_arraystruct_action(ts, kJPhi, "", &constraints, &jp)); + for (int j = 0; j < kNJPhi; ++j) { + AL_ASSERT_OK(al_contract::write_data( + jp, kReconstructed, {}, {reconstructed(i, j)})); + AL_ASSERT_OK(al_contract::write_data(jp, "measured", {}, + {measured(i, j)})); + AL_ASSERT_OK(al_contract::write_char_array(jp, kSource, + source(i, j))); + if (j + 1 < kNJPhi) + AL_ASSERT_OK(al_iterate_over_arraystruct(jp, 1)); + } + AL_ASSERT_OK(al_end_action(jp)); + if (i + 1 < kNSlices) AL_ASSERT_OK(al_iterate_over_arraystruct(ts, 1)); + } + AL_ASSERT_OK(al_end_action(ts)); + AL_ASSERT_OK(al_end_action(op)); + } + + // Opens a GLOBAL read, walks the outer AOS to element `slice` and the + // nested AOS to element `constraint`, then hands the body the three live + // contexts (operation, time_slice, constraints/j_phi). + // + // The cursor is advanced element by element, exactly as an HLI drives it; + // that is what makes "the current cursor" a real, nonzero position rather + // than a freshly opened context that happens to sit at zero. + template + void at(int slice, int constraint, Body body) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx_, kIds, "", READ_OP, &op)); + + int slices = 0; + int ts = -1; + AL_ASSERT_OK( + al_begin_arraystruct_action(op, kTimeSlice, kTimebase, &slices, &ts)); + ASSERT_EQ(slices, kNSlices); + for (int i = 0; i < slice; ++i) + AL_ASSERT_OK(al_iterate_over_arraystruct(ts, 1)); + + int constraints = 0; + int jp = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(ts, kJPhi, "", &constraints, &jp)); + ASSERT_EQ(constraints, kNJPhi); + for (int j = 0; j < constraint; ++j) + AL_ASSERT_OK(al_iterate_over_arraystruct(jp, 1)); + + body(op, ts, jp); + + al_end_action(jp); + al_end_action(ts); + al_end_action(op); + } + + // Reads one DOUBLE scalar; returns the status and the value the core left + // in the caller's buffer (EMPTY_DOUBLE when the field is reported absent). + static al_status_t read_scalar(int ctx, const char* field, double* out) { + *out = al_contract::kEmptyDouble; + int size[MAXDIM] = {0}; + void* buf = out; + return al_read_data(ctx, field, "", &buf, DOUBLE_DATA, 0, size); + } + + // The same read through the plugin entry point. al_read_data delegates to + // al_plugin_read_data whenever no plugin is bound to the field + // (al_lowlevel.cpp), so the two normally share this code path -- but the + // issue's independent reproducer reached the core through this symbol + // directly, and a caller that interposes the plugin layer sees only this + // one. Asserting both keeps the pair from silently diverging. + static al_status_t plugin_read_scalar(int ctx, const char* field, double* out) { + *out = al_contract::kEmptyDouble; + int size[MAXDIM] = {0}; + void* buf = out; + return al_plugin_read_data(ctx, field, "", &buf, DOUBLE_DATA, 0, size); + } + + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/65, /*run=*/0}; + int pctx_ = -1; +}; + +// =========================================================================== +// The issue's headline case: a scalar leaf beneath a nested AOS, addressed +// absolutely from inside that same AOS. +// =========================================================================== +TEST_F(Hdf5AbsoluteFieldPaths, ScalarUnderNestedAosAgreesWithRelative) { + // Nonzero on both AOS levels: an implementation that ignores the cursor + // and reads element (0,0) reads 47.0 and would pass at (0,0) alone. + constexpr int kSlice = 1, kConstraint = 2; + at(kSlice, kConstraint, [&](int, int, int jp) { + double relative = 0.0, absolute = 0.0; + AL_EXPECT_OK(read_scalar(jp, kReconstructed, &relative)); + AL_EXPECT_OK(read_scalar(jp, kReconstructedAbs, &absolute)); + + EXPECT_DOUBLE_EQ(relative, reconstructed(kSlice, kConstraint)) + << "the relative spelling is the control -- if this fails the " + "fixture, not the absolute-path resolution, is wrong"; + EXPECT_DOUBLE_EQ(absolute, reconstructed(kSlice, kConstraint)) + << "an absolute fieldpath must address the same stored datum as " + "the relative one (al_lowlevel.h read-data contract); " + "EMPTY_DOUBLE here is the issue-#65 silent-absence bug"; + }); +} + +// The same assertion through al_plugin_read_data, the entry point the issue's +// independent baseline resolved with dlsym. +TEST_F(Hdf5AbsoluteFieldPaths, ScalarUnderNestedAosAgreesThroughPluginEntryPoint) { + constexpr int kSlice = 1, kConstraint = 2; + at(kSlice, kConstraint, [&](int, int, int jp) { + double relative = 0.0, absolute = 0.0; + AL_EXPECT_OK(plugin_read_scalar(jp, kReconstructed, &relative)); + AL_EXPECT_OK(plugin_read_scalar(jp, kReconstructedAbs, &absolute)); + EXPECT_DOUBLE_EQ(relative, reconstructed(kSlice, kConstraint)); + EXPECT_DOUBLE_EQ(absolute, reconstructed(kSlice, kConstraint)) + << "al_plugin_read_data must resolve an absolute fieldpath exactly " + "as al_read_data does"; + }); +} + +// Every (slice, constraint) pair, so the resolved AOS selection is shown to +// track the live cursor on both levels rather than matching at one position. +TEST_F(Hdf5AbsoluteFieldPaths, AbsoluteReadTracksBothCursors) { + for (int i = 0; i < kNSlices; ++i) { + for (int j = 0; j < kNJPhi; ++j) { + at(i, j, [&](int, int, int jp) { + double absolute = 0.0, absolute_measured = 0.0; + AL_EXPECT_OK(read_scalar(jp, kReconstructedAbs, &absolute)); + AL_EXPECT_OK(read_scalar(jp, kMeasuredAbs, &absolute_measured)); + EXPECT_DOUBLE_EQ(absolute, reconstructed(i, j)) + << "slice=" << i << " constraint=" << j; + EXPECT_DOUBLE_EQ(absolute_measured, measured(i, j)) + << "slice=" << i << " constraint=" << j; + }); + } + } +} + +// =========================================================================== +// Array leaf beneath the nested AOS: rank and extent must survive resolution, +// not just the first element. +// =========================================================================== +TEST_F(Hdf5AbsoluteFieldPaths, ArrayLeafUnderNestedAosAgreesWithRelative) { + constexpr int kSlice = 2, kConstraint = 1; + at(kSlice, kConstraint, [&](int, int, int jp) { + std::string relative, absolute; + AL_EXPECT_OK(al_contract::read_char_array(jp, kSource, &relative)); + AL_EXPECT_OK(al_contract::read_char_array(jp, kSourceAbs, &absolute)); + + EXPECT_EQ(relative, source(kSlice, kConstraint)); + EXPECT_EQ(absolute, source(kSlice, kConstraint)) + << "a CHAR array leaf must resolve identically through both " + "spellings, extent included"; + }); +} + +// =========================================================================== +// Ancestor targets: the retained selection must be the ancestor's, not the +// child's. Applying the full child cursor to an ancestor-level dataset is the +// other way to get this wrong. +// =========================================================================== +TEST_F(Hdf5AbsoluteFieldPaths, AncestorScalarKeepsOnlyTheAncestorCursor) { + constexpr int kSlice = 2, kConstraint = 1; + at(kSlice, kConstraint, [&](int, int ts, int jp) { + double from_parent = 0.0, from_child = 0.0; + // Same datum, reached two ways: relatively from the time_slice + // context, and absolutely from one level deeper. + AL_EXPECT_OK(read_scalar(ts, kIp, &from_parent)); + AL_EXPECT_OK(read_scalar(jp, kIpAbs, &from_child)); + + EXPECT_DOUBLE_EQ(from_parent, ip(kSlice)); + EXPECT_DOUBLE_EQ(from_child, ip(kSlice)) + << "an ancestor-level field addressed from a child context must " + "keep the time_slice index and drop the constraints/j_phi one"; + }); +} + +TEST_F(Hdf5AbsoluteFieldPaths, AncestorArrayKeepsOnlyTheAncestorCursor) { + constexpr int kSlice = 1, kConstraint = 2; + at(kSlice, kConstraint, [&](int, int ts, int jp) { + std::vector parent_shape, child_shape; + std::vector parent_data, child_data; + AL_EXPECT_OK( + al_contract::read_data(ts, kPsi, 1, &parent_shape, &parent_data)); + AL_EXPECT_OK(al_contract::read_data(jp, kPsiAbs, 1, &child_shape, + &child_data)); + + ASSERT_EQ(parent_shape, std::vector{kRadialLen}); + EXPECT_EQ(parent_data, psi(kSlice)); + EXPECT_EQ(child_shape, std::vector{kRadialLen}) + << "the absolute read must report the ancestor array's real extent"; + EXPECT_EQ(child_data, psi(kSlice)); + }); +} + +// =========================================================================== +// Root targets: no AOS index applies at all. Reached from the deepest context +// (both cursors live) and from the plain operation context. +// =========================================================================== +TEST_F(Hdf5AbsoluteFieldPaths, RootScalarReadableFromNestedContext) { + constexpr int kSlice = 2, kConstraint = 2; + at(kSlice, kConstraint, [&](int op, int ts, int jp) { + double from_op = 0.0, from_slice = 0.0, from_constraint = 0.0; + AL_EXPECT_OK(read_scalar(op, kR0, &from_op)); + AL_EXPECT_OK(read_scalar(ts, kR0Abs, &from_slice)); + AL_EXPECT_OK(read_scalar(jp, kR0Abs, &from_constraint)); + + EXPECT_DOUBLE_EQ(from_op, r0_value()); + EXPECT_DOUBLE_EQ(from_slice, r0_value()) + << "a root field must stay readable from inside an AOS"; + EXPECT_DOUBLE_EQ(from_constraint, r0_value()) + << "... and from inside a nested AOS, with neither index applied"; + }); +} + +// The DD stamp is an ordinary root string field (the core attaches no meaning +// to it) -- and it is exactly what a version-agnostic reader asks for first, +// from whatever context it happens to hold. +TEST_F(Hdf5AbsoluteFieldPaths, RootDdStampReadableFromNestedContext) { + at(1, 1, [&](int op, int, int jp) { + std::string from_op, from_constraint; + AL_EXPECT_OK(al_contract::read_char_array(op, kDdStamp, &from_op)); + AL_EXPECT_OK(al_contract::read_char_array(jp, kDdStampAbs, &from_constraint)); + EXPECT_EQ(from_op, dd_stamp()); + EXPECT_EQ(from_constraint, dd_stamp()); + }); +} + +// An absolute spelling used from the operation context itself: no AOS is open, +// so the leading '/' is the only thing to strip. This is the same defect with +// an empty context chain, and it must not regress the relative form. +TEST_F(Hdf5AbsoluteFieldPaths, AbsoluteAndRelativeAgreeAtRootContext) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx_, kIds, "", READ_OP, &op)); + + double relative = 0.0, absolute = 0.0; + AL_EXPECT_OK(read_scalar(op, kR0, &relative)); + AL_EXPECT_OK(read_scalar(op, kR0Abs, &absolute)); + EXPECT_DOUBLE_EQ(relative, r0_value()); + EXPECT_DOUBLE_EQ(absolute, r0_value()) + << "at the operation context an absolute fieldpath differs from the " + "relative one only by its leading '/'"; + + std::vector shape; + std::vector data; + AL_EXPECT_OK( + al_contract::read_data(op, "/time", 1, &shape, &data)); + EXPECT_EQ(shape, std::vector{kNSlices}); + EXPECT_EQ(data, timebase()) << "the root timebase array too"; + + al_end_action(op); +} + +// =========================================================================== +// Ordinary missing-data semantics must be untouched: the fix makes stored data +// findable, it must not make absent data look present. +// =========================================================================== +TEST_F(Hdf5AbsoluteFieldPaths, GenuinelyAbsentFieldsStayAbsent) { + constexpr int kSlice = 1, kConstraint = 1; + at(kSlice, kConstraint, [&](int op, int, int jp) { + // Never written, addressed absolutely... + double absent_abs = 0.0; + AL_EXPECT_OK(read_scalar(jp, kWeightAbs, &absent_abs)); + EXPECT_DOUBLE_EQ(absent_abs, al_contract::kEmptyDouble) + << "an unwritten field must still report the absent sentinel"; + + // ... and relatively, the control. + double absent_rel = 0.0; + AL_EXPECT_OK(read_scalar(jp, "weight", &absent_rel)); + EXPECT_DOUBLE_EQ(absent_rel, al_contract::kEmptyDouble); + + // A root-level path that does not exist at all. + double absent_root = 0.0; + AL_EXPECT_OK(read_scalar(jp, "/no_such_structure/no_such_field", + &absent_root)); + EXPECT_DOUBLE_EQ(absent_root, al_contract::kEmptyDouble); + + // An absolute path whose leading segment matches the open AOS but + // whose remainder does not exist: this must resolve and come back + // absent, not accidentally alias a sibling that does exist. + double absent_sibling = 0.0; + AL_EXPECT_OK(read_scalar(jp, "/time_slice/global_quantities/no_such_leaf", + &absent_sibling)); + EXPECT_DOUBLE_EQ(absent_sibling, al_contract::kEmptyDouble); + + double still_there = 0.0; + AL_EXPECT_OK(read_scalar(op, kR0, &still_there)); + EXPECT_DOUBLE_EQ(still_there, r0_value()) + << "the absent lookups must not poison neighbouring reads"; + }); +} + +// =========================================================================== +// The decided limit of the rule, pinned so it reads as a decision rather than +// an accident (docs/adr/0002-absolute-field-path-resolution.md). +// =========================================================================== +// An absolute path is resolved against the AOS chain the caller has actually +// opened. A target that crosses an AOS the caller never entered has no cursor +// to be read at, and the core holds no schema that would even tell it that +// segment IS an array of structures -- so the name resolves without that +// level's "[]", finds nothing, and is reported absent. The alternative would be +// to invent an index, which is how a wrong number gets returned instead of no +// number at all. +TEST_F(Hdf5AbsoluteFieldPaths, TargetBeyondAnUnopenedAosIsReportedAbsent) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx_, kIds, "", READ_OP, &op)); + int slices = 0, ts = -1; + AL_ASSERT_OK( + al_begin_arraystruct_action(op, kTimeSlice, kTimebase, &slices, &ts)); + ASSERT_EQ(slices, kNSlices); + + // constraints/j_phi is never opened here, so this names no single datum. + double beyond = 0.0; + AL_EXPECT_OK(read_scalar(ts, kReconstructedAbs, &beyond)); + EXPECT_DOUBLE_EQ(beyond, al_contract::kEmptyDouble) + << "with no cursor for constraints/j_phi the read must report absent, " + "not silently pick an element"; + + // The ancestor level that *is* open still resolves, at its own cursor. + double ancestor = 0.0; + AL_EXPECT_OK(read_scalar(ts, kIpAbs, &ancestor)); + EXPECT_DOUBLE_EQ(ancestor, ip(0)); + + al_end_action(ts); + al_end_action(op); +} + +// =========================================================================== +// Dataset-name caching: the reader memoizes both "this dataset exists" and the +// open dataset handle under the resolved name. Repeating the reads in mixed +// order, and re-reading after the cursor moves, proves the cache is keyed on +// the *resolved* name and not on the spelling the caller used. +// =========================================================================== +TEST_F(Hdf5AbsoluteFieldPaths, RepeatedMixedSpellingsStayConsistent) { + constexpr int kSlice = 2, kConstraint = 0; + at(kSlice, kConstraint, [&](int, int, int jp) { + const double expected = reconstructed(kSlice, kConstraint); + for (int repeat = 0; repeat < 3; ++repeat) { + double absolute = 0.0, relative = 0.0; + AL_EXPECT_OK(read_scalar(jp, kReconstructedAbs, &absolute)); + AL_EXPECT_OK(read_scalar(jp, kReconstructed, &relative)); + EXPECT_DOUBLE_EQ(absolute, expected) << "repeat " << repeat; + EXPECT_DOUBLE_EQ(relative, expected) << "repeat " << repeat; + } + + // An absent absolute lookup is cached too; it must not turn a later + // present one into a miss. + double absent = 0.0; + AL_EXPECT_OK(read_scalar(jp, kWeightAbs, &absent)); + EXPECT_DOUBLE_EQ(absent, al_contract::kEmptyDouble); + + double after = 0.0; + AL_EXPECT_OK(read_scalar(jp, kReconstructedAbs, &after)); + EXPECT_DOUBLE_EQ(after, expected); + }); +} + +// Within one open AOS context, advancing the cursor must move what an absolute +// path resolves to -- the cached dataset handle is shared across iterations, +// so this is where a name/selection mix-up would surface. +TEST_F(Hdf5AbsoluteFieldPaths, AbsoluteReadFollowsIterationWithinOneContext) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx_, kIds, "", READ_OP, &op)); + int slices = 0, ts = -1; + AL_ASSERT_OK( + al_begin_arraystruct_action(op, kTimeSlice, kTimebase, &slices, &ts)); + ASSERT_EQ(slices, kNSlices); + + for (int i = 0; i < kNSlices; ++i) { + int constraints = 0, jp = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(ts, kJPhi, "", &constraints, &jp)); + ASSERT_EQ(constraints, kNJPhi); + for (int j = 0; j < kNJPhi; ++j) { + double absolute = 0.0, ancestor = 0.0; + AL_EXPECT_OK(read_scalar(jp, kReconstructedAbs, &absolute)); + AL_EXPECT_OK(read_scalar(jp, kIpAbs, &ancestor)); + EXPECT_DOUBLE_EQ(absolute, reconstructed(i, j)) + << "slice=" << i << " constraint=" << j; + EXPECT_DOUBLE_EQ(ancestor, ip(i)) + << "slice=" << i << " constraint=" << j; + if (j + 1 < kNJPhi) AL_EXPECT_OK(al_iterate_over_arraystruct(jp, 1)); + } + al_end_action(jp); + if (i + 1 < kNSlices) AL_EXPECT_OK(al_iterate_over_arraystruct(ts, 1)); + } + al_end_action(ts); + al_end_action(op); +} + +} // namespace diff --git a/tests/contract/test_hdf5_version_drift.cpp b/tests/contract/test_hdf5_version_drift.cpp new file mode 100644 index 00000000..f3000b6b --- /dev/null +++ b/tests/contract/test_hdf5_version_drift.cpp @@ -0,0 +1,118 @@ +// HDF5 stored-backend-version drift check (issue #36, TRACEABILITY.md Part 2 +// row B). +// +// al_begin_dataentry_action (src/al_lowlevel.cpp) compares the backend's +// compiled version (`getVersion(NULL)`, HDF5: {1,0}) against the version +// stored in the pulse being opened (`getVersion(pctx)`, HDF5: the +// HDF5_BACKEND_VERSION attribute of master.h5) on OPEN_PULSE/FORCE_OPEN_PULSE +// and throws LOWLEVEL_ERR on a mismatch. Part 2 row B previously declared +// this reachable check a terminal `gap` because forcing a mismatch needs +// out-of-band HDF5 attribute surgery; that surgery now lives in the isolated +// fixture producer hdf5_fixture_tool.cpp (same producer/observer split as the +// MDSplus drift tier), so this file asserts the contract exclusively through +// the public C ABI — no HDF5 include or link enters contract_tests. +// +// Empirically characterized: for HDF5 the generic LOWLEVEL_ERR drift branch +// is SHADOWED — HDF5Backend::openPulse constructs its reader/writer through +// HDF5BackendFactory, which accepts exactly "1.0" +// (src/hdf5/hdf5_backend_factory.cpp) and throws for any other stored value, +// so an incompatible pulse is refused with BACKEND_ERR ("No backend writer +// with version: ") BEFORE al_lowlevel.cpp's comparison ever runs. +// Every stored version other than the compiled one is thereby rejected — +// stricter than the generic check (which would tolerate an older minor). +// These pins assert that exact current refusal. +#ifdef AL_CONTRACT_HDF5_FIXTURE_TOOL + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include + +namespace { + +class Hdf5VersionDrift : public ::testing::Test { +protected: + // Creates a genuine pulse through the public C ABI and returns its + // directory (the URI "path" query parameter). + std::string create_pulse() { + const std::string dir = base_.str() + "/pulse"; + const std::string uri = "imas:hdf5?path=" + dir; + int ctx = -1; + EXPECT_EQ( + al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &ctx) + .code, + 0); + EXPECT_EQ(al_close_pulse(ctx, CLOSE_PULSE).code, 0); + return dir; + } + + // Out-of-band fixture mutation: rewrite the pulse's stored + // HDF5_BACKEND_VERSION via the isolated setup tool. Fixture producer + // only — nothing asserted here is part of the contract. + void set_stored_version(const std::string& dir, + const std::string& version) { + const std::string cmd = std::string("\"") + + AL_CONTRACT_HDF5_FIXTURE_TOOL + "\" \"" + dir + + "\" \"" + version + "\""; + ASSERT_EQ(std::system(cmd.c_str()), 0) + << "fixture tool failed: " << cmd; + } + + al_status_t open_pulse(const std::string& dir, int* ctx) { + const std::string uri = "imas:hdf5?path=" + dir; + return al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, ctx); + } + + al_contract::TempBase base_; +}; + +// Baseline: a pulse created and reopened by the same compiled backend carries +// a matching stored version, so the drift check is a no-op and OPEN succeeds. +TEST_F(Hdf5VersionDrift, MatchingStoredVersionOpensCleanly) { + const std::string dir = create_pulse(); + int ctx = -1; + al_status_t s = open_pulse(dir, &ctx); + EXPECT_EQ(s.code, 0) << "message: " << s.message; + if (s.code == 0) { + EXPECT_EQ(al_close_pulse(ctx, CLOSE_PULSE).code, 0); + } +} + +// A stored major the compiled backend (1.x) can never match is refused — +// never a silent open of incompatible data. Exact observed status: the +// factory's BACKEND_ERR, which fires before the generic LOWLEVEL_ERR drift +// comparison (see the header note). +TEST_F(Hdf5VersionDrift, HigherStoredMajorRefusesOpen) { + const std::string dir = create_pulse(); + set_stored_version(dir, "999.0"); + int ctx = -1; + al_status_t s = open_pulse(dir, &ctx); + EXPECT_EQ(s.code, alerror::backend_err) << "message: " << s.message; + EXPECT_NE(std::string(s.message).find("No backend writer with version"), + std::string::npos) + << "message: " << s.message; + EXPECT_NE(std::string(s.message).find("999.0"), std::string::npos) + << "the refusal must name the stored version; message: " << s.message; +} + +// The minor comparison boundary: a stored minor NEWER than the compiled one +// is likewise refused (the factory accepts exactly "1.0", so this is refused +// by the same shadowing BACKEND_ERR, not the generic minor comparison). +TEST_F(Hdf5VersionDrift, NewerStoredMinorRefusesOpen) { + const std::string dir = create_pulse(); + set_stored_version(dir, "1.999"); + int ctx = -1; + al_status_t s = open_pulse(dir, &ctx); + EXPECT_EQ(s.code, alerror::backend_err) << "message: " << s.message; + EXPECT_NE(std::string(s.message).find("1.999"), std::string::npos) + << "message: " << s.message; +} + +} // namespace + +#endif // AL_CONTRACT_HDF5_FIXTURE_TOOL diff --git a/tests/contract/test_introspection.cpp b/tests/contract/test_introspection.cpp new file mode 100644 index 00000000..a4e22290 --- /dev/null +++ b/tests/contract/test_introspection.cpp @@ -0,0 +1,176 @@ +// Introspection / diagnostics contract tests — the thin unit tier. +// +// TEST_STRATEGY.md §4 step 3 + issue #5: the pure-lookup surface of the C ABI +// (FUNCTIONALITY_INVENTORY.md "Cluster 4 — Introspection / diagnostics", +// lines 434-459). These four functions take no context and touch no backend, +// so they are the genuinely-unit part of a C-ABI-only layer (decision D6) and +// need zero DD artifacts (decision D5). +// +// Every assertion here is *intended contract* (decision D2), asserted +// directly — including the silent "" for unmapped ids, which the inventory +// documents as deliberate (al_const.cpp:4-11), not a defect. Nothing in this +// file is expected-fail; the crash-class defects live in test_known_defects.cpp. + +#include +#include // AL_VERSION, DD_VERSION — the compiled-in macros we + // check getALVersion()/getDDVersion() actually return. + +#include + +#include + +namespace { + +// --------------------------------------------------------------------------- +// const2str — maps a vocabulary constant to its symbolic name. +// --------------------------------------------------------------------------- + +// Mapped ids: a representative spread across the constmap categories (backend, +// operation, access mode, pulse action, datatype). Table-driven so a missing +// or renamed entry names itself in the failure. +TEST(Introspection, Const2StrMapsKnownConstantsToTheirNames) { + struct Case { int id; const char* name; }; + const Case cases[] = { + {NO_BACKEND, "NO_BACKEND"}, + {ASCII_BACKEND, "ASCII_BACKEND"}, + {MDSPLUS_BACKEND, "MDSPLUS_BACKEND"}, + {HDF5_BACKEND, "HDF5_BACKEND"}, + {MEMORY_BACKEND, "MEMORY_BACKEND"}, + {UDA_BACKEND, "UDA_BACKEND"}, + {GLOBAL_OP, "GLOBAL_OP"}, + {SLICE_OP, "SLICE_OP"}, + {READ_OP, "READ_OP"}, + {WRITE_OP, "WRITE_OP"}, + {OPEN_PULSE, "OPEN_PULSE"}, + {FORCE_CREATE_PULSE, "FORCE_CREATE_PULSE"}, + {CLOSE_PULSE, "CLOSE_PULSE"}, + {ERASE_PULSE, "ERASE_PULSE"}, + {CHAR_DATA, "CHAR_DATA"}, + {INTEGER_DATA, "INTEGER_DATA"}, + {DOUBLE_DATA, "DOUBLE_DATA"}, + {COMPLEX_DATA, "COMPLEX_DATA"}, + }; + for (const Case& c : cases) { + const char* got = const2str(c.id); + ASSERT_NE(got, nullptr) << "const2str must never return NULL (id=" << c.id << ")"; + EXPECT_EQ(std::string(got), c.name) << "const2str(" << c.id << ")"; + } +} + +// Unmapped id: an out-of-range value is not in constmap, so the documented +// contract is a silent empty string (NOT NULL, NOT an error). al_const.cpp:7-8. +TEST(Introspection, Const2StrReturnsEmptyForUnmappedId) { + const char* got = const2str(999999); + ASSERT_NE(got, nullptr) << "unmapped id must yield \"\", never NULL"; + EXPECT_STREQ(got, "") << "unmapped id must silently return an empty string"; +} + +// Known map-coverage gaps, asserted as the *intended* silent-"" contract: +// FUNCTIONALITY_INVENTORY.md:75-79 & 451-454 record that TIMERANGE_OP and +// FLEXBUFFERS_BACKEND are defined constants deliberately absent from +// alconst::constmap, so const2str returns "" for them rather than erroring. +// This pins that documented quirk so a future map change is a visible event. +TEST(Introspection, Const2StrReturnsEmptyForDefinedButUnmappedConstants) { + EXPECT_STREQ(const2str(TIMERANGE_OP), "") + << "TIMERANGE_OP is a defined constant absent from constmap " + "(FUNCTIONALITY_INVENTORY.md:451-454)"; + EXPECT_STREQ(const2str(FLEXBUFFERS_BACKEND), "") + << "FLEXBUFFERS_BACKEND is a defined constant absent from constmap " + "(FUNCTIONALITY_INVENTORY.md:75-79)"; +} + +// --------------------------------------------------------------------------- +// err2str — maps an error code to its name (a separate map from const2str). +// --------------------------------------------------------------------------- + +TEST(Introspection, Err2StrMapsKnownErrorCodesToTheirNames) { + struct Case { int id; const char* name; }; + const Case cases[] = { + {UNKNOWN_ERR, "UNKNOWN_ERR"}, + {CONTEXT_ERR, "CONTEXT_ERR"}, + {BACKEND_ERR, "BACKEND_ERR"}, + {LOWLEVEL_ERR, "LOWLEVEL_ERR"}, + }; + for (const Case& c : cases) { + const char* got = err2str(c.id); + ASSERT_NE(got, nullptr) << "err2str must never return NULL (id=" << c.id << ")"; + EXPECT_EQ(std::string(got), c.name) << "err2str(" << c.id << ")"; + } +} + +TEST(Introspection, Err2StrReturnsEmptyForUnmappedId) { + const char* got = err2str(999999); + ASSERT_NE(got, nullptr) << "unmapped error id must yield \"\", never NULL"; + EXPECT_STREQ(got, "") << "unmapped error id must silently return an empty string"; +} + +// const2str and err2str are backed by *distinct* maps: an error code resolves +// only through err2str, a vocabulary constant only through const2str. The two +// key spaces don't even overlap — error codes are negative (al_defs.h ERR_0=-1) +// and constmap keys positive — so this documents the two-namespace design +// deterministically, in both directions. +TEST(Introspection, Const2StrAndErr2StrUseSeparateMaps) { + // An error code is not a vocabulary constant: const2str must not resolve it. + EXPECT_STREQ(const2str(BACKEND_ERR), "") + << "an error code must not resolve through const2str"; + EXPECT_EQ(std::string(err2str(BACKEND_ERR)), "BACKEND_ERR"); + + // Symmetrically, a backend constant is not an error code. + EXPECT_STREQ(err2str(HDF5_BACKEND), "") + << "a vocabulary constant must not resolve through err2str"; + EXPECT_EQ(std::string(const2str(HDF5_BACKEND)), "HDF5_BACKEND"); +} + +// --------------------------------------------------------------------------- +// getALVersion — returns the AL library version string. +// --------------------------------------------------------------------------- + +// The tightest self-consistent oracle: the function must return exactly the +// compiled-in AL_VERSION macro (al_defs.h, from CMake PROJECT_VERSION). This +// verifies the linkage returns the constant, not merely that it is non-empty. +TEST(Introspection, GetALVersionReturnsCompiledVersionMacro) { + const char* got = getALVersion(); + ASSERT_NE(got, nullptr); + EXPECT_STREQ(got, AL_VERSION); + EXPECT_FALSE(std::string(got).empty()) << "AL version must not be empty"; +} + +// And it must be a dotted-numeric version whose first three components parse as +// integers — the shape python/tests/test_imasdef.py::test_AL_version relies on. +TEST(Introspection, GetALVersionIsDottedNumeric) { + const std::string v = getALVersion(); + int components = 0; + size_t start = 0; + while (start <= v.size() && components < 3) { + size_t dot = v.find('.', start); + const std::string tok = v.substr( + start, dot == std::string::npos ? std::string::npos : dot - start); + ASSERT_FALSE(tok.empty()) << "empty version component in '" << v << "'"; + for (char ch : tok) { + ASSERT_TRUE(ch >= '0' && ch <= '9') + << "non-numeric version component '" << tok << "' in '" << v << "'"; + } + ++components; + if (dot == std::string::npos) break; + start = dot + 1; + } + EXPECT_GE(components, 3) + << "expected at least MAJOR.MINOR.PATCH in '" << v << "'"; +} + +// --------------------------------------------------------------------------- +// getDDVersion — the deprecated sentinel (intended contract). +// --------------------------------------------------------------------------- + +// CLAUDE.md + include/al_defs.h.in:57 deliberately set DD_VERSION to +// "!!DEPRECATED!!"; python/tests/test_imasdef.py::test_DD_version asserts the +// same at the binding layer. The core carries no compile-time DD coupling, so +// the C ABI must surface this sentinel verbatim — not real version info. +TEST(Introspection, GetDDVersionReturnsDeprecatedSentinel) { + const char* got = getDDVersion(); + ASSERT_NE(got, nullptr); + EXPECT_STREQ(got, "!!DEPRECATED!!"); + EXPECT_STREQ(got, DD_VERSION) << "must match the compiled-in DD_VERSION macro"; +} + +} // namespace diff --git a/tests/contract/test_known_defects.cpp b/tests/contract/test_known_defects.cpp new file mode 100644 index 00000000..0ea4f9c7 --- /dev/null +++ b/tests/contract/test_known_defects.cpp @@ -0,0 +1,135 @@ +// Known-defect contract tests — the expected-fail mechanism proof. +// +// Decision D2 (TEST_STRATEGY.md): for a genuine defect we write the test for +// the *correct* behavior and mark it expected-fail, never freezing the bug as +// if it were the spec. When someone fixes the defect the expected-fail flips to +// pass — a signal, not a regression. +// +// Scaffold defect: al_setvalue_*_parameter_plugin called with an *unregistered* +// plugin name crashes instead of returning an error. +// FUNCTIONALITY_INVENTORY.md:410-417 +// src/al_lowlevel.cpp:453-457 (llpluginsStore[name] default-constructs an +// LLplugin with al_plugin == NULL, then dereferences it — an uncatchable +// null-pointer dereference the surrounding try/catch cannot convert to an +// al_status_t failure). +// +// GoogleTest was chosen over Catch2 precisely for this class of defect +// (decision D3): the crash would take down an in-process runner, so we isolate +// it in a death test. + +#include "al_contract.h" + +#include +#include + +#include + +#include + +namespace { + +// Shared with test_plugins.cpp via al_contract.h (was a duplicated literal). +constexpr const char* kUnregistered = al_contract::kUnregisteredPluginName; + +// CORRECT-CONTRACT test, expected-fail. +// +// Marked expected-fail via the DISABLED_ prefix: it does not run in the default +// `ctest` pass, so it can't crash the runner, but it stays compiled and is +// listed by `--gtest_list_tests`. The day the null-deref is fixed to return an +// error, drop the DISABLED_ prefix and this becomes a live green assertion — +// the suite then documents the fixed contract. +// +// (GoogleTest has no first-class xfail; DISABLED_ + this comment + the paired +// death test below is the framework's idiom for "known defect, correct +// behavior asserted, tracked".) +TEST(KnownDefects, DISABLED_SetValueIntScalarUnregisteredPluginReturnsError) { + al_status_t s = + al_setvalue_int_scalar_parameter_plugin("param", 1, kUnregistered); + // The correct contract: report the unknown plugin as an error, like the + // registration checks the other Cluster-3 calls perform. + EXPECT_NE(s.code, 0) + << "setting a parameter on an unregistered plugin must return an " + "error, not crash"; +} + +// CURRENT-BEHAVIOR guard (death test). +// +// Actively pins today's crash so that a fix — which turns the crash into an +// error return — makes THIS test fail loudly, forcing whoever fixed it to also +// enable the correct-contract test above. This is the tripwire that keeps the +// quirk catalog honest; it asserts "it currently dies", not "it should die". +TEST(KnownDefectsDeath, SetValueIntScalarUnregisteredPluginCurrentlyCrashes) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + // Pinned to SIGSEGV specifically (the null-pointer dereference), not "any + // death" — an unrelated abort must not be mistaken for the defect being + // present. `.*` on the stderr matcher because a bare SIGSEGV prints nothing. + ASSERT_EXIT( + { + al_setvalue_int_scalar_parameter_plugin("param", 1, kUnregistered); + }, + ::testing::KilledBySignal(SIGSEGV), ".*") + << "expected the unregistered-plugin null-deref (see " + "FUNCTIONALITY_INVENTORY.md:410). If this no longer dies by SIGSEGV, " + "the defect was likely fixed — enable " + "KnownDefects.SetValueIntScalarUnregisteredPluginReturnsError."; +} + +// --------------------------------------------------------------------------- +// Same null-deref defect, double-scalar variant. +// FUNCTIONALITY_INVENTORY.md:399-417 lists all three setvalue variants as +// sharing the crash: they all funnel into LLplugin::setvalueParameterPlugin, +// whose `llpluginsStore[plugin_name]` default-constructs a null-al_plugin entry +// for an unknown key and dereferences it. (src/al_lowlevel.cpp:453-457.) +// --------------------------------------------------------------------------- + +TEST(KnownDefects, DISABLED_SetValueDoubleScalarUnregisteredPluginReturnsError) { + al_status_t s = + al_setvalue_double_scalar_parameter_plugin("param", 1.0, kUnregistered); + EXPECT_NE(s.code, 0) + << "setting a double parameter on an unregistered plugin must return " + "an error, not crash"; +} + +TEST(KnownDefectsDeath, SetValueDoubleScalarUnregisteredPluginCurrentlyCrashes) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + ASSERT_EXIT( + { + al_setvalue_double_scalar_parameter_plugin("param", 1.0, + kUnregistered); + }, + ::testing::KilledBySignal(SIGSEGV), ".*") + << "expected the unregistered-plugin null-deref (double variant, see " + "FUNCTIONALITY_INVENTORY.md:410). If this no longer dies by SIGSEGV, " + "enable KnownDefects." + "SetValueDoubleScalarUnregisteredPluginReturnsError."; +} + +// --------------------------------------------------------------------------- +// Same null-deref defect, generic-typed variant +// (al_setvalue_parameter_plugin). Same code path, same crash. +// --------------------------------------------------------------------------- + +TEST(KnownDefects, DISABLED_SetValueGenericUnregisteredPluginReturnsError) { + int value = 1; + al_status_t s = al_setvalue_parameter_plugin("param", INTEGER_DATA, 0, + nullptr, &value, kUnregistered); + EXPECT_NE(s.code, 0) + << "setting a parameter (generic variant) on an unregistered plugin " + "must return an error, not crash"; +} + +TEST(KnownDefectsDeath, SetValueGenericUnregisteredPluginCurrentlyCrashes) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + ASSERT_EXIT( + { + int value = 1; + al_setvalue_parameter_plugin("param", INTEGER_DATA, 0, nullptr, + &value, kUnregistered); + }, + ::testing::KilledBySignal(SIGSEGV), ".*") + << "expected the unregistered-plugin null-deref (generic variant, see " + "FUNCTIONALITY_INVENTORY.md:410). If this no longer dies by SIGSEGV, " + "enable KnownDefects.SetValueGenericUnregisteredPluginReturnsError."; +} + +} // namespace diff --git a/tests/contract/test_mdsplus.cpp b/tests/contract/test_mdsplus.cpp new file mode 100644 index 00000000..486786cd --- /dev/null +++ b/tests/contract/test_mdsplus.cpp @@ -0,0 +1,87 @@ +// MDSplus tracer-bullet round trip (issue #13, TRACEABILITY.md Part 4 row 1). +// +// Proves the whole MDSplus characterization machine end-to-end before any +// behavioral breadth is attempted (TEST_STRATEGY.md §4, issue #12's +// vertical-slice-1 scoping note): one equilibrium scalar round-trips through +// the public C ABI against a real MDSplus tree baked from DD 4.1.1 +// (AL_BUILD_MDSPLUS_MODELS=ON, see docker/mdsplus/). Reuses the real DD path +// and value from equilibrium_seed.h rather than inventing a new one, since +// MDSplus resolves paths against its model tree (issue #12 Q2) and cannot +// take the synthetic opaque paths RoundTrip/RoundTripMatrix use. +// +// Build-gated by AL_CONTRACT_HAVE_MDSPLUS (defined in CMakeLists.txt only +// when AL_BACKEND_MDSPLUS=ON) so the file compiles out entirely otherwise; +// runtime-skipped (GTEST_SKIP()) when MDSPLUS_MODELS_PATH is unset, per +// TEST_STRATEGY D4 — absent/skipped, never failed. +#ifdef AL_CONTRACT_HAVE_MDSPLUS + +#include "al_contract.h" +#include "equilibrium_seed.h" + +#include +#include + +#include + +#include +#include +#include + +using al_contract::PulseId; + +namespace { + +class MdsplusTracerBullet : public ::testing::Test { +protected: + void SetUp() override { AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); } + + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/13, + /*run=*/0}; +}; + +TEST_F(MdsplusTracerBullet, ScalarSurvivesWriteThenRead) { + base_.make_legacy_tree(pulse_); + + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + + const double kValue = equilibrium_seed::scalar_r0(); + + // --- write --- + { + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, + "", WRITE_OP, &op_ctx)); + AL_EXPECT_OK(al_contract::write_data( + op_ctx, equilibrium_seed::kScalar, {}, {kValue})); + AL_ASSERT_OK(al_end_action(op_ctx)); + } + + // --- read back --- + { + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, + "", READ_OP, &op_ctx)); + + std::vector shape; + std::vector value; + AL_EXPECT_OK(al_contract::read_data( + op_ctx, equilibrium_seed::kScalar, 0, &shape, &value)); + ASSERT_EQ(value.size(), 1u); + EXPECT_EQ(value.at(0), kValue); + + AL_ASSERT_OK(al_end_action(op_ctx)); + } + + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); +} + +} // namespace + +#endif // AL_CONTRACT_HAVE_MDSPLUS diff --git a/tests/contract/test_mdsplus_delete.cpp b/tests/contract/test_mdsplus_delete.cpp new file mode 100644 index 00000000..7ae42d94 --- /dev/null +++ b/tests/contract/test_mdsplus_delete.cpp @@ -0,0 +1,299 @@ +// MDSplus real-DD-path delete semantics (issue #34, TRACEABILITY.md Part 4 +// DeleteMatrix row). +// +// The always-on DeleteMatrix (test_structured_data.cpp) exercises +// al_delete_data at leaf / structure / DATAOBJECT-root granularity against the +// synthetic fixture paths (leaf_a, block, "") on HDF5/Memory/ASCII/Flexbuffers. +// MDSplus is absent from kDeleteBackends because those synthetic paths have no +// node in its DD-baked model tree -- the same wall RoundTripMatrix/AosMatrix +// hit (issue #12 Q2/Q5). TRACEABILITY.md long marked the whole MDSplus +// DeleteMatrix row a `terminal-gap`, even though MDSplusBackend::deleteData +// (src/mdsplus/mdsplus_backend.cpp:2136) genuinely implements path-scoped +// deletion. This file closes that gap the same way MdsplusRealPathMatrix +// (issue #15) closed the RoundTrip gap: with real DD-4.1.1 paths MDSplus can +// resolve, and one verdict per granularity. +// +// Real DD-4.1.1 paths (validated against the baked-from artifact +// build-mdsplus/_deps/data-dictionary-src/IDSDef.xml, the same method +// real_dd_path_catalog.h documents -- the imas-dd MCP tool CLAUDE.md names is +// not in this session's MCP config): +// * equilibrium/code is a plain structure holding several STR_0D leaves +// (name, version, description, ...) -- the leaf and structure cases. +// * equilibrium/vacuum_toroidal_field/r0 (FLT_0D) is an unrelated sibling +// leaf that must survive a code-scoped or leaf-scoped delete. +// +// Observed verdicts (characterized empirically through the public C ABI): +// * LEAF (al_delete_data("code/name")) -> COVERED. Succeeds; the target's +// data is cleared (reads back empty, no longer its seeded value) while +// every sibling keeps its value. +// * STRUCTURE (al_delete_data("code")) -> XFAIL. deleteData's STRUCTURE +// branch (mdsplus_backend.cpp:2147) assumes array-of-structures storage -- +// it looks up the `:static`/`.timed_aos` AOS-layout children, which a +// plain DD structure has no node for, so the call throws +// `%TREE-W-NNF, Node Not Found` and nothing is deleted. Pinned as a +// DISABLED_ correct-contract test + an active current-behavior tripwire +// (decision D2). +// * ROOT (al_delete_data("")) -> XFAIL, same %TREE-W-NNF cause (the +// DATAOBJECT root is itself a STRUCTURE-usage node). Explicit observed +// verdict via the same DISABLED_/tripwire pair. +// +// Oracle: seed distinguishable values through the public C ABI, al_delete_data +// at a granularity, reopen the pulse, read every path back. Only the *paths* +// are real DD; the deletion contract itself is backend behavior (decision D1: +// the core attaches no DD semantics to any path). +#ifdef AL_CONTRACT_HAVE_MDSPLUS + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include + +using al_contract::PulseId; + +namespace { + +constexpr const char* kIds = "equilibrium"; + +// A real DD-4.1.1 STR_0D leaf under equilibrium/code and the value seeded into +// it. All static (non-dynamic), so one GLOBAL write seeds them. +struct SeedLeaf { + const char* path; + const char* value; +}; +constexpr SeedLeaf kCodeName{"code/name", "alpha-name"}; +constexpr SeedLeaf kCodeVersion{"code/version", "beta-version"}; +constexpr SeedLeaf kCodeDescription{"code/description", "gamma-description"}; + +// An unrelated sibling leaf outside `code`: FLT_0D, must survive a code-scoped +// (structure) or code/name-scoped (leaf) delete. +constexpr const char* kSiblingPath = "vacuum_toroidal_field/r0"; +constexpr double kSiblingValue = 6.2; + +PulseId pulse_id() { + return PulseId{/*database=*/"test", /*version=*/"3", /*pulse=*/15, + /*run=*/0}; +} + +void write_str(int op, const char* path, const std::string& value) { + std::vector buf(value.begin(), value.end()); + AL_EXPECT_OK(al_contract::write_data( + op, path, {static_cast(buf.size())}, buf)); +} + +// Read a STR_0D/1D leaf. Returns true with `out` set on a clean read; false if +// the read reported an error. After a delete clears a node, the read succeeds +// but returns empty -- so callers distinguish "gone" via emptiness, not error. +bool try_read_str(int op, const char* path, std::string* out) { + std::vector shape; + std::vector data; + al_status_t s = al_contract::read_data(op, path, /*rank=*/1, + &shape, &data); + if (s.code != 0) return false; + out->assign(data.begin(), data.end()); + return true; +} + +bool try_read_double(int op, const char* path, double* out) { + std::vector shape; + std::vector data; + al_status_t s = al_contract::read_data(op, path, /*rank=*/0, &shape, + &data); + if (s.code != 0 || data.empty()) return false; + *out = data.front(); + return true; +} + +// True iff a STR leaf reads back its exact seeded value. +bool str_survives(int op, const SeedLeaf& leaf) { + std::string v; + return try_read_str(op, leaf.path, &v) && v == leaf.value; +} + +// True iff a STR leaf's data is gone (read fails, or reads back empty rather +// than its seeded value). +bool str_gone(int op, const SeedLeaf& leaf) { + std::string v; + if (!try_read_str(op, leaf.path, &v)) return true; + return v != leaf.value; +} + +bool sibling_survives(int op) { + double d = 0; + return try_read_double(op, kSiblingPath, &d) && d == kSiblingValue; +} + +class MdsplusRealPathDelete : public ::testing::Test { +protected: + void SetUp() override { AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); } + + // Create a fresh MDSplus pulse and seed the three code/* leaves plus the + // unrelated vacuum_toroidal_field/r0 sibling. Leaves `base` owning the tree + // dir so the caller can reopen the same pulse. + void seed(al_contract::TempBase& base) { + base.make_legacy_tree(pulse_id()); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse_id()); + ASSERT_FALSE(uri.empty()); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, &op)); + write_str(op, kCodeName.path, kCodeName.value); + write_str(op, kCodeVersion.path, kCodeVersion.value); + write_str(op, kCodeDescription.path, kCodeDescription.value); + AL_EXPECT_OK(al_contract::write_data(op, kSiblingPath, {}, + {kSiblingValue})); + AL_ASSERT_OK(al_end_action(op)); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + + // Delete `path` in a fresh WRITE action on the (reopened) pulse; returns the + // al_delete_data status. Opens/closes its own pulse ctx. + al_status_t delete_path(const al_contract::TempBase& base, + const char* path) { + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse_id()); + int pulse_ctx = -1; + EXPECT_EQ( + al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx).code, + 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, &op).code, + 0); + al_status_t s = al_delete_data(op, path); + al_end_action(op); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + return s; + } + + // Reopen the pulse for READ and hand the op ctx to `check`; tidies up after. + template + void reopen_and_read(const al_contract::TempBase& base, Fn&& check) { + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse_id()); + int pulse_ctx = -1; + AL_ASSERT_OK( + al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", READ_OP, &op)); + check(op); + al_end_action(op); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } +}; + +// --- LEAF: covered ---------------------------------------------------------- +// Deleting a single STR_0D leaf clears exactly that leaf; every sibling (two +// other code/* leaves and the unrelated vacuum_toroidal_field/r0) survives. +TEST_F(MdsplusRealPathDelete, LeafDeleteRemovesJustTheLeaf) { + al_contract::TempBase base; + seed(base); + + AL_EXPECT_OK(delete_path(base, kCodeName.path)); + + reopen_and_read(base, [](int op) { + EXPECT_TRUE(str_gone(op, kCodeName)) + << "deleted leaf code/name still reads back its seeded value"; + EXPECT_TRUE(str_survives(op, kCodeVersion)) + << "sibling code/version must survive a code/name delete"; + EXPECT_TRUE(str_survives(op, kCodeDescription)) + << "sibling code/description must survive a code/name delete"; + EXPECT_TRUE(sibling_survives(op)) + << "unrelated vacuum_toroidal_field/r0 must survive"; + }); +} + +// --- STRUCTURE: xfail ------------------------------------------------------- +// Correct contract (disabled): deleting the `code` structure removes its whole +// subtree (all code/* leaves) and nothing else. Enable when deleteData learns +// to delete a plain (non-AOS) structure. +TEST_F(MdsplusRealPathDelete, DISABLED_StructureDeleteRemovesWholeSubtree) { + al_contract::TempBase base; + seed(base); + + AL_EXPECT_OK(delete_path(base, "code")); + + reopen_and_read(base, [](int op) { + EXPECT_TRUE(str_gone(op, kCodeName)); + EXPECT_TRUE(str_gone(op, kCodeVersion)); + EXPECT_TRUE(str_gone(op, kCodeDescription)); + EXPECT_TRUE(sibling_survives(op)) + << "a code-scoped delete must not touch vacuum_toroidal_field/r0"; + }); +} + +// Current behavior (active tripwire): al_delete_data("code") throws +// %TREE-W-NNF because deleteData's STRUCTURE branch looks up the AOS-only +// :static/.timed_aos children, which a plain structure lacks. The delete is a +// no-op: every seeded leaf survives. When deleteData is fixed, this tripwire +// fails and its paired DISABLED_ test above should be enabled. +TEST_F(MdsplusRealPathDelete, StructureDeleteCurrentlyThrowsNodeNotFound) { + al_contract::TempBase base; + seed(base); + + al_status_t s = delete_path(base, "code"); + EXPECT_NE(s.code, 0) + << "al_delete_data(\"code\") now succeeds -- enable " + "DISABLED_StructureDeleteRemovesWholeSubtree"; + EXPECT_NE(std::string(s.message).find("Node Not Found"), std::string::npos) + << "expected %TREE-W-NNF, got: " << s.message; + + reopen_and_read(base, [](int op) { + EXPECT_TRUE(str_survives(op, kCodeName)) + << "failed structure delete must leave code/name intact"; + EXPECT_TRUE(str_survives(op, kCodeVersion)); + EXPECT_TRUE(str_survives(op, kCodeDescription)); + EXPECT_TRUE(sibling_survives(op)); + }); +} + +// --- ROOT: xfail ------------------------------------------------------------ +// Correct contract (disabled): deleting the DATAOBJECT root ("") removes the +// whole occurrence -- every path is gone. Enable when deleteData supports root +// granularity for MDSplus. +TEST_F(MdsplusRealPathDelete, DISABLED_RootDeleteRemovesWholeOccurrence) { + al_contract::TempBase base; + seed(base); + + AL_EXPECT_OK(delete_path(base, "")); + + reopen_and_read(base, [](int op) { + EXPECT_TRUE(str_gone(op, kCodeName)); + EXPECT_TRUE(str_gone(op, kCodeVersion)); + EXPECT_TRUE(str_gone(op, kCodeDescription)); + double d = 0; + EXPECT_FALSE(try_read_double(op, kSiblingPath, &d)) + << "root delete must remove vacuum_toroidal_field/r0 too"; + }); +} + +// Current behavior (active tripwire): al_delete_data("") throws %TREE-W-NNF +// (the DATAOBJECT root is a STRUCTURE-usage node, same AOS-layout assumption as +// the structure case). Explicit observed verdict; the occurrence is untouched. +TEST_F(MdsplusRealPathDelete, RootDeleteCurrentlyThrowsNodeNotFound) { + al_contract::TempBase base; + seed(base); + + al_status_t s = delete_path(base, ""); + EXPECT_NE(s.code, 0) + << "al_delete_data(\"\") now succeeds -- enable " + "DISABLED_RootDeleteRemovesWholeOccurrence"; + EXPECT_NE(std::string(s.message).find("Node Not Found"), std::string::npos) + << "expected %TREE-W-NNF, got: " << s.message; + + reopen_and_read(base, [](int op) { + EXPECT_TRUE(str_survives(op, kCodeName)) + << "failed root delete must leave the occurrence intact"; + EXPECT_TRUE(sibling_survives(op)); + }); +} + +} // namespace + +#endif // AL_CONTRACT_HAVE_MDSPLUS diff --git a/tests/contract/test_mdsplus_real_paths.cpp b/tests/contract/test_mdsplus_real_paths.cpp new file mode 100644 index 00000000..491b9dfe --- /dev/null +++ b/tests/contract/test_mdsplus_real_paths.cpp @@ -0,0 +1,283 @@ +// MDSplus real-DD-path datatype x rank breadth (issue #15, TRACEABILITY.md +// Part 4). +// +// RoundTripMatrix (test_roundtrip_matrix.cpp) sweeps +// {CHAR, INTEGER, DOUBLE, COMPLEX} x rank {scalar .. MAXDIM} +// against SYNTHETIC opaque paths on the always-on backend tier -- a shape +// MDSplus cannot join (issue #12 Q2/Q5, confirmed by the AosMatrix/ +// EquilibriumSeedMatrix divergence rows in test_structured_data.cpp): it +// resolves every path against a real, DD-4.1.1-baked model tree, so a made-up +// field name throws "%TREE-W-NNF, Node Not Found". This file is +// RoundTripMatrix's MDSplus-only counterpart: the same type x rank space, +// curated as real DD-4.1.1 paths so MDSplus can actually store them, spanning +// multiple IDSs (the model tree is baked from the whole IDSDef.xml, so this +// exercises that whole-DD bake, not one container). +// +// The curated path set is the shared real_dd::catalog() (issue #46): the IDS, +// struct_array chain, leaf, and terminal-gap DD facts are identical for MDSplus +// and UDA and live in real_dd_path_catalog.{h,cpp}. Only MDSplus's own +// storage-model verdicts stay here (kDivergences below). +// +// Per (type, rank) cell, one of three shapes: +// * plain leaf -- straightforward GLOBAL write/read, exactly like a +// RoundTripMatrix cell (`aos_chain` empty). +// * AOS-nested -- the field only exists inside an array-of-structures in +// the whole DD; the AOS is entered (one to three levels, +// matching that field's real nesting) via the same +// al_begin_arraystruct_action idiom AosMatrix already +// proved round-trips against MDSplus for real +// DD-conformant paths (test_structured_data.cpp's +// Divergence note names +// equilibrium/time_slice/global_quantities/ip as the +// proof); a single element is written and read back, so +// AOS *breadth* itself stays AosMatrix/#14's job. +// * terminal-gap -- the DD contains no field of that (type, rank) at any +// nesting depth; the test itself GTEST_SKIP()s with the +// empirical reason, so the cell is visibly accounted for +// rather than silently absent from the matrix. +// +// Oracle: per-backend write->read self-consistency (issue #12 Q2), the same +// al_contract::synth_value/synth_buffer/shape_for_rank generator +// RoundTripMatrix uses -- only the field *path* is real; the *content* stays +// opaque synthetic data clear of the write-path "absent" sentinels +// (al_contract.h). This stays a storage-shape probe, not a DD-semantics test +// (decision D1: the core attaches no DD semantics to any path). +#ifdef AL_CONTRACT_HAVE_MDSPLUS + +#include "al_contract.h" +#include "real_dd_path_catalog.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include + +using al_contract::PulseId; +using al_contract::real_dd::CatalogEntry; +using al_contract::real_dd::DType; + +namespace { + +// MDSplus's own verdict for a catalog cell, layered on top of the shared path. +// `divergence_reason` non-null means the cell round-tripped but not as a plain +// pass -- a legitimate storage-model difference empirically confirmed by +// running it (see CHAR_r0 below), not a defect (D2); the round trip is +// attempted up to the point that confirms the divergence, then the test +// self-reports via GTEST_SKIP() rather than asserting equality. terminal-gap +// cells (a DD fact) come straight from the catalog entry. +struct MdsplusCell { + const CatalogEntry* entry; + const char* divergence_reason; +}; + +// The only MDSplus-specific divergence: CHAR dim=0 against a real STR_0D node. +struct Divergence { + DType dt; + int rank; + const char* reason; +}; +const Divergence kDivergences[] = { + {DType::Char, 0, + "MDSplus's real STR_0D node is inherently string-shaped (1-D): reading a " + "dim=0 write back reports CHAR_DATA in 1D, not 0D ('Wrong dimension of " + "Data returned by backend'), confirmed empirically. Not a crash/defect " + "(contrast HDF5's genuine CHAR-scalar crash, RoundTripKnownDefects) -- " + "the C-ABI's synthetic 'dim=0 CHAR' shape has no DD analogue at all " + "(the DD's smallest text type, STR_0D, is a *string*, i.e. dim=1); " + "against a real DD-conformant node the backend correctly refuses the " + "mismatched rank instead of silently misreading it"}, +}; + +const char* divergence_for(DType dt, int rank) { + for (const Divergence& d : kDivergences) { + if (d.dt == dt && d.rank == rank) return d.reason; + } + return nullptr; +} + +// Derive the MDSplus matrix 1:1 from the shared catalog: exactly one cell per +// catalog entry, in catalog order, with MDSplus's local verdict attached. The +// CoversEveryCatalogKeyOnce meta-test pins that this mapping stays a bijection. +const std::vector& mdsplus_cells() { + static const std::vector kCells = [] { + std::vector cells; + for (const CatalogEntry& e : al_contract::real_dd::catalog()) { + cells.push_back({&e, divergence_for(e.dt, e.rank)}); + } + return cells; + }(); + return kCells; +} + +// --- write/read a leaf field at whatever context it lives in ---------------- +template +void write_leaf(int ctx, const char* leaf, const std::vector& shape, + const std::vector& data) { + AL_EXPECT_OK(al_contract::write_data(ctx, leaf, shape, data)); +} + +template +void read_leaf_and_expect(int ctx, const char* leaf, int rank, + const std::vector& shape, + const std::vector& written) { + std::vector read_shape; + std::vector read_data; + AL_EXPECT_OK(al_contract::read_data(ctx, leaf, rank, &read_shape, + &read_data)); + EXPECT_EQ(read_shape, shape) << "shape changed across the round trip"; + ASSERT_EQ(read_data.size(), written.size()) + << "element count changed across the round trip"; + EXPECT_EQ(read_data, written) << "data changed across the round trip"; +} + +// One real-path round trip: open a fresh MDSplus pulse, descend `aos_chain` +// (if any, one al_begin_arraystruct_action per segment, a single element +// each), write the leaf, read it back, assert self-consistency. +template +void run_cell(const CatalogEntry& spec) { + al_contract::TempBase base; + PulseId pulse{/*database=*/"test", /*version=*/"3", + /*pulse=*/15, /*run=*/0}; + base.make_legacy_tree(pulse); + + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse); + ASSERT_FALSE(uri.empty()); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + + const std::vector shape = al_contract::shape_for_rank(spec.rank); + const std::vector written = + al_contract::synth_buffer(al_contract::element_count(shape)); + + // --- write --- + { + int op = -1; + AL_ASSERT_OK( + al_begin_global_action(pulse_ctx, spec.ids, "", WRITE_OP, &op)); + + int ctx = op; + std::vector aos_ctxs; + for (const char* name : spec.aos_chain) { + int size = 1; + int aos = -1; + AL_EXPECT_OK(al_begin_arraystruct_action(ctx, name, "", &size, + &aos)); + aos_ctxs.push_back(aos); + ctx = aos; + } + + write_leaf(ctx, spec.leaf, shape, written); + + for (auto it = aos_ctxs.rbegin(); it != aos_ctxs.rend(); ++it) { + AL_EXPECT_OK(al_end_action(*it)); + } + AL_ASSERT_OK(al_end_action(op)); + } + + // --- read back --- + { + int op = -1; + AL_ASSERT_OK( + al_begin_global_action(pulse_ctx, spec.ids, "", READ_OP, &op)); + + int ctx = op; + std::vector aos_ctxs; + for (const char* name : spec.aos_chain) { + int size = 0; + int aos = -1; + AL_EXPECT_OK(al_begin_arraystruct_action(ctx, name, "", &size, + &aos)); + EXPECT_EQ(size, 1) << "single-element AOS size must round-trip"; + aos_ctxs.push_back(aos); + ctx = aos; + } + + read_leaf_and_expect(ctx, spec.leaf, spec.rank, shape, written); + + for (auto it = aos_ctxs.rbegin(); it != aos_ctxs.rend(); ++it) { + AL_EXPECT_OK(al_end_action(*it)); + } + AL_ASSERT_OK(al_end_action(op)); + } + + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); +} + +class MdsplusRealPathMatrix : public ::testing::TestWithParam { +protected: + void SetUp() override { AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); } +}; + +TEST_P(MdsplusRealPathMatrix, RealPathRoundTrip) { + const MdsplusCell cell = GetParam(); + const CatalogEntry& spec = *cell.entry; + if (spec.terminal_gap_reason != nullptr) { + GTEST_SKIP() << "terminal-gap: " << spec.terminal_gap_reason; + } + if (cell.divergence_reason != nullptr) { + GTEST_SKIP() << "divergence: " << cell.divergence_reason; + } + switch (spec.dt) { + case DType::Char: + run_cell(spec); + break; + case DType::Int: + run_cell(spec); + break; + case DType::Double: + run_cell(spec); + break; + case DType::Complex: + run_cell>(spec); + break; + } +} + +// A free function, not an inline lambda: a brace-init-list's top-level commas +// aren't paren-protected, so they would otherwise split this macro's argument +// list at the preprocessor stage (INSTANTIATE_TEST_SUITE_P only tracks paren +// nesting, not brace nesting). Case names come from the shared catalog so they +// stay stable across the #46 extraction (e.g. "DOUBLE_r3"). +std::string NameMdsplusRealPathMatrixCase( + const ::testing::TestParamInfo& info) { + return al_contract::real_dd::case_name(*info.param.entry); +} + +INSTANTIATE_TEST_SUITE_P(Mdsplus, MdsplusRealPathMatrix, + ::testing::ValuesIn(mdsplus_cells()), + NameMdsplusRealPathMatrixCase); + +// Meta-test (issue #46): the MDSplus matrix maps the shared catalog 1:1 -- one +// cell per catalog key, no duplicate, none dropped. A future refactor that +// filters cells out of mdsplus_cells() turns this red instead of silently +// shrinking the matrix. +TEST(MdsplusRealPathCatalog, CoversEveryCatalogKeyOnce) { + using Key = std::pair; + std::set matrix_keys; + for (const MdsplusCell& cell : mdsplus_cells()) { + ASSERT_NE(cell.entry, nullptr); + const Key k{static_cast(cell.entry->dt), cell.entry->rank}; + EXPECT_TRUE(matrix_keys.insert(k).second) + << "duplicate matrix key " + << al_contract::real_dd::case_name(*cell.entry); + } + std::set catalog_keys; + for (const CatalogEntry& e : al_contract::real_dd::catalog()) { + catalog_keys.insert({static_cast(e.dt), e.rank}); + } + EXPECT_EQ(matrix_keys, catalog_keys) + << "MDSplus matrix and catalog key sets diverge"; +} + +} // namespace + +#endif // AL_CONTRACT_HAVE_MDSPLUS diff --git a/tests/contract/test_mdsplus_unique_surface.cpp b/tests/contract/test_mdsplus_unique_surface.cpp new file mode 100644 index 00000000..001cb1f2 --- /dev/null +++ b/tests/contract/test_mdsplus_unique_surface.cpp @@ -0,0 +1,577 @@ +// MDSplus-unique surface: struct-aware slice interpolation, timerange +// resampling, segment-backed dynamic writes, timebase cache (issue #17, +// TRACEABILITY.md Part 4's last "gap (deferred)" row). +// +// Every other MDSplus test file joins MDSplus to a fixture that also runs on +// HDF5/Memory/ASCII/Flexbuffers (parity) or curates real DD paths for the +// synthetic-path shapes those fixtures can't transfer (issue #15's breadth +// matrix). This file is different: it exercises behavior that has **no +// HDF5-tier analogue at all**, because it depends on MDSplus's real +// segment-based time-series storage model, not just its real-DD-path model +// tree. +// +// --- struct-aware slice interpolation ----------------------------------- +// HDF5's slice mode (Hdf5TimeDependent.SliceInterpolationModes, +// test_capability_gated.cpp) only interpolates a single scalar leaf. +// MDSplus's slice mode can also interpolate a whole array-of-structures +// *element* at once: al_begin_arraystruct_action, called on a SLICE_OP +// operation context whose target path is a real DD-conformant dynamic AOS, +// reaches MDSplusBackend::readSliceApd (src/mdsplus/mdsplus_backend.cpp:3359) +// instead of the plain-leaf MDSplusBackend::readSlice. For LINEAR_INTERP with +// the requested time strictly between two stored elements, readSliceApd +// calls MDSplusBackend::interpolateStruct (mdsplus_backend.cpp:3675), which +// walks the whole struct recursively and interpolates every numeric leaf it +// finds -- confirmed empirically below by writing two leaves inside the same +// AOS element ("time" and "global_quantities/ip") and observing BOTH come +// back correctly interpolated from a single al_begin_arraystruct_action call. +// +// Real path used: equilibrium/time_slice (struct_array, +// timebasepath="time" -- i.e. each element carries its own "time" leaf, +// matching DD 4.1.1's own coordinate1 "time_slice(itime)/time"; confirmed by +// walking build-mdsplus/_deps/data-dictionary-src/IDSDef.xml, the same +// artifact issue #15 curated real paths against). This is the same path +// TRACEABILITY.md's AosMatrix/EquilibriumSeedMatrix divergence notes and +// test_mdsplus_real_paths.cpp's file header already name as the proof that +// real DD-conformant AOS paths round-trip on MDSplus. +// +// --- timerange resampling ------------------------------------------------- +// Empirically, there is no unique surface here: supportsTimeRangeOperation() +// is unconditionally false for MDSplus (mdsplus_backend.cpp:5444-5446), so +// al_lowlevel.cpp's capability gate (al_lowlevel.cpp:1053) refuses +// al_begin_timerange_action with LOWLEVEL_ERR before the backend is ever +// reached -- already pinned as parity by +// CapabilityMatrix.TimeRangeReadPositiveOrRefused/Mdsplus +// (test_capability_gated.cpp). MDSplus's real time-dependent reads go +// through slice mode instead (struct-aware interpolation above), never +// through time-range resampling. The test below re-asserts the refusal +// directly against this file's own real DD path, so this file is a +// self-contained record of the finding rather than a cross-file assumption. +// +// --- segment-backed dynamic writes ----------------------------------------- +// MDSplusBackend::beginWriteArraystruct dispatches to +// MDSplusBackend::writeDynamicApd (mdsplus_backend.cpp:2688) with its `append` +// parameter set to true specifically when the enclosing operation is a +// SLICE_OP against a real timebase-carrying AOS (mdsplus_backend.cpp:5257, +// 5264,5317,5324) -- as opposed to the whole-container rewrite a GLOBAL_OP +// write does (mdsplus_backend.cpp:5243,5245,5292,5295, the form the +// struct-aware-interpolation/timebase-cache tests above use to seed their +// fixtures). This is the ABI-observable manifestation of "segment-backed +// dynamic write": al_begin_slice_action(WRITE_OP, UNDEFINED_TIME) + +// al_begin_arraystruct_action, called once per new element, appends that +// element's serialized MDSplus::Apd as one more segment on the AOS node (the +// same per-segment storage readDynamicApd/getApdSliceAt already walk on read, +// mdsplus_backend.cpp:3158,3293) -- confirmed below by appending kN elements +// one WRITE session at a time and reading every one of them back +// independently, in contrast to HDF5's scalar slice-append defect +// (Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior, +// test_capability_gated.cpp) where only the last write survives. This is +// correct MDSplus behavior, not a defect -- no xfail pairing needed. +// +// A different, narrower shape -- a bare top-level scalar leaf slice-appended +// against a *separate* external timebase field via al_write_data's `timebase` +// parameter (mirroring Hdf5SliceAppend's own idiom verbatim) -- was tried +// first and empirically found to throw "%TREE-E-NOSEGMENTS" on MDSplus the +// very first time either field is written with no pre-existing segment in +// the other; that narrower shape was dropped in favor of the AOS-append form +// above, which is the one this codebase's own write dispatch already treats +// as the deliberate, first-class "append" path (the `append=true` call sites +// just cited). Whether the scalar-leaf shape is a distinct, fixable defect or +// an unsupported combination is left unresolved -- out of scope for this +// characterization pass, which pins the crash-class defect it did surface +// (MdsplusSliceWriteKnownDefects* below) and otherwise sticks to the +// mechanism the backend actually supports. +// +// --- timebase cache -------------------------------------------------------- +// MDSplusBackend keeps a per-node cache of computed segment/timebase-index +// mappings (segmentIdxMap, mdsplus_backend.h:283), populated on first use by +// getSegmentIdxFromSliceIdx (mdsplus_backend.cpp:3221) and consulted again by +// every subsequent getApdSliceAt call -- e.g. the two lookups a single +// LINEAR_INTERP struct-aware slice read needs (sliceIdx and sliceIdx1) reuse +// the one cached vector rather than recomputing it twice. Grepping every +// touch site shows it is invalidated unconditionally (a whole-map clear, not +// scoped to the written node) at the *start* of every write dispatch +// (MDSplusBackend::writeData, mdsplus_backend.cpp:4577; +// MDSplusBackend::beginWriteArraystruct, mdsplus_backend.cpp:4634) and again +// at the *end* of an AOS write (mdsplus_backend.cpp:5342) and at +// MDSplusBackend::deleteData (mdsplus_backend.cpp:2141) -- so no write path +// through the C ABI can leave a stale segment map for a later read to +// consult. The test below is this finding's characterization: it warms the +// cache with a slice-interpolated read, forces an intervening write (both an +// unrelated GLOBAL scalar write, and a SLICE_OP append that grows the very +// AOS whose segment map was cached by one more element -- see the +// segment-backed-writes section above for why append, not a second GLOBAL +// rewrite, is the correct way to grow it), and confirms a repeated slice read +// afterwards is neither corrupted by the invalidation nor stale to the new +// layout the intervening write introduced. +#ifdef AL_CONTRACT_HAVE_MDSPLUS + +#include "al_contract.h" +#include "equilibrium_seed.h" + +#include +#include + +#include + +#include +#include + +using al_contract::PulseId; + +namespace { + +// --- real DD-4.1.1 paths (equilibrium/time_slice, see file header) -------- +constexpr const char* kIds = equilibrium_seed::kIds; // "equilibrium" +constexpr const char* kAos = "time_slice"; // struct_array +constexpr const char* kAosTime = "time"; // timebasepath +constexpr const char* kIp = "global_quantities/ip"; // FLT_0D leaf + +// =========================================================================== +// Struct-aware slice interpolation: a whole AOS element, two leaves at once. +// =========================================================================== +class MdsplusStructAwareSliceInterpolation : public ::testing::Test { +protected: + void SetUp() override { + AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); + base_.make_legacy_tree(pulse_); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + AL_ASSERT_OK( + al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx_)); + write_two_time_slices(); + } + void TearDown() override { + if (pctx_ >= 0) al_close_pulse(pctx_, CLOSE_PULSE); + } + + // time_slice[0] = {time=1.0, ip=10.0}; time_slice[1] = {time=2.0, ip=20.0}. + void write_two_time_slices() { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx_, kIds, "", WRITE_OP, &op)); + int size = 2; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {kT0})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {kV0})); + AL_EXPECT_OK(al_iterate_over_arraystruct(aos, 1)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {kT1})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {kV1})); + AL_ASSERT_OK(al_end_action(aos)); + AL_ASSERT_OK(al_end_action(op)); + } + + // Slice-reads time_slice at `time`/`interp`; returns the single + // interpolated element's ("time", "ip") pair via out params. Fails the + // test fatally (not via a return code) if the struct-aware slice itself + // is refused, so a downstream value mismatch can never be blamed on a + // broken setup. + void slice_read_struct(double time, int interp, double* out_time, + double* out_ip) { + int op = -1; + ASSERT_EQ(al_begin_slice_action(pctx_, kIds, READ_OP, time, interp, &op) + .code, + 0); + int size = 0; + int aos = -1; + ASSERT_EQ( + al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos).code, 0) + << "struct-aware slice read must be accepted"; + ASSERT_EQ(size, 1) + << "a slice read of an AOS must collapse to exactly one element"; + + std::vector shape; + std::vector time_val, ip_val; + EXPECT_EQ(al_contract::read_data(aos, kAosTime, 0, &shape, + &time_val) + .code, + 0); + EXPECT_EQ( + al_contract::read_data(aos, kIp, 0, &shape, &ip_val).code, + 0); + ASSERT_EQ(time_val.size(), 1u); + ASSERT_EQ(ip_val.size(), 1u); + *out_time = time_val[0]; + *out_ip = ip_val[0]; + + EXPECT_EQ(al_end_action(aos).code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + } + + static constexpr double kT0 = 1.0, kT1 = 2.0; + static constexpr double kV0 = 10.0, kV1 = 20.0; + + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/17, + /*run=*/0}; + int pctx_ = -1; +}; + +TEST_F(MdsplusStructAwareSliceInterpolation, ClosestPicksNearerElement) { + double t = -1.0, ip = -1.0; + slice_read_struct(1.4, CLOSEST_INTERP, &t, &ip); + EXPECT_EQ(t, kT0); + EXPECT_EQ(ip, kV0); + + slice_read_struct(1.6, CLOSEST_INTERP, &t, &ip); + EXPECT_EQ(t, kT1); + EXPECT_EQ(ip, kV1); +} + +TEST_F(MdsplusStructAwareSliceInterpolation, PreviousPicksLastElementAtOrBelow) { + double t = -1.0, ip = -1.0; + slice_read_struct(1.9, PREVIOUS_INTERP, &t, &ip); + EXPECT_EQ(t, kT0); + EXPECT_EQ(ip, kV0); +} + +// The struct-aware case: LINEAR_INTERP at the midpoint interpolates BOTH +// leaves of the element together ("time" and "global_quantities/ip"), from +// one al_begin_arraystruct_action call -- this is the behavior with no HDF5 +// analogue (HDF5's slice mode only ever interpolates one scalar leaf at a +// time, never a whole struct). +TEST_F(MdsplusStructAwareSliceInterpolation, LinearInterpolatesWholeStruct) { + double t = -1.0, ip = -1.0; + slice_read_struct(1.5, LINEAR_INTERP, &t, &ip); + EXPECT_EQ(t, (kT0 + kT1) / 2.0) << "the element's own timebase leaf must " + "interpolate, not just the signal"; + EXPECT_EQ(ip, (kV0 + kV1) / 2.0); +} + +// =========================================================================== +// Timerange resampling: no unique MDSplus surface -- refused unconditionally. +// =========================================================================== +TEST(MdsplusTimerangeResampling, RefusedWithNoUniqueSurface) { + AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); + al_contract::TempBase base; + PulseId pulse{"test", "3", 17, 0}; + base.make_legacy_tree(pulse); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + int op = -1; + double dtime = 0.0; + int dtime_shape = 0; + al_status_t s = al_begin_timerange_action(pctx, kIds, READ_OP, 0.0, 10.0, + &dtime, &dtime_shape, + UNDEFINED_INTERP, &op); + EXPECT_NE(s.code, 0) + << "supportsTimeRangeOperation() is unconditionally false for " + "MDSplus (mdsplus_backend.cpp:5444-5446) -- there is no MDSplus " + "timerange-resampling surface to characterize; time-dependent " + "reads go through slice mode instead, see " + "MdsplusStructAwareSliceInterpolation above"; + EXPECT_EQ(s.code, LOWLEVEL_ERR) + << "documented refusal is LOWLEVEL_ERR (al_lowlevel.cpp:1053), same " + "as every other non-HDF5 always-on backend"; + + al_close_pulse(pctx, CLOSE_PULSE); +} + +// =========================================================================== +// Segment-backed dynamic writes: incremental AOS appends all persist. +// =========================================================================== +// MDSplusBackend::beginWriteArraystruct dispatches to +// MDSplusBackend::writeDynamicApd (mdsplus_backend.cpp:2688) with its `append` +// parameter set to `true` specifically when the enclosing operation context +// is a SLICE_OP whose target is a real timebase-carrying AOS +// (mdsplus_backend.cpp:5257,5264,5317,5324 -- contrasted with the `false`/ +// whole-rewrite form the GLOBAL_OP writes at mdsplus_backend.cpp:5243,5245, +// 5292,5295 use, and the one this file's struct-aware-interpolation / +// timebase-cache tests above use). This is the ABI-observable manifestation +// of "segment-backed dynamic write": al_begin_slice_action(WRITE_OP, +// UNDEFINED_TIME) + al_begin_arraystruct_action, called once per new element, +// appends that element's serialized MDSplus::Apd as one more segment onto the +// AOS node (the same per-segment storage readDynamicApd/getApdSliceAt walk on +// read, mdsplus_backend.cpp:3158,3293) -- rather than the whole-container +// rewrite a GLOBAL write does. Confirmed below by appending kN elements one +// session at a time and reading every one of them back independently, in +// contrast to HDF5's scalar slice-append defect +// (Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior, +// test_capability_gated.cpp) where only the last write survives. +class MdsplusSegmentBackedWrites : public ::testing::Test { +protected: + static constexpr int kN = 5; + + void SetUp() override { AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); } + + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/17, + /*run=*/0}; +}; + +TEST_F(MdsplusSegmentBackedWrites, AppendedElementsAllPersist) { + base_.make_legacy_tree(pulse_); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + double t[kN], v[kN]; + for (int k = 0; k < kN; ++k) { + t[k] = 1.0 + k; + v[k] = 10.0 * (k + 1); + } + + // Element 0 establishes the AOS's very first segment via an ordinary + // GLOBAL write -- empirically, writeDynamicApd's append=true path (taken + // for a SLICE_OP write) throws "%TREE-E-NOSEGMENTS" the first time it + // must create a segment from nothing, the same first-segment fragility + // as MdsplusSliceWriteKnownDefects below hits for a bare scalar leaf. + // Once the AOS has its first segment, further SLICE_OP appends succeed. + { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + int size = 1; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {t[0]})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {v[0]})); + AL_ASSERT_OK(al_end_action(aos)); + AL_ASSERT_OK(al_end_action(op)); + } + + // Elements 1..kN-1 are appended one at a time -- WRITE_OP + UNDEFINED_TIME, + // one al_begin_slice_action/al_begin_arraystruct_action(size=1)/al_end_action + // set per element, never writing more than one element per session. + for (int k = 1; k < kN; ++k) { + int op = -1; + AL_ASSERT_OK(al_begin_slice_action(pctx, kIds, WRITE_OP, UNDEFINED_TIME, + UNDEFINED_INTERP, &op)); + int size = 1; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {t[k]})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {v[k]})); + AL_ASSERT_OK(al_end_action(aos)); + AL_ASSERT_OK(al_end_action(op)); + } + + // Every appended element must be independently retrievable by its own + // time -- unlike HDF5's scalar slice-append defect + // (Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior), MDSplus's + // segment-backed AOS storage keeps all kN, not just the last. + for (int k = 0; k < kN; ++k) { + int op = -1; + AL_ASSERT_OK(al_begin_slice_action(pctx, kIds, READ_OP, t[k], + CLOSEST_INTERP, &op)); + int size = 0; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos)); + ASSERT_EQ(size, 1); + std::vector shape; + std::vector value; + AL_EXPECT_OK(al_contract::read_data(aos, kIp, 0, &shape, &value)); + ASSERT_EQ(value.size(), 1u); + EXPECT_EQ(value[0], v[k]) << "element " << k << " (t=" << t[k] + << ") did not persist its own value"; + AL_EXPECT_OK(al_end_action(aos)); + AL_EXPECT_OK(al_end_action(op)); + } + + al_close_pulse(pctx, CLOSE_PULSE); +} + +// Backs the claim in AppendedElementsAllPersist's setup comment with its own +// assertion (D2: don't freeze an empirical finding as an unpinned comment) -- +// a divergence, not a defect: appending the very first element of a +// brand-new AOS via SLICE_OP has no existing segment to extend, and MDSplus +// refuses gracefully (a caught, returned error, not a crash) rather than +// silently doing something undefined. +TEST_F(MdsplusSegmentBackedWrites, FirstElementCannotBeAppendedViaSliceAction) { + base_.make_legacy_tree(pulse_); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + int op = -1; + AL_ASSERT_OK(al_begin_slice_action(pctx, kIds, WRITE_OP, UNDEFINED_TIME, + UNDEFINED_INTERP, &op)); + int size = 1; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {1.0})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {10.0})); + al_status_t s = al_end_action(aos); + EXPECT_NE(s.code, 0) + << "expected MDSplus to refuse a SLICE_OP append against a " + "brand-new, still-empty AOS (no prior segment to extend)"; + al_end_action(op); + al_close_pulse(pctx, CLOSE_PULSE); +} + +// --------------------------------------------------------------------------- +// KNOWN DEFECT (decision D2), discovered while characterizing segment-backed +// writes above: MDSplusBackend::writeData's SLICE_OP branch +// (mdsplus_backend.cpp:4586-4591) dereferences `size[0]` unconditionally -- +// case alconst::slice_op: +// if(size[0] > 1) writeTimedData(...); else writeSlice(...); +// -- but the C ABI passes `size == nullptr` for any dim=0 (scalar) write +// (al_lowlevel.h; al_contract::write_int_scalar documents this convention). +// Writing ANY scalar field inside a SLICE_OP write action therefore +// segfaults MDSplus, unconditionally -- confirmed empirically the first time +// this file tried the exact idiom Hdf5SliceAppend (test_capability_gated.cpp) +// uses to seed homogeneous_time inside its slice-write loop, which is fine on +// HDF5 but crashes on MDSplus. Not scoped to homogeneous_time specifically: +// any dim=0 field hits the same unconditional dereference. +// --------------------------------------------------------------------------- +al_status_t write_scalar_within_slice_action() { + al_contract::TempBase base; + PulseId pulse{"test", "3", 17, 0}; + base.make_legacy_tree(pulse); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx) + .code, + 0); + + int op = -1; + EXPECT_EQ(al_begin_slice_action(pctx, "magnetics", WRITE_OP, UNDEFINED_TIME, + UNDEFINED_INTERP, &op) + .code, + 0); + int ht = 1; + al_status_t s = al_write_data(op, "ids_properties/homogeneous_time", "", + &ht, INTEGER_DATA, 0, nullptr); + al_end_action(op); + al_close_pulse(pctx, CLOSE_PULSE); + return s; +} + +TEST(MdsplusSliceWriteKnownDefects, DISABLED_ScalarWriteWithinSliceActionSucceeds) { + AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); + EXPECT_EQ(write_scalar_within_slice_action().code, 0) + << "a dim=0 (scalar) al_write_data inside a SLICE_OP action must not " + "crash MDSplus"; +} + +TEST(MdsplusSliceWriteKnownDefectsDeath, + ScalarWriteWithinSliceActionCurrentlyCrashes) { + AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); + GTEST_FLAG_SET(death_test_style, "threadsafe"); + ASSERT_DEATH(write_scalar_within_slice_action(), ".*") + << "expected a scalar write inside a SLICE_OP action to crash " + "MDSplus (mdsplus_backend.cpp:4586-4591 dereferences size[0] " + "unconditionally, but the C ABI passes size=nullptr for dim=0). " + "If it no longer crashes, enable " + "MdsplusSliceWriteKnownDefects.DISABLED_ScalarWriteWithinSliceActionSucceeds"; +} + +// =========================================================================== +// Timebase cache: correctness across an invalidating write, not staleness. +// =========================================================================== +TEST(MdsplusTimebaseCache, SurvivesInterveningWritesWithoutStaleness) { + AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); + al_contract::TempBase base; + PulseId pulse{"test", "3", 17, 0}; + base.make_legacy_tree(pulse); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + // --- seed: time_slice[0..1] = {(1.0, 10.0), (2.0, 20.0)} ---------------- + { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + int size = 2; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {1.0})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {10.0})); + AL_EXPECT_OK(al_iterate_over_arraystruct(aos, 1)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {2.0})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {20.0})); + AL_ASSERT_OK(al_end_action(aos)); + AL_ASSERT_OK(al_end_action(op)); + } + + auto interp_ip_at = [&](double time, int interp) { + int op = -1; + EXPECT_EQ( + al_begin_slice_action(pctx, kIds, READ_OP, time, interp, &op).code, + 0); + int size = 0; + int aos = -1; + EXPECT_EQ( + al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos).code, 0); + std::vector shape; + std::vector ip_val; + EXPECT_EQ( + al_contract::read_data(aos, kIp, 0, &shape, &ip_val).code, + 0); + EXPECT_EQ(al_end_action(aos).code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + return ip_val.empty() ? -1.0 : ip_val[0]; + }; + auto linear_interp_ip_at = [&](double time) { + return interp_ip_at(time, LINEAR_INTERP); + }; + auto closest_interp_ip_at = [&](double time) { + return interp_ip_at(time, CLOSEST_INTERP); + }; + + // --- warm the timebase/segment cache for the time_slice node ------------ + ASSERT_EQ(linear_interp_ip_at(1.5), 15.0); + + // --- an unrelated GLOBAL write elsewhere in the same pulse clears the + // whole-map cache unconditionally (MDSplusBackend::writeData, + // mdsplus_backend.cpp:4577-4578) ------------------------------------------ + { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data( + op, equilibrium_seed::kScalar, {}, {6.2})); + AL_ASSERT_OK(al_end_action(op)); + } + + // --- re-read the SAME interpolated point: must reproduce the same + // value, not a corrupted one, after the cache was invalidated and must + // rebuild from scratch -------------------------------------------------- + EXPECT_EQ(linear_interp_ip_at(1.5), 15.0) + << "an unrelated write must not corrupt a subsequent struct-aware " + "slice interpolation on an already-cached AOS node"; + + // --- append a THIRD element via SLICE_OP (the genuinely-additive path + // MdsplusSegmentBackedWrites above confirms -- a second GLOBAL write of a + // fresh N-element AOS does NOT replace what is already stored: writeDynamicApd + // computes its next slice index by walking the existing segments + // regardless of the `append` flag (mdsplus_backend.cpp:2746-2776), so it + // would instead produce a non-monotonic five-element timebase + // {1,2,1,2,3} and corrupt the interpolation oracle below for reasons + // unrelated to the cache). The append also clears the cache, via + // MDSplusBackend::beginWriteArraystruct (mdsplus_backend.cpp:4634) and + // again at the end of the AOS write (mdsplus_backend.cpp:5342) ---------- + { + int op = -1; + AL_ASSERT_OK(al_begin_slice_action(pctx, kIds, WRITE_OP, UNDEFINED_TIME, + UNDEFINED_INTERP, &op)); + int size = 1; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {3.0})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {30.0})); + AL_ASSERT_OK(al_end_action(aos)); + AL_ASSERT_OK(al_end_action(op)); + } + + // --- the freshly-rebuilt cache must reflect the NEW segment layout, not + // a stale two-element view ----------------------------------------------- + EXPECT_EQ(closest_interp_ip_at(3.0), 30.0) + << "a slice read after appending a new element must see it -- the " + "cache must not still reflect the pre-append segment layout"; + EXPECT_EQ(linear_interp_ip_at(1.5), 15.0) + << "the original interpolated point must be unaffected by the append"; + + al_close_pulse(pctx, CLOSE_PULSE); +} + +} // namespace + +#endif // AL_CONTRACT_HAVE_MDSPLUS diff --git a/tests/contract/test_mdsplus_version_drift.cpp b/tests/contract/test_mdsplus_version_drift.cpp new file mode 100644 index 00000000..59636ee5 --- /dev/null +++ b/tests/contract/test_mdsplus_version_drift.cpp @@ -0,0 +1,125 @@ +// MDSplus version-drift check (issue #16, reworked by issue #35; +// TRACEABILITY.md Part 4). +// +// al_lowlevel.cpp's al_begin_dataentry_action compares the backend's compiled +// version (`getVersion(NULL)`) against the version stored in the pulse being +// opened (`getVersion(pctx)`) on OPEN_PULSE/FORCE_OPEN_PULSE and throws +// LOWLEVEL_ERR on a mismatch. MDSplus stores that version in the tree's +// VERSION:BACK_MAJOR/BACK_MINOR nodes, written once at pulse creation from +// the compiled constants (currently 1/1) and read back unchanged forever. +// +// Forcing the mismatch needs out-of-band tree surgery — but that mutation is +// FIXTURE PREPARATION, not contract: it lives in the separate build-gated +// producer mdsplus_fixture_tool.cpp (issue #35), which owns all direct +// MDSplus C++ API access. This file includes only AL/test headers and calls +// only `al_*` APIs, so a rewrite satisfying the C ABI is judged by exactly +// the C ABI — no second behavioral seam through or the legacy +// storage layout leaks into the asserted contract. +#if defined(AL_CONTRACT_HAVE_MDSPLUS) && defined(AL_CONTRACT_MDSPLUS_FIXTURE_TOOL) + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include + +using al_contract::PulseId; + +namespace { + +// A backend version the compiled MDSplus backend (MDSPLUS_BACKEND_MAJOR == 1, +// see mdsplus_backend.cpp) can never match, forcing al_lowlevel.cpp's major +// mismatch branch ((ver.first != sver.first) || ...). +constexpr int kMismatchedMajor = 99; + +class MdsplusVersionDrift : public ::testing::Test { +protected: + void SetUp() override { AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); } + + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/16, + /*run=*/0}; + + // The directory DataEntryContext::buildFullPath computes for this pulse + // (al_context.cpp:140-179) and hands to the backend as the URI "path" + // query parameter -- i.e. where the "ids" tree/shot-1 files actually + // live. Mirrors al_contract::TempBase::make_legacy_tree's own layout. + std::string pulse_dir() const { + return base_.str() + "/" + pulse_.database + "/" + pulse_.version + + "/" + std::to_string(pulse_.pulse) + "/" + + std::to_string(pulse_.run); + } + + // Out-of-band fixture mutation via the isolated setup tool + // (mdsplus_fixture_tool.cpp) — the producer of the mismatched fixture, + // not part of the asserted contract. + void PokeStoredBackendMajorVersion(int mismatched_major) { + const std::string cmd = std::string("\"") + + AL_CONTRACT_MDSPLUS_FIXTURE_TOOL + "\" \"" + + pulse_dir() + "\" " + + std::to_string(mismatched_major); + ASSERT_EQ(std::system(cmd.c_str()), 0) + << "fixture tool failed: " << cmd; + } +}; + +// Matching case: a pulse created and immediately reopened by the same +// compiled backend carries the same VERSION:BACK_MAJOR/MINOR it just wrote, +// so the drift check's comparison is a no-op and OPEN_PULSE succeeds. +TEST_F(MdsplusVersionDrift, MatchingVersionOpensCleanly) { + base_.make_legacy_tree(pulse_); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int create_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &create_ctx)); + EXPECT_EQ(al_close_pulse(create_ctx, CLOSE_PULSE).code, 0); + + int open_ctx = -1; + al_status_t status = + al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &open_ctx); + EXPECT_TRUE(al_contract::IsOk(status)); + if (status.code == 0) { + EXPECT_EQ(al_close_pulse(open_ctx, CLOSE_PULSE).code, 0); + } +} + +// Mismatching case: after creation, VERSION:BACK_MAJOR is overwritten (by the +// fixture tool) to a value the compiled backend (MDSPLUS_BACKEND_MAJOR == 1) +// can never match. Reopening through the C ABI must hit al_lowlevel.cpp's +// `(ver.first != sver.first)` branch and fail with LOWLEVEL_ERR, never a +// silent open of stale/incompatible data. +TEST_F(MdsplusVersionDrift, MismatchedBackendVersionRefusesOpen) { + base_.make_legacy_tree(pulse_); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int create_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &create_ctx)); + EXPECT_EQ(al_close_pulse(create_ctx, CLOSE_PULSE).code, 0); + + PokeStoredBackendMajorVersion(kMismatchedMajor); + + int open_ctx = -1; + al_status_t status = + al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &open_ctx); + EXPECT_NE(status.code, 0) + << "expected the version-drift check to refuse opening a pulse whose " + "stored VERSION:BACK_MAJOR (" + << kMismatchedMajor << ") doesn't match the compiled backend's"; + EXPECT_NE(std::string(status.message).find("Compatibility"), + std::string::npos) + << "status.message=" << status.message; +} + +} // namespace + +#endif // AL_CONTRACT_HAVE_MDSPLUS && AL_CONTRACT_MDSPLUS_FIXTURE_TOOL diff --git a/tests/contract/test_plugin_fixture.cpp b/tests/contract/test_plugin_fixture.cpp new file mode 100644 index 00000000..537445d3 --- /dev/null +++ b/tests/contract/test_plugin_fixture.cpp @@ -0,0 +1,124 @@ +// Minimal in-repo plugin, built as a loadable `_plugin.so` for the +// plugin-lifecycle contract tests (issue #7, TEST_STRATEGY.md step 5). +// +// This is the *fixture/harness* half of the suite (the only place classic +// red-green applies): a real, deterministic plugin the C ABI can actually +// `dlopen`, so the register / bind / unbind / unregister state machine and the +// `al_setvalue_*_parameter_plugin` happy paths can be exercised end to end +// rather than only through their failure modes. +// +// It implements the whole `access_layer_plugin` interface (all pure virtuals of +// access_layer_base_plugin + provenance_plugin_feature + readback_plugin_feature) +// so `create()` returns a fully-concrete object, and exports the C +// `create()`/`destroy()` factory symbols the framework resolves via `dlsym` +// (src/al_lowlevel.cpp:360-377). It links no libal symbols — every plugin base +// class is header-only — so the module is self-contained and needs no rpath. +// +// setParameter appends a machine-readable line to the file named by the +// AL_CONTRACT_PLUGIN_LOG env var (when set), so a test can prove a value passed +// through the C ABI actually reached the plugin, not merely that the call +// returned success. + +#include "access_layer_plugin.h" + +#include +#include +#include + +namespace { + +class ContractTestPlugin : public access_layer_plugin { +public: + // --- the one method the setvalue_* ABI calls reach --------------------- + void setParameter(const char* parameter_name, int datatype, int dim, + int* /*size*/, void* data) override { + const char* log_path = std::getenv("AL_CONTRACT_PLUGIN_LOG"); + if (log_path == nullptr) { + return; + } + std::FILE* f = std::fopen(log_path, "a"); + if (f == nullptr) { + return; + } + // Format: PARAM|||| + // The scalar value is decoded for the two convenience variants so the + // test can assert the exact value that crossed the ABI boundary. + if (dim == 0 && data != nullptr && datatype == INTEGER_DATA) { + std::fprintf(f, "PARAM|%s|%d|%d|%d\n", parameter_name, datatype, dim, + *static_cast(data)); + } else if (dim == 0 && data != nullptr && datatype == DOUBLE_DATA) { + std::fprintf(f, "PARAM|%s|%d|%d|%g\n", parameter_name, datatype, dim, + *static_cast(data)); + } else { + std::fprintf(f, "PARAM|%s|%d|%d|-\n", parameter_name, datatype, dim); + } + std::fclose(f); + } + + // --- remaining access_layer_plugin surface: inert, never invoked by the + // lifecycle tests (they never drive a get/put through the plugin) ----- + void begin_global_action(int, const char*, const char*, int, int) override {} + void begin_slice_action(int, const char*, int, double, int, int) override {} + void begin_arraystruct_action(int, int*, const char*, const char*, + int*) override {} + void end_action(int) override {} + int read_data(int, const char*, const char*, void**, int, int, + int*) override { + return 0; // "no data" per the readData 0/1 convention + } + void write_data(int, const char*, const char*, void*, int, int, + int*) override {} + plugin::OPERATION node_operation(const std::string&) override { + return plugin::PUT_AND_GET; + } + + // --- provenance_plugin_feature ----------------------------------------- + std::string getName() override { return "alcontract"; } + std::string getDescription() override { return "contract-test plugin"; } + std::string getCommit() override { return "0000000"; } + std::string getVersion() override { return "0.0.0"; } + std::string getRepository() override { return "in-repo"; } + std::string getParameters() override { return ""; } + + // --- readback_plugin_feature ------------------------------------------- + // By default this plugin declares no readback capability for any path + // (every getReadback* below returns ""), matching the plugin-lifecycle + // tests (issue #7). AL_CONTRACT_PLUGIN_READBACK_PATH opts a single field + // path in, for the readback-binding contract test (issue #8): when set + // and equal to `path`, the plugin declares itself as its own readback + // provider for that path, self-consistent with its own provenance (so + // AccessLayerPluginManager::bind_readback_plugins' version check passes). + bool is_readback_path(const std::string& path) const { + const char* readback_path = + std::getenv("AL_CONTRACT_PLUGIN_READBACK_PATH"); + return readback_path != nullptr && path == readback_path; + } + + std::string getReadbackName(const std::string& path, int* index) override { + if (!is_readback_path(path)) return ""; + *index = 0; + return getName(); + } + std::string getReadbackDescription(const std::string& path) override { + return is_readback_path(path) ? getDescription() : ""; + } + std::string getReadbackCommit(const std::string& path) override { + return is_readback_path(path) ? getCommit() : ""; + } + std::string getReadbackVersion(const std::string& path) override { + return is_readback_path(path) ? getVersion() : ""; + } + std::string getReadbackRepository(const std::string& path) override { + return is_readback_path(path) ? getRepository() : ""; + } + std::string getReadbackParameters(const std::string&) override { + return ""; + } +}; + +} // namespace + +// The C factory symbols the framework resolves after dlopen (create_t/destroy_t +// in access_layer_base_plugin.h). +extern "C" access_layer_base_plugin* create() { return new ContractTestPlugin(); } +extern "C" void destroy(access_layer_base_plugin* p) { delete p; } diff --git a/tests/contract/test_plugins.cpp b/tests/contract/test_plugins.cpp new file mode 100644 index 00000000..4d32795c --- /dev/null +++ b/tests/contract/test_plugins.cpp @@ -0,0 +1,776 @@ +// Plugin management contract tests — User Cluster 3 +// (FUNCTIONALITY_INVENTORY.md:318-430, TEST_STRATEGY.md step 5). +// +// The register / bind / unbind / unregister state machine and the +// al_setvalue_*_parameter_plugin calls, driven through the public C ABI +// (decision D1). Every behavior asserted here was first characterized against +// the built library, then classified once per decision D2: +// * intended contract -> asserted directly (green) +// * genuine defect -> correct behavior asserted, DISABLED_ (xfail), +// paired with a current-behavior tripwire +// +// The crash-class null-deref defects (setvalue on an unregistered name) are +// death tests and live with the other death tests in test_known_defects.cpp. +// Two defects that need a real, registerable plugin live here: the +// registered-but-never-bound plugin left un-destroyed on unregister, and the +// dlopen-failure swallowed by the NDEBUG-stripped assert. +// +// Fixture: a real plugin is dlopen-ed from AL_CONTRACT_PLUGIN_DIR (the +// build-time location of alcontract_plugin.so, from test_plugin_fixture.cpp), +// with IMAS_AL_ENABLE_PLUGINS=TRUE. The plugin registry is process-global and +// static; SetUp/TearDown force a clean slate so the suite is order-independent +// whether run per-case under ctest or as one direct ./contract_tests process. + +#ifndef _WIN32 // plugins framework is dlopen/dlfcn-based (POSIX) + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +// Both are always supplied by CMake; a silent fallback could drift from the +// build's real values, so require both explicitly. +#ifndef AL_CONTRACT_PLUGIN_DIR +#error "AL_CONTRACT_PLUGIN_DIR must be defined by CMake (dir holding alcontract_plugin.so)" +#endif +#ifndef AL_CONTRACT_PLUGIN_NAME +#error "AL_CONTRACT_PLUGIN_NAME must be defined by CMake (the registerable plugin name)" +#endif + +namespace { + +using ::al_contract::IsOk; + +constexpr const char* kPlugin = AL_CONTRACT_PLUGIN_NAME; + +// A plugin name that is never backed by a .so and never registered. +constexpr const char* kNeverRegistered = al_contract::kUnregisteredPluginName; + +bool is_registered(const char* name) { + bool r = false; + al_status_t s = al_is_plugin_registered(name, &r); + return s.code == 0 && r; +} + +// Force `name` out of the process-global registry so each test starts clean. +// +// A plain al_unregister_plugin is not enough: the very defect this suite pins is +// that unregister only destroys a plugin that is *bound* to a path. So we bind +// it to a throwaway path first (which makes unregister take its destroy+erase +// path), then unregister. Only touches plugins whose entry has a live instance +// (the contract-test plugin always does); a half-registered entry from a failed +// dlopen is left alone, which is fine — no test reuses such a name. +void force_unregister(const char* name) { + bool r = false; + if (al_is_plugin_registered(name, &r).code != 0 || !r) { + return; + } + al_bind_plugin("alcontract_cleanup/node", name); + al_unregister_plugin(name); +} + +// Shared by every test below that needs a real (Memory-backend, unit-tier) +// pulse to write/read through — issue #8's ownership-sweep tests are the +// first in this file to need one, since the earlier plugin-registry tests +// above operate on bare path strings with no backend at all. +int open_memory_pulse(al_contract::TempBase& base, + const al_contract::PulseId& pulse) { + const std::string u = al_contract::build_uri(MEMORY_BACKEND, base.str(), pulse); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx).code, + 0); + return pctx; +} + +class PluginTest : public ::testing::Test { +protected: + void SetUp() override { + setenv("IMAS_AL_ENABLE_PLUGINS", "TRUE", 1); + setenv("IMAS_AL_PLUGINS", AL_CONTRACT_PLUGIN_DIR, 1); + force_unregister(kPlugin); + } + void TearDown() override { + // Runs while the framework is still enabled so force_unregister works, + // then clears the env so non-plugin cases in the same process are + // unaffected (the read/write hot path checks IMAS_AL_ENABLE_PLUGINS). + setenv("IMAS_AL_ENABLE_PLUGINS", "TRUE", 1); + setenv("IMAS_AL_PLUGINS", AL_CONTRACT_PLUGIN_DIR, 1); + force_unregister(kPlugin); + unsetenv("AL_CONTRACT_PLUGIN_LOG"); + unsetenv("IMAS_AL_ENABLE_PLUGINS"); + unsetenv("IMAS_AL_PLUGINS"); + } +}; + +// =========================================================================== +// register / unregister lifecycle + is_plugin_registered +// =========================================================================== + +TEST_F(PluginTest, RegisterMakesPluginRegistered) { + ASSERT_FALSE(is_registered(kPlugin)) << "SetUp should leave a clean slate"; + AL_ASSERT_OK(al_register_plugin(kPlugin)); + EXPECT_TRUE(is_registered(kPlugin)); +} + +TEST_F(PluginTest, RegisterTwiceReturnsError) { + AL_ASSERT_OK(al_register_plugin(kPlugin)); + al_status_t s = al_register_plugin(kPlugin); + EXPECT_NE(s.code, 0) + << "registering an already-registered name must error, not silently " + "re-register (FUNCTIONALITY_INVENTORY.md:345-346)"; +} + +TEST_F(PluginTest, IsPluginRegisteredIsFalseForNeverRegisteredName) { + bool r = true; + al_status_t s = al_is_plugin_registered(kNeverRegistered, &r); + AL_EXPECT_OK(s); + EXPECT_FALSE(r); +} + +TEST_F(PluginTest, UnregisterNeverRegisteredNameReturnsError) { + al_status_t s = al_unregister_plugin(kNeverRegistered); + EXPECT_NE(s.code, 0) + << "unregistering a name that was never registered must error " + "(FUNCTIONALITY_INVENTORY.md:345-346)"; +} + +// A *bound* plugin IS properly destroyed on unregister — the contrast that +// makes the never-bound quirk (below) a genuine defect rather than the spec. +TEST_F(PluginTest, UnregisterBoundPluginDestroysIt) { + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK(al_bind_plugin("equilibrium/time", kPlugin)); + AL_ASSERT_OK(al_unregister_plugin(kPlugin)); + EXPECT_FALSE(is_registered(kPlugin)) + << "a plugin bound to a path must be destroyed and removed on " + "unregister"; +} + +// =========================================================================== +// bind / unbind +// =========================================================================== + +TEST_F(PluginTest, BindRegisteredPluginSucceeds) { + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_EXPECT_OK(al_bind_plugin("equilibrium/time", kPlugin)); +} + +TEST_F(PluginTest, BindUnregisteredPluginReturnsError) { + // Deliberately not registered. + al_status_t s = al_bind_plugin("equilibrium/time", kNeverRegistered); + EXPECT_NE(s.code, 0) + << "binding a plugin that was never registered must error " + "(src/al_lowlevel.cpp:193-197)"; +} + +TEST_F(PluginTest, DoubleBindSamePathReturnsError) { + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK(al_bind_plugin("equilibrium/time", kPlugin)); + al_status_t s = al_bind_plugin("equilibrium/time", kPlugin); + EXPECT_NE(s.code, 0) + << "binding the same plugin to the same path twice must throw " + "(FUNCTIONALITY_INVENTORY.md:373-374, src/al_lowlevel.cpp:269-273)"; +} + +// Unbinding a path nothing is bound to is a documented silent no-op (returns +// success), not an error (FUNCTIONALITY_INVENTORY.md:374-376, +// src/al_lowlevel.cpp:302-305). Note: al_unbind_plugin does not even require +// the plugin to be registered — it operates on the bound-path map directly. +TEST_F(PluginTest, UnbindNeverBoundPathIsSilentNoOp) { + AL_EXPECT_OK(al_unbind_plugin("equilibrium/time", kPlugin)); +} + +// =========================================================================== +// al_setvalue_*_parameter_plugin — happy paths on a REGISTERED plugin +// (the unregistered-name crashes are death tests in test_known_defects.cpp) +// =========================================================================== + +// Reads the fixture plugin's setParameter log (see test_plugin_fixture.cpp). +std::string read_plugin_log(const std::filesystem::path& p) { + std::ifstream in(p); + std::stringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +// A per-test path for the fixture plugin's setParameter log, wired via the +// AL_CONTRACT_PLUGIN_LOG env var and RAII-cleaned. `tag` keeps concurrent cases +// from colliding on the same file. +class PluginLog { +public: + explicit PluginLog(const char* tag) + : path_(std::filesystem::temp_directory_path() / + ("al_contract_pluginlog_" + std::to_string(::getpid()) + "_" + + tag + ".txt")) { + std::filesystem::remove(path_); + setenv("AL_CONTRACT_PLUGIN_LOG", path_.string().c_str(), 1); + } + ~PluginLog() { std::filesystem::remove(path_); } + std::string contents() const { return read_plugin_log(path_); } + +private: + std::filesystem::path path_; +}; + +// Proves a parameter value crossed the C ABI and reached the plugin's +// setParameter, by matching the log line the fixture emits: +// PARAM|||| +void expect_param_logged(const PluginLog& log, const std::string& name, + int datatype, const std::string& value) { + const std::string logged = log.contents(); + EXPECT_NE(logged.find("PARAM|" + name + "|" + std::to_string(datatype) + + "|0|" + value), + std::string::npos) + << "plugin log did not record parameter '" << name << "'; got: [" + << logged << "]"; +} + +TEST_F(PluginTest, SetValueIntScalarOnRegisteredPluginReachesPlugin) { + PluginLog log("int"); + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK(al_setvalue_int_scalar_parameter_plugin("gain", 42, kPlugin)); + expect_param_logged(log, "gain", INTEGER_DATA, "42"); +} + +TEST_F(PluginTest, SetValueDoubleScalarOnRegisteredPluginReachesPlugin) { + PluginLog log("dbl"); + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK( + al_setvalue_double_scalar_parameter_plugin("scale", 1.5, kPlugin)); + expect_param_logged(log, "scale", DOUBLE_DATA, "1.5"); +} + +// The generic variant carries datatype/dim/size/data explicitly; assert the +// value reaches the plugin (not merely that the call returns OK), matching the +// strength of the int/double variants above. +TEST_F(PluginTest, SetValueGenericOnRegisteredPluginReachesPlugin) { + PluginLog log("generic"); + AL_ASSERT_OK(al_register_plugin(kPlugin)); + int value = 7; + AL_ASSERT_OK(al_setvalue_parameter_plugin("mode", INTEGER_DATA, 0, nullptr, + &value, kPlugin)); + expect_param_logged(log, "mode", INTEGER_DATA, "7"); +} + +// =========================================================================== +// al_setvalue_parameter_plugin's dim/size (FUNCTIONALITY_INVENTORY.md:399-420): +// is `dim` bound by MAXDIM? (issue #8 ownership sweep — resolved empirically, +// not assumed.) +// +// First hypothesis tried here was that dim > MAXDIM would be rejected; it +// isn't (confirmed by running this test against the built library before +// settling on the assertion below). LLplugin::setvalueParameterPlugin +// (al_lowlevel.cpp:453-457) forwards dim/size straight through to the +// plugin's setParameter with no comparison against MAXDIM and no copy into +// any core-side fixed-size buffer. MAXDIM bounds only al_read_data's/ +// al_write_data's own shape arrays, not this call chain — a plugin author +// choosing to size an internal buffer at MAXDIM would be relying on a +// convention the core does not enforce for this ABI. +// =========================================================================== +TEST_F(PluginTest, SetValueGenericAcceptsDimAboveMaxdim) { + PluginLog log("maxdim"); + AL_ASSERT_OK(al_register_plugin(kPlugin)); + + constexpr int kDim = MAXDIM + 1; // one past the documented bound + int size[kDim]; + for (int i = 0; i < kDim; ++i) size[i] = i + 1; + int value = 99; + al_status_t s = al_setvalue_parameter_plugin("oversize", INTEGER_DATA, kDim, + size, &value, kPlugin); + EXPECT_EQ(s.code, 0) + << "dim beyond MAXDIM is not bounds-checked by the core, so the call " + "must still succeed"; + const std::string logged = log.contents(); + EXPECT_NE(logged.find("PARAM|oversize|" + std::to_string(INTEGER_DATA) + + "|" + std::to_string(kDim) + "|-"), + std::string::npos) + << "dim must reach the plugin unmodified (not clamped to MAXDIM); " + "got: [" << logged << "]"; +} + +// =========================================================================== +// framework-gating: with IMAS_AL_ENABLE_PLUGINS unset the calls must error +// (src/al_lowlevel.cpp:116-119), not proceed. +// =========================================================================== + +TEST_F(PluginTest, RegisterWithFrameworkDisabledReturnsError) { + unsetenv("IMAS_AL_ENABLE_PLUGINS"); + al_status_t s = al_register_plugin(kPlugin); + EXPECT_NE(s.code, 0); +} + +TEST_F(PluginTest, IsPluginRegisteredWithFrameworkDisabledReturnsError) { + unsetenv("IMAS_AL_ENABLE_PLUGINS"); + bool r = false; + al_status_t s = al_is_plugin_registered(kPlugin, &r); + EXPECT_NE(s.code, 0); +} + +// =========================================================================== +// DEFECT (D2): registered-but-never-bound plugin left un-destroyed on +// unregister. FUNCTIONALITY_INVENTORY.md:347-354, src/al_lowlevel.cpp:382-431: +// unregisterPlugin's destroy()+erase only run inside the boundPlugins loop, so +// a plugin that was never bound survives unregister (and re-registering it then +// throws "already registered"). +// =========================================================================== + +// CORRECT-CONTRACT test, expected-fail (DISABLED_). The day unregister is fixed +// to destroy unconditionally, drop the DISABLED_ prefix. +TEST_F(PluginTest, DISABLED_UnregisterNeverBoundPluginDestroysIt) { + AL_ASSERT_OK(al_register_plugin(kPlugin)); + // Intentionally never bound. + AL_ASSERT_OK(al_unregister_plugin(kPlugin)); + EXPECT_FALSE(is_registered(kPlugin)) + << "correct contract: unregister must destroy and remove the plugin " + "even when it was never bound"; +} + +// CURRENT-BEHAVIOR tripwire: pins that the never-bound plugin stays registered +// after unregister. If this flips (is_registered becomes false), the defect was +// fixed — enable DISABLED_UnregisterNeverBoundPluginDestroysIt above. +TEST_F(PluginTest, UnregisterNeverBoundPluginLeavesItRegistered_CurrentBehavior) { + AL_ASSERT_OK(al_register_plugin(kPlugin)); + // Intentionally never bound. + AL_ASSERT_OK(al_unregister_plugin(kPlugin)) + << "unregister currently reports success even though it destroys " + "nothing"; + EXPECT_TRUE(is_registered(kPlugin)) + << "tripwire: a never-bound plugin is currently left in the registry " + "on unregister (FUNCTIONALITY_INVENTORY.md:347-354). If this fails, " + "the defect was fixed — enable " + "PluginTest.UnregisterNeverBoundPluginDestroysIt."; +} + +// =========================================================================== +// DEFECT (D2): dlopen failure in registerPlugin is guarded only by an assert. +// FUNCTIONALITY_INVENTORY.md:355-360, src/al_lowlevel.cpp:350-357: a failed +// dlopen() is printf'd and asserted, so current behavior is BUILD-MODE +// DEPENDENT and the pins below are too (issue #32): +// +// - assert-enabled builds (Debug, no NDEBUG): the assert fires -> SIGABRT. +// Pinned with a death test so the abort happens in a child process and +// the suite itself survives. +// - NDEBUG builds (Release/RelWithDebInfo): the assert is stripped and the +// null handle flows on to dlsym, whose own null-check throws — so +// registerPlugin returns LOWLEVEL_ERR with the *misleading* "Cannot load +// symbol create" message rather than reporting the dlopen failure, and +// does NOT crash. +// +// Either way the correct contract is the same (a nonzero status attributing +// the actual dlopen error) — the DISABLED_ test below stays build-mode +// independent and becomes enableable once registerPlugin handles the failed +// dlopen instead of asserting on it. +// =========================================================================== + +// Points IMAS_AL_PLUGINS at a temp dir holding a garbage _plugin.so that +// exists (so registerPlugin gets past its boost::filesystem::exists check) but +// is not a loadable shared library, then registers `name`. Returns the status. +al_status_t register_unloadable(const std::filesystem::path& dir, + const char* name) { + namespace fs = std::filesystem; + fs::create_directories(dir); + const fs::path so = dir / (std::string(name) + "_plugin.so"); + { + std::ofstream out(so, std::ios::binary | std::ios::trunc); + out << "this is deliberately not a valid shared library\n"; + } + setenv("IMAS_AL_PLUGINS", dir.string().c_str(), 1); + return al_register_plugin(name); +} + +// Both tests key on whether the returned status *attributes the dlopen +// failure*. Correct handling would surface the dlerror (message mentions +// "dlopen"); the current swallowed-assert path does not — it reports the +// unrelated downstream symbol miss instead. Keying on "dlopen" presence is +// robust to the exact downstream text (which depends on dlsym's null-handle +// diagnostic, and on no global "create" symbol existing). +bool status_reports_dlopen(const al_status_t& s) { + return std::string(s.message).find("dlopen") != std::string::npos; +} + +// CORRECT-CONTRACT test, expected-fail (DISABLED_): the error must attribute the +// dlopen failure (surface the dlerror), not a downstream symptom. +TEST_F(PluginTest, DISABLED_RegisterUnloadableSharedLibReportsDlopenFailure) { + namespace fs = std::filesystem; + const fs::path dir = fs::temp_directory_path() / + ("al_contract_badplugin_" + + std::to_string(::getpid()) + "_a"); + al_status_t s = register_unloadable(dir, "unloadable_a"); + EXPECT_NE(s.code, 0); + EXPECT_TRUE(status_reports_dlopen(s)) + << "correct contract: report the dlopen failure (dlerror). Got: " + << s.message; + fs::remove_all(dir); +} + +#ifdef NDEBUG +// CURRENT-BEHAVIOR tripwire (NDEBUG builds): pins that the returned status does +// NOT mention the dlopen failure — the NDEBUG-stripped assert lets the null +// handle flow past dlopen, so the failure is swallowed and only a downstream +// symptom is reported. If the status starts naming dlopen, the handling was +// fixed — enable the DISABLED_ test above. +TEST_F(PluginTest, RegisterUnloadableSharedLibSwallowsAssert_CurrentBehavior) { + namespace fs = std::filesystem; + const fs::path dir = fs::temp_directory_path() / + ("al_contract_badplugin_" + + std::to_string(::getpid()) + "_b"); + al_status_t s = register_unloadable(dir, "unloadable_b"); + EXPECT_NE(s.code, 0) << "on this NDEBUG build it returns an error, not a crash"; + EXPECT_FALSE(status_reports_dlopen(s)) + << "tripwire: the dlopen failure is currently swallowed by the " + "NDEBUG-stripped assert, so the status does not attribute it " + "(FUNCTIONALITY_INVENTORY.md:355-360). If this fails, the dlopen " + "handling was fixed — enable " + "PluginTest.RegisterUnloadableSharedLibReportsDlopenFailure."; + fs::remove_all(dir); +} +#else +// CURRENT-BEHAVIOR tripwire (assert-enabled builds): the product assertion at +// src/al_lowlevel.cpp fires on the failed dlopen and aborts. Run the call in a +// death-test child so the runner survives; pin SIGABRT + the assertion text +// specifically, so an unrelated crash is not mistaken for this defect. The +// fixture artifacts are created and removed by the PARENT — the child dying +// must not leak the temp dir (issue #32). +TEST_F(PluginTest, RegisterUnloadableSharedLibAbortsOnAssert_CurrentBehavior) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + namespace fs = std::filesystem; + const fs::path dir = fs::temp_directory_path() / + ("al_contract_badplugin_" + + std::to_string(::getpid()) + "_b"); + // threadsafe-style death tests RE-EXECUTE the whole test in the child, + // which would recompute `dir` with the child's own pid and leave that + // directory behind; hand the parent's path down via the environment so + // parent and child agree on the one directory the parent removes below. + // overwrite=0: the re-executed child runs this line too and must NOT + // replace the inherited parent value with its own pid-derived path. + setenv("AL_CONTRACT_BADPLUGIN_DIR", dir.string().c_str(), /*overwrite=*/0); + const std::filesystem::path used_dir = + std::getenv("AL_CONTRACT_BADPLUGIN_DIR"); + ASSERT_EXIT( + { + const char* d = std::getenv("AL_CONTRACT_BADPLUGIN_DIR"); + register_unloadable(d != nullptr ? fs::path(d) : dir, + "unloadable_b"); + }, + ::testing::KilledBySignal(SIGABRT), "[Aa]ssert") + << "expected the registerPlugin dlopen assert to abort in an " + "assert-enabled build (src/al_lowlevel.cpp, " + "FUNCTIONALITY_INVENTORY.md:355-360). If this no longer dies, the " + "handling was fixed — enable " + "PluginTest.RegisterUnloadableSharedLibReportsDlopenFailure."; + unsetenv("AL_CONTRACT_BADPLUGIN_DIR"); + fs::remove_all(used_dir); +} +#endif + +// =========================================================================== +// al_plugin_* low-level reentry (FUNCTIONALITY_INVENTORY.md:854-895, Part 3 +// cluster 5) — issue #8 ownership sweep. +// +// These are plain C-ABI functions that go straight to +// `lle.backend->{beginAction,writeData,readData,endAction}`, bypassing plugin +// dispatch entirely — they are exactly what al_write_data/al_read_data fall +// back to when no regular plugin is bound to the path +// (al_lowlevel.cpp:1692,1728). No plugin registry object or dlopen'd plugin +// is needed to call them directly: same preconditions as the ordinary +// al_begin_global_action/al_write_data/al_read_data/al_end_action quartet. +// (al_plugin_begin_timerange_action is excluded — a known declaration/ +// definition mismatch, tracked separately per TRACEABILITY.md.) +// =========================================================================== +class PluginReentry : public ::testing::Test { +protected: + al_contract::TempBase base_; + al_contract::PulseId pulse_{/*database=*/"test", /*version=*/"3", + /*pulse=*/12, /*run=*/0}; +}; + +TEST_F(PluginReentry, WriteDataReentersTheBackendDirectly) { + const int pctx = open_memory_pulse(base_, pulse_); + + int opctx = -1; + AL_ASSERT_OK(al_plugin_begin_global_action(pctx, "magnetics", "", + WRITE_OP, &opctx)); + int value = 55; + AL_ASSERT_OK(al_plugin_write_data(opctx, "comment", "", &value, + INTEGER_DATA, 0, nullptr)); + AL_ASSERT_OK(al_plugin_end_action(opctx)); + + // Read back through the *ordinary* ABI to prove al_plugin_write_data + // landed in the real backend storage, not some plugin-side stash. + int op2 = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, "magnetics", "", READ_OP, &op2)); + int got = -1; + AL_ASSERT_OK(al_contract::read_int_scalar(op2, "comment", &got)); + EXPECT_EQ(got, 55); + al_end_action(op2); + + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_F(PluginReentry, ReadDataAlsoReentersTheBackendDirectly) { + const int pctx = open_memory_pulse(base_, pulse_); + + // Write through the ordinary ABI... + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, "magnetics", "", WRITE_OP, &op)); + AL_ASSERT_OK(al_contract::write_int_scalar(op, "comment", 77)); + al_end_action(op); + + // ...and read it back through the plugin reentry path. + int opctx = -1; + AL_ASSERT_OK(al_plugin_begin_global_action(pctx, "magnetics", "", READ_OP, + &opctx)); + int got = -1; + int size[MAXDIM] = {0}; + void* buf = &got; + AL_ASSERT_OK(al_plugin_read_data(opctx, "comment", "", &buf, INTEGER_DATA, + 0, size)); + EXPECT_EQ(got, 77); + AL_ASSERT_OK(al_plugin_end_action(opctx)); + + al_close_pulse(pctx, CLOSE_PULSE); +} + +// =========================================================================== +// Action lifecycle & data interception (FUNCTIONALITY_INVENTORY.md:784-824, +// Part 3 cluster 3) — issue #8 ownership sweep. +// +// al_write_data/al_read_data check LLplugin::getBoundPlugins for the target +// field path *before* touching the backend (al_lowlevel.cpp:1685-1693, +// 1721-1731): if a plugin is bound there, the call dispatches to the +// plugin's write_data/read_data instead of the backend. ContractTestPlugin's +// write_data is an inert no-op and its read_data always returns 0 ("no +// data"), so binding it to a path makes writes there vanish (never reach the +// backend) and reads come back as the "no data" default — proof the plugin, +// not the backend, handled the call. +// =========================================================================== +TEST_F(PluginTest, BoundPluginInterceptsWriteInsteadOfBackend) { + al_contract::TempBase base; + al_contract::PulseId pulse{"test", "3", 12, 0}; + const int pctx = open_memory_pulse(base, pulse); + + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK(al_bind_plugin("magnetics/probe", kPlugin)); + + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, "magnetics", "", WRITE_OP, &op)); + AL_ASSERT_OK(al_contract::write_int_scalar(op, "probe", 42)); + // A sibling field, never bound to any plugin: the normal path, as a + // control proving the value 42 above wasn't lost for some unrelated + // reason. + AL_ASSERT_OK(al_contract::write_int_scalar(op, "control", 42)); + al_end_action(op); + + // Unregister *before* reading back, so the read genuinely reaches the + // backend — otherwise the still-bound plugin's read_data() would also + // intercept the read, and EXPECT_NE(probe, 42) below would pass even if + // the write had actually landed (conflating write-interception with + // read-interception, which BoundPluginInterceptsReadInsteadOfBackend + // covers separately). al_unregister_plugin, not al_unbind_plugin: bind + // normalizes "magnetics/probe" to "magnetics:0/probe" internally + // (al_lowlevel.cpp LLplugin::bindPlugin's occurrence-suffix regex), but + // unbind does no such normalization and looks up the raw string + // unchanged — so al_unbind_plugin("magnetics/probe", kPlugin) here would + // silently miss (confirmed: it prints "No plugin bound..." and leaves + // the binding intact). al_unregister_plugin instead scans boundPlugins + // by plugin name, not by path string, so it isn't sensitive to this + // normalization mismatch. + AL_ASSERT_OK(al_unregister_plugin(kPlugin)); + + int op2 = -1; + AL_ASSERT_OK( + al_begin_global_action(pctx, "magnetics", "", READ_OP, &op2)); + int control = -1; + AL_ASSERT_OK(al_contract::read_int_scalar(op2, "control", &control)); + EXPECT_EQ(control, 42) << "the unbound sibling field must round-trip " + "normally (control for the assertion below)"; + + int probe = -1; + AL_ASSERT_OK(al_contract::read_int_scalar(op2, "probe", &probe)); + EXPECT_NE(probe, 42) + << "the bound plugin's inert write_data() must have absorbed the " + "write — 42 must never have reached the Memory backend, even " + "read back directly (unbound) afterward"; + al_end_action(op2); + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_F(PluginTest, BoundPluginInterceptsReadInsteadOfBackend) { + al_contract::TempBase base; + al_contract::PulseId pulse{"test", "3", 12, 0}; + const int pctx = open_memory_pulse(base, pulse); + + // Write for real, with no plugin bound. + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, "magnetics", "", WRITE_OP, &op)); + AL_ASSERT_OK(al_contract::write_int_scalar(op, "probe", 42)); + al_end_action(op); + + // Bind only for the read. + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK(al_bind_plugin("magnetics/probe", kPlugin)); + + int op2 = -1; + AL_ASSERT_OK( + al_begin_global_action(pctx, "magnetics", "", READ_OP, &op2)); + int got = -1; + AL_ASSERT_OK(al_contract::read_int_scalar(op2, "probe", &got)); + EXPECT_NE(got, 42) + << "the bound plugin's read_data() (returns 0, \"no data\") must " + "answer instead of the backend, even though 42 really is stored"; + al_end_action(op2); + al_close_pulse(pctx, CLOSE_PULSE); +} + +// =========================================================================== +// al_write_plugins_metadata (FUNCTIONALITY_INVENTORY.md:422-430, User Cluster +// 3 + Part 3 cluster 1 provenance) — issue #8 ownership sweep. +// +// Writes, under the just-written IDS's "ids_properties/plugins/node" AOS, one +// entry per distinct bound-and-put-capable path: the path itself, a nested +// "put_operation" AOS with the bound plugin's provenance +// (getName/getDescription/getCommit/getVersion/getRepository/getParameters), +// and a nested "readback" AOS built from getReadbackName/* for any plugin +// that opts in — empty here, since ContractTestPlugin declares no readback +// capability for any path (access_layer_plugin_manager.cpp:359-489). +// =========================================================================== +TEST_F(PluginTest, WritePluginsMetadataStoresBoundPluginProvenance) { + al_contract::TempBase base; + al_contract::PulseId pulse{"test", "3", 12, 0}; + const int pctx = open_memory_pulse(base, pulse); + + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK(al_bind_plugin("magnetics/comment", kPlugin)); + + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, "magnetics", "", WRITE_OP, &op)); + AL_ASSERT_OK(al_write_plugins_metadata(op)); + al_end_action(op); + + int op2 = -1; + AL_ASSERT_OK( + al_begin_global_action(pctx, "magnetics", "", READ_OP, &op2)); + int nsize = 0; + int naos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action( + op2, "ids_properties/plugins/node", "", &nsize, &naos)); + ASSERT_EQ(nsize, 1) << "one bound put-capable path -> one node entry"; + + std::string path; + AL_ASSERT_OK(al_contract::read_char_array(naos, "path", &path)); + EXPECT_EQ(path, "comment") + << "the dataobjectname prefix must be stripped from the stored path"; + + int put_size = 0; + int put_aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(naos, "put_operation", "", + &put_size, &put_aos)); + ASSERT_EQ(put_size, 1); + std::string name, version, commit, repository; + AL_ASSERT_OK(al_contract::read_char_array(put_aos, "name", &name)); + AL_ASSERT_OK(al_contract::read_char_array(put_aos, "version", &version)); + AL_ASSERT_OK(al_contract::read_char_array(put_aos, "commit", &commit)); + AL_ASSERT_OK( + al_contract::read_char_array(put_aos, "repository", &repository)); + EXPECT_EQ(name, "alcontract"); + EXPECT_EQ(version, "0.0.0"); + EXPECT_EQ(commit, "0000000"); + EXPECT_EQ(repository, "in-repo"); + if (put_aos != 0) al_end_action(put_aos); + + int readback_size = -1; + int readback_aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(naos, "readback", "", + &readback_size, &readback_aos)); + EXPECT_EQ(readback_size, 0) + << "ContractTestPlugin declares no readback capability for any path"; + if (readback_aos != 0) al_end_action(readback_aos); + + if (naos != 0) al_end_action(naos); + al_end_action(op2); + al_close_pulse(pctx, CLOSE_PULSE); +} + +// =========================================================================== +// al_bind_readback_plugins / al_unbind_readback_plugins +// (FUNCTIONALITY_INVENTORY.md:378-389, User Cluster 3 + Part 3 cluster 4 +// readback metadata) — issue #8 ownership sweep. +// +// Documented as "function called before/after a get()" +// (access_layer_plugin_manager.cpp:52,268): given metadata previously +// written by al_write_plugins_metadata, al_bind_readback_plugins reads it +// back and auto-registers + auto-binds any plugin that declared itself a +// readback provider for the stored path — the whole point being that the +// get()-side caller need not already know which plugins apply. +// al_unbind_readback_plugins then auto-unregisters exactly what it +// auto-bound. ContractTestPlugin opts into being its own readback provider +// for one path via AL_CONTRACT_PLUGIN_READBACK_PATH (test_plugin_fixture.cpp) +// — self-consistent with its own provenance, so the version check in +// AccessLayerPluginManager::bind_readback_plugins passes. +// =========================================================================== +// Same fixture as PluginTest, plus the one extra env var that opts +// ContractTestPlugin into readback capability for a single path. +class ReadbackPlugins : public PluginTest { +protected: + void SetUp() override { + PluginTest::SetUp(); + setenv("AL_CONTRACT_PLUGIN_READBACK_PATH", "comment", 1); + } + void TearDown() override { + unsetenv("AL_CONTRACT_PLUGIN_READBACK_PATH"); + PluginTest::TearDown(); + } +}; + +TEST_F(ReadbackPlugins, BindReadbackPluginsAutoRegistersAndBindsFromStoredMetadata) { + al_contract::TempBase base; + al_contract::PulseId pulse{"test", "3", 12, 0}; + const int pctx = open_memory_pulse(base, pulse); + + // --- put-time: bind, then write metadata describing this plugin as its + // own readback provider for "comment" --- + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK(al_bind_plugin("magnetics/comment", kPlugin)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, "magnetics", "", WRITE_OP, &op)); + AL_ASSERT_OK(al_write_plugins_metadata(op)); + al_end_action(op); + AL_ASSERT_OK(al_unregister_plugin(kPlugin)); // simulate a fresh session + + ASSERT_FALSE(is_registered(kPlugin)) + << "must start the get() side genuinely unregistered"; + + // --- get-time: bind_readback_plugins must find the stored metadata and + // re-register + bind the plugin on its own --- + int op2 = -1; + AL_ASSERT_OK( + al_begin_global_action(pctx, "magnetics", "", READ_OP, &op2)); + AL_ASSERT_OK(al_bind_readback_plugins(op2)); + EXPECT_TRUE(is_registered(kPlugin)) + << "bind_readback_plugins must auto-register a plugin named in " + "stored metadata"; + al_status_t rebind = al_bind_plugin("magnetics/comment", kPlugin); + EXPECT_NE(rebind.code, 0) + << "the path must already be bound after bind_readback_plugins (a " + "double-bind is a documented error) -- indirect ABI-observable " + "proof of the bind"; + + AL_ASSERT_OK(al_unbind_readback_plugins(op2)); + EXPECT_FALSE(is_registered(kPlugin)) + << "unbind_readback_plugins must auto-unregister what it auto-bound"; + + al_end_action(op2); + al_close_pulse(pctx, CLOSE_PULSE); +} + +} // namespace + +#endif // !_WIN32 diff --git a/tests/contract/test_pulse_lifecycle.cpp b/tests/contract/test_pulse_lifecycle.cpp new file mode 100644 index 00000000..6969c1d8 --- /dev/null +++ b/tests/contract/test_pulse_lifecycle.cpp @@ -0,0 +1,432 @@ +// Pulse-lifecycle detail contract tests (issue #4 / TEST_STRATEGY.md §4 step 2). +// +// The scaffold and matrix suites only ever drive FORCE_CREATE_PULSE + CLOSE_PULSE +// (TRACEABILITY.md marks the other modes / al_context_info / al_get_backendID as +// gaps). This file closes those: al_begin_dataentry_action's four modes, +// al_context_info's format and ownership, al_get_backendID's happy path (and a +// real null-deref defect on the wrong context type), and al_close_pulse's +// CLOSE_PULSE vs ERASE_PULSE distinction. +// +// Per D2, the FUNCTIONALITY_INVENTORY / issue citation for every DISABLED_ +// expected-fail below lives in TRACEABILITY.md (xfail bookkeeping table). +// +// Flexbuffers is excluded from the mode-matrix and erase tests: it is a pure +// in-process serialize/deserialize buffer with no persistent pulse state, so it +// does not distinguish among the four open modes at all (src/flexbuffers_backend.cpp +// openPulse only branches on read-vs-write) — the questions this file asks +// ("does OPEN_PULSE fail when absent", "does erase remove persisted state") +// don't apply to it. + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include +#include + +using al_contract::BackendCase; +using al_contract::PulseId; + +namespace { + +constexpr const char* kIds = "magnetics"; + +const BackendCase kLifecycleBackends[] = { + {HDF5_BACKEND, "HDF5", /*on_disk=*/true}, + {MEMORY_BACKEND, "Memory", /*on_disk=*/false}, + {ASCII_BACKEND, "ASCII", /*on_disk=*/true}, +}; + +// =========================================================================== +// al_begin_dataentry_action mode matrix. +// =========================================================================== +struct ModeBackendCase { + int id; + const char* name; + bool on_disk; + // Whether CREATE_PULSE genuinely refuses to overwrite an existing pulse + // (true for HDF5/Memory, verified by reading HDF5Utils::createPulse and + // MemoryBackend::openPulse; false for ASCII, a real defect — see + // ModeKnownDefects below). + bool create_guards_existing; +}; + +inline void PrintTo(const ModeBackendCase& b, std::ostream* os) { *os << b.name; } + +const ModeBackendCase kModeBackends[] = { + {HDF5_BACKEND, "HDF5", /*on_disk=*/true, /*create_guards_existing=*/true}, + {MEMORY_BACKEND, "Memory", /*on_disk=*/false, /*create_guards_existing=*/true}, + {ASCII_BACKEND, "ASCII", /*on_disk=*/true, /*create_guards_existing=*/false}, +#ifdef AL_CONTRACT_HAVE_MDSPLUS + // Confirmed empirically (issue #14): MDSplusBackend::openPulse's mode + // switch guards CREATE_PULSE against an existing "ids_001.tree" exactly + // like HDF5/Memory (src/mdsplus/mdsplus_backend.cpp:4507-4510) — no defect + // here, unlike ASCII's. + {MDSPLUS_BACKEND, "Mdsplus", /*on_disk=*/true, /*create_guards_existing=*/true}, +#endif +}; + +class DataEntryModes : public ::testing::TestWithParam { +protected: + void SetUp() override { +#ifdef AL_CONTRACT_HAVE_MDSPLUS + if (GetParam().id == MDSPLUS_BACKEND) AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); +#endif + } + + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; + + // precreate_tree=false leaves nothing on disk at all — the genuinely + // "absent" case. ASCII's OPEN_PULSE only checks that the *directory* + // exists (ascii_backend.cpp:123-127), never that a pulse was actually + // created there, so pre-creating the legacy tree (as every other test in + // this fixture does, matching al_contract::TempBase's own documented + // purpose "for FORCE_CREATE_PULSE") would make OPEN_PULSE spuriously + // succeed on ASCII against an empty directory. + std::string uri(bool precreate_tree = true) const { + const ModeBackendCase& b = GetParam(); + if (precreate_tree && b.on_disk) base_.make_legacy_tree(pulse_); + return al_contract::build_uri(b.id, base_.str(), pulse_); + } +}; + +TEST_P(DataEntryModes, OpenPulseFailsWhenAbsent) { + const std::string u = uri(/*precreate_tree=*/false); + ASSERT_FALSE(u.empty()); + int pctx = -1; + al_status_t s = al_begin_dataentry_action(u.c_str(), OPEN_PULSE, &pctx); + EXPECT_NE(s.code, 0) << "OPEN_PULSE must fail when no pulse exists yet"; + if (s.code == 0) al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_P(DataEntryModes, ForceOpenPulseCreatesWhenAbsent) { + const std::string u = uri(); + ASSERT_FALSE(u.empty()); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(u.c_str(), FORCE_OPEN_PULSE, &pctx)); + EXPECT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); +} + +TEST_P(DataEntryModes, OpenPulseSucceedsOncePresent) { + const std::string u = uri(); + ASSERT_FALSE(u.empty()); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx)); + EXPECT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); + + int pctx2 = -1; + AL_EXPECT_OK(al_begin_dataentry_action(u.c_str(), OPEN_PULSE, &pctx2)); + EXPECT_EQ(al_close_pulse(pctx2, CLOSE_PULSE).code, 0); +} + +TEST_P(DataEntryModes, CreatePulseFailsWhenAlreadyExists) { + const ModeBackendCase& b = GetParam(); + if (!b.create_guards_existing) { + GTEST_SKIP() << "known defect for " << b.name + << " — see ModeKnownDefects.*"; + } + const std::string u = uri(); + ASSERT_FALSE(u.empty()); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx)); + EXPECT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); + + int pctx2 = -1; + al_status_t s = al_begin_dataentry_action(u.c_str(), CREATE_PULSE, &pctx2); + EXPECT_NE(s.code, 0) << "CREATE_PULSE must refuse to overwrite an existing pulse"; + if (s.code == 0) al_close_pulse(pctx2, CLOSE_PULSE); +} + +INSTANTIATE_TEST_SUITE_P( + Backends, DataEntryModes, ::testing::ValuesIn(kModeBackends), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.name); + }); + +// --- ASCII: CREATE_PULSE silently truncates instead of refusing ------------ +TEST(ModeKnownDefects, DISABLED_AsciiCreatePulseFailsWhenAlreadyExists) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string u = al_contract::build_uri(ASCII_BACKEND, base.str(), pulse); + int pctx = -1; + ASSERT_EQ(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + ASSERT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); + + int pctx2 = -1; + al_status_t s = al_begin_dataentry_action(u.c_str(), CREATE_PULSE, &pctx2); + EXPECT_NE(s.code, 0) + << "CREATE_PULSE must refuse an already-existing pulse (documented: " + "\"create a new pulse, do not overwrite if already exist\")"; + if (s.code == 0) al_close_pulse(pctx2, CLOSE_PULSE); +} + +TEST(ModeKnownDefects, AsciiCreatePulseCurrentlyOverwritesSilently) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string u = al_contract::build_uri(ASCII_BACKEND, base.str(), pulse); + int pctx = -1; + ASSERT_EQ(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + int op = -1; + al_begin_global_action(pctx, kIds, "", WRITE_OP, &op); + al_contract::write_data(op, "leaf", {}, {11.0}); + al_end_action(op); + ASSERT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); + + int pctx2 = -1; + al_status_t s = al_begin_dataentry_action(u.c_str(), CREATE_PULSE, &pctx2); + EXPECT_EQ(s.code, 0) + << "AsciiBackend::openPulse now guards CREATE_PULSE against an " + "existing pulse — enable " + "ModeKnownDefects.DISABLED_AsciiCreatePulseFailsWhenAlreadyExists " + "(ascii_backend.cpp:107-157 has no existence check for CREATE_PULSE)"; + if (s.code == 0) al_close_pulse(pctx2, CLOSE_PULSE); +} + +// =========================================================================== +// al_context_info: format per context type, NULL-context special case, +// malloc ownership (src/al_lowlevel.cpp:828-832 -> free() is correct). +// =========================================================================== +class ContextInfo : public ::testing::Test { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; +}; + +TEST_F(ContextInfo, NullContextReturnsLiteralString) { + char* info = nullptr; + AL_ASSERT_OK(al_context_info(0, &info)); + ASSERT_NE(info, nullptr); + EXPECT_STREQ(info, "NULL context"); + free(info); +} + +TEST_F(ContextInfo, PulseContextDescribesItsUri) { + base_.make_legacy_tree(pulse_); + const std::string u = al_contract::build_uri(HDF5_BACKEND, base_.str(), pulse_); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx)); + + char* info = nullptr; + AL_ASSERT_OK(al_context_info(pctx, &info)); + ASSERT_NE(info, nullptr); + EXPECT_NE(std::string(info).find("uri"), std::string::npos) + << "pulse context description must mention its uri: " << info; + free(info); + + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_F(ContextInfo, OperationContextDescribesDataobjectAndAccessmode) { + base_.make_legacy_tree(pulse_); + const std::string u = al_contract::build_uri(HDF5_BACKEND, base_.str(), pulse_); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + + char* info = nullptr; + AL_ASSERT_OK(al_context_info(op, &info)); + ASSERT_NE(info, nullptr); + const std::string s(info); + EXPECT_NE(s.find("dataobjectname"), std::string::npos) << s; + EXPECT_NE(s.find("accessmode"), std::string::npos) << s; + EXPECT_NE(s.find(kIds), std::string::npos) << s; + free(info); + + al_end_action(op); + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_F(ContextInfo, ArraystructContextDescribesPathAndIndex) { + base_.make_legacy_tree(pulse_); + const std::string u = al_contract::build_uri(HDF5_BACKEND, base_.str(), pulse_); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + int size = 1; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, "elements", "", &size, &aos)); + + char* info = nullptr; + AL_ASSERT_OK(al_context_info(aos, &info)); + ASSERT_NE(info, nullptr); + const std::string s(info); + EXPECT_NE(s.find("path"), std::string::npos) << s; + EXPECT_NE(s.find("index"), std::string::npos) << s; + free(info); + + al_end_action(aos); + al_end_action(op); + al_close_pulse(pctx, CLOSE_PULSE); +} + +// =========================================================================== +// al_build_uri_from_legacy_parameters: caller frees `*uri` (malloc-based, +// src/al_context.cpp:241-261 `malloc`+`memcpy`+NUL) — issue #8 ownership +// sweep. al_contract::build_uri already frees it on every call across this +// whole suite; this test pins the ownership contract explicitly by calling +// the raw ABI function directly instead of through that helper, mirroring +// ContextInfo's explicit-free style above. +// =========================================================================== +TEST(UriOwnership, CallerFreesBuiltUri) { + al_contract::TempBase base; + PulseId pulse{/*database=*/"test", /*version=*/"3", /*pulse=*/12, + /*run=*/0}; + char* uri = nullptr; + al_status_t s = al_build_uri_from_legacy_parameters( + HDF5_BACKEND, pulse.pulse, pulse.run, base.str().c_str(), + pulse.database.c_str(), pulse.version.c_str(), "", &uri); + AL_ASSERT_OK(s); + ASSERT_NE(uri, nullptr); + EXPECT_NE(std::string(uri).find("pulse=12"), std::string::npos) << uri; + free(uri); +} + +// =========================================================================== +// al_get_backendID. +// =========================================================================== +TEST(GetBackendId, ReturnsTheBackendUsedToOpen) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string u = al_contract::build_uri(HDF5_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx)); + + int beid = -1; + AL_ASSERT_OK(al_get_backendID(pctx, &beid)); + EXPECT_EQ(beid, HDF5_BACKEND); + + al_close_pulse(pctx, CLOSE_PULSE); +} + +// --- known defect: al_get_backendID silently accepts a non-pulse context --- +// al_lowlevel.cpp's al_get_backendID reinterprets ctxID's context via +// `static_cast(lle.context)` — NOT a dynamic_cast, and with +// no type check at all (unlike al_close_pulse / al_begin_dataentry_action / +// al_delete_data, which all dynamic_cast and explicitly guard against a null +// result). Confirmed empirically (a debug build against this exact call): +// passing an OperationContext does NOT crash — the cast is well-formed +// pointer arithmetic, so it silently reads whatever bytes sit at +// DataEntryContext::backend_id's offset inside the unrelated object and +// returns them as if they were valid, with status.code==0. That value is +// undefined behavior (not a stable "wrong number" to assert), so the only +// portable part of the defect to pin is that the call reports *success* at +// all for a context of the wrong type. +TEST(GetBackendIdKnownDefects, DISABLED_WrongContextTypeReturnsError) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string u = al_contract::build_uri(HDF5_BACKEND, base.str(), pulse); + int pctx = -1; + ASSERT_EQ(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + int op = -1; + ASSERT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); + + int beid = -1; + al_status_t s = al_get_backendID(op, &beid); + EXPECT_NE(s.code, 0) + << "al_get_backendID on a non-pulse context must return an error"; + + al_end_action(op); + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST(GetBackendIdKnownDefects, WrongContextTypeCurrentlySucceedsViaUnsafeCast) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string u = al_contract::build_uri(HDF5_BACKEND, base.str(), pulse); + int pctx = -1; + ASSERT_EQ(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + int op = -1; + ASSERT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); + + int beid = -1; + al_status_t s = al_get_backendID(op, &beid); + EXPECT_EQ(s.code, 0) + << "al_get_backendID now validates context type — enable " + "GetBackendIdKnownDefects.DISABLED_WrongContextTypeReturnsError " + "(al_lowlevel.cpp al_get_backendID uses an unchecked static_cast)"; + + al_end_action(op); + al_close_pulse(pctx, CLOSE_PULSE); +} + +// =========================================================================== +// al_close_pulse: CLOSE_PULSE vs ERASE_PULSE. +// =========================================================================== +// None of the three always-on persistent backends actually branch on `mode` +// in closePulse (verified by reading HDF5Reader/Writer::closePulse, +// AsciiBackend::closePulse, and MemoryBackend::closePulse, none of which +// reference their mode parameter) — ERASE_PULSE behaves exactly like +// CLOSE_PULSE. The oracle here is backend-agnostic and avoids hard-coding any +// on-disk path convention: after ERASE_PULSE, a plain (non-FORCE) OPEN_PULSE +// on the same URI is the documented signal that the pulse is gone. +bool erase_then_plain_open_succeeds(const BackendCase& b, + al_contract::TempBase& base, + const PulseId& pulse) { + if (b.on_disk) base.make_legacy_tree(pulse); + const std::string u = al_contract::build_uri(b.id, base.str(), pulse); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); + EXPECT_EQ(al_contract::write_data(op, "leaf", {}, {11.0}).code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + EXPECT_EQ(al_close_pulse(pctx, ERASE_PULSE).code, 0); + + int pctx2 = -1; + al_status_t s = al_begin_dataentry_action(u.c_str(), OPEN_PULSE, &pctx2); + if (s.code == 0) al_close_pulse(pctx2, CLOSE_PULSE); + return s.code == 0; +} + +class ErasePulseKnownDefects : public ::testing::Test { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; +}; + +TEST_F(ErasePulseKnownDefects, DISABLED_Hdf5EraseMakesPulseUnopenable) { + EXPECT_FALSE(erase_then_plain_open_succeeds(kLifecycleBackends[0], base_, pulse_)) + << "ERASE_PULSE must remove the pulse so a later plain OPEN_PULSE fails"; +} +TEST_F(ErasePulseKnownDefects, Hdf5EraseCurrentlyLeavesPulseOpenable) { + EXPECT_TRUE(erase_then_plain_open_succeeds(kLifecycleBackends[0], base_, pulse_)) + << "HDF5Reader/Writer::closePulse now honors ERASE_PULSE — enable " + "DISABLED_Hdf5EraseMakesPulseUnopenable " + "(closePulse never references its mode parameter today)"; +} + +TEST_F(ErasePulseKnownDefects, DISABLED_AsciiEraseMakesPulseUnopenable) { + EXPECT_FALSE(erase_then_plain_open_succeeds(kLifecycleBackends[2], base_, pulse_)) + << "ERASE_PULSE must remove the pulse so a later plain OPEN_PULSE fails"; +} +TEST_F(ErasePulseKnownDefects, AsciiEraseCurrentlyLeavesPulseOpenable) { + EXPECT_TRUE(erase_then_plain_open_succeeds(kLifecycleBackends[2], base_, pulse_)) + << "AsciiBackend::closePulse now honors ERASE_PULSE — enable " + "DISABLED_AsciiEraseMakesPulseUnopenable " + "(closePulse ignores its mode parameter today)"; +} + +TEST_F(ErasePulseKnownDefects, DISABLED_MemoryEraseMakesPulseUnopenable) { + EXPECT_FALSE(erase_then_plain_open_succeeds(kLifecycleBackends[1], base_, pulse_)) + << "ERASE_PULSE must remove the pulse so a later plain OPEN_PULSE fails"; +} +TEST_F(ErasePulseKnownDefects, MemoryEraseCurrentlyLeavesPulseOpenable) { + EXPECT_TRUE(erase_then_plain_open_succeeds(kLifecycleBackends[1], base_, pulse_)) + << "MemoryBackend::closePulse now honors ERASE_PULSE — enable " + "DISABLED_MemoryEraseMakesPulseUnopenable " + "(closePulse is an empty function body today)"; +} + +} // namespace diff --git a/tests/contract/test_real_dd_path_catalog.cpp b/tests/contract/test_real_dd_path_catalog.cpp new file mode 100644 index 00000000..64535e98 --- /dev/null +++ b/tests/contract/test_real_dd_path_catalog.cpp @@ -0,0 +1,82 @@ +// Always-on meta-tests for the shared real-DD-path catalog (issue #46). +// +// These guard the catalog's own invariants (one verdict per cell, no duplicate +// keys) independently of any backend. The per-backend "the matrix covers every +// catalog key" meta-tests live in test_mdsplus_real_paths.cpp and +// test_uda_real_paths.cpp behind their compile gates, so a matrix cannot drop a +// cell without a red test on that backend. + +#include "real_dd_path_catalog.h" + +#include + +#include +#include + +namespace { + +using al_contract::real_dd::catalog; +using al_contract::real_dd::CatalogEntry; +using al_contract::real_dd::DType; +using al_contract::real_dd::kMaxRank; +using al_contract::real_dd::kMinRank; +using al_contract::real_dd::kNumCells; +using al_contract::real_dd::kNumDTypes; + +using Key = std::pair; // (datatype ordinal, rank) + +Key key_of(const CatalogEntry& e) { + return {static_cast(e.dt), e.rank}; +} + +// The catalog defines exactly one verdict for every (datatype, rank) cell in +// the swept space, with no duplicate and no missing key. This is the shared +// half of issue #46's "exactly one verdict for every catalog key, no +// duplicates" acceptance criterion; the per-backend halves pin that each +// matrix maps this same key set 1:1. +TEST(RealDdPathCatalog, CoversEveryCellExactlyOnce) { + std::set seen; + for (const CatalogEntry& e : catalog()) { + EXPECT_TRUE(seen.insert(key_of(e)).second) + << "duplicate catalog key: dtype=" << static_cast(e.dt) + << " rank=" << e.rank; + } + EXPECT_EQ(catalog().size(), static_cast(kNumCells)); + + // Every cell in the swept space is present. + for (int d = 0; d < kNumDTypes; ++d) { + for (int r = kMinRank; r <= kMaxRank; ++r) { + EXPECT_TRUE(seen.count({d, r}) == 1) + << "missing catalog key: dtype=" << d << " rank=" << r; + } + } +} + +// A terminal-gap cell carries its DD-fact reason and no path; a real-path cell +// carries a path and no gap reason. The two are mutually exclusive, so neither +// backend suite can misread a half-populated entry. +TEST(RealDdPathCatalog, PathAndTerminalGapAreMutuallyExclusive) { + for (const CatalogEntry& e : catalog()) { + const bool is_gap = e.terminal_gap_reason != nullptr; + if (is_gap) { + EXPECT_EQ(e.ids, nullptr) + << "terminal-gap cell " << al_contract::real_dd::case_name(e) + << " must not name an IDS"; + EXPECT_EQ(e.leaf, nullptr) + << "terminal-gap cell " << al_contract::real_dd::case_name(e) + << " must not name a leaf"; + EXPECT_TRUE(e.aos_chain.empty()) + << "terminal-gap cell " << al_contract::real_dd::case_name(e) + << " must not carry an AOS chain"; + } else { + EXPECT_NE(e.ids, nullptr) + << "real-path cell " << al_contract::real_dd::case_name(e) + << " must name an IDS"; + EXPECT_NE(e.leaf, nullptr) + << "real-path cell " << al_contract::real_dd::case_name(e) + << " must name a leaf"; + } + } +} + +} // namespace diff --git a/tests/contract/test_roundtrip.cpp b/tests/contract/test_roundtrip.cpp new file mode 100644 index 00000000..57b0fb56 --- /dev/null +++ b/tests/contract/test_roundtrip.cpp @@ -0,0 +1,100 @@ +// Round-trip contract tests — the scaffold-slice behavior proof. +// +// TEST_STRATEGY.md §4 step 1: one write->read round trip on an on-disk backend +// (HDF5) *and* an in-RAM backend (Memory), proving the machine end-to-end +// before the matrix is broadened. Oracle = round-trip self-consistency +// (read-back equals write), which needs zero DD artifacts (decision D5). +// +// This is intended-contract territory (decision D2): the ABI genuinely stores +// and returns what it was given, so we assert it directly. The suffix on each +// ctest name is the backend label (HDF5 / Memory). + +#include "al_contract.h" + +#include + +#include + +using al_contract::BackendCase; +using al_contract::PulseId; + +namespace { + +// A magnetics IDS is used purely as an opaque container of two leaf paths; the +// core attaches no DD semantics to it (Key architecture fact: DD paths are +// opaque strings through the whole C ABI). +constexpr const char* kIds = "magnetics"; +constexpr const char* kIntField = "ids_properties/homogeneous_time"; +constexpr const char* kStrField = "ids_properties/comment"; + +class RoundTrip : public ::testing::TestWithParam { +protected: + al_contract::TempBase base_; + // A single fixed pulse address; every case gets its own base_ dir, so the + // on-disk trees can never alias even though the address is shared. + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, + /*run=*/0}; +}; + +// Write an INTEGER scalar and a CHAR array, then read both back through a fresh +// READ operation on the same data entry, and assert exact equality. +TEST_P(RoundTrip, IntScalarAndCharArraySurviveWriteThenRead) { + const BackendCase backend = GetParam(); + if (backend.on_disk) { + base_.make_legacy_tree(pulse_); + } + + const std::string uri = + al_contract::build_uri(backend.id, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + + const int kIntValue = 1; + const std::string kStrValue = "round-trip"; + + // --- write --- + { + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, + &op_ctx)); + AL_EXPECT_OK(al_contract::write_int_scalar(op_ctx, kIntField, + kIntValue)); + AL_EXPECT_OK(al_contract::write_char_array(op_ctx, kStrField, + kStrValue)); + AL_ASSERT_OK(al_end_action(op_ctx)); + } + + // --- read back --- + { + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", READ_OP, + &op_ctx)); + + int read_int = -999; + AL_EXPECT_OK(al_contract::read_int_scalar(op_ctx, kIntField, + &read_int)); + EXPECT_EQ(read_int, kIntValue); + + std::string read_str; + AL_EXPECT_OK(al_contract::read_char_array(op_ctx, kStrField, + &read_str)); + EXPECT_EQ(read_str, kStrValue); + + AL_ASSERT_OK(al_end_action(op_ctx)); + } + + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); +} + +INSTANTIATE_TEST_SUITE_P( + Backends, RoundTrip, + ::testing::Values(BackendCase{HDF5_BACKEND, "HDF5", /*on_disk=*/true}, + BackendCase{MEMORY_BACKEND, "Memory", /*on_disk=*/false}), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.name); + }); + +} // namespace diff --git a/tests/contract/test_roundtrip_matrix.cpp b/tests/contract/test_roundtrip_matrix.cpp new file mode 100644 index 00000000..07fa10ab --- /dev/null +++ b/tests/contract/test_roundtrip_matrix.cpp @@ -0,0 +1,481 @@ +// Synthetic write->read round-trip matrix (issue #3). +// +// TEST_STRATEGY.md §4 step 2: the bulk of the storage contract. One round-trip +// test is value-parametrized over the always-on backend tier (decision D4) +// {HDF5, Memory, ASCII, Flexbuffers} x {CHAR, INTEGER, DOUBLE, COMPLEX} +// x {scalar, 1-D, ... 7-D} +// (D4's columns x D5's synthetic data). Each cell is its own ctest entry, so +// one failing datatype/shape/backend is pinpointed by name. +// +// Oracle = round-trip self-consistency (decision D5): the ABI is handed a +// deterministic synthetic buffer and must return exactly those bytes with the +// shape it was given. No Data Dictionary artifacts are required — itself the +// standing proof of the version-free storage property the migration protects. +// +// Not every (backend, datatype, shape) cell is a plain round trip. Following +// decision D2 (assert the intended contract; never freeze a bug) and D4 +// (capability-gating with paired negatives), each cell is classified once: +// * RoundTrip - the backend stores and returns the data; assert equality. +// * Refused - the backend documents the operation as unsupported and must +// refuse it; assert the refusal (D4's paired-negative). Two +// cases: CHAR > 2D on the tensorizing/text backends (IMAS +// models CHAR as string / array-of-strings), and every cell +// of Flexbuffers, which is a serializer, not a pulse store — +// it refuses a read while writing (src/flexbuffers_backend.cpp +// :358) and never persists (:148). Its full serialize -> +// deserialize round trip needs the HLI "" protocol and +// belongs to the serialize seam; here it is the must-refuse +// column D4 prescribes. +// * KnownDefect - the backend crashes or corrupts on a shape it ought to +// handle; the cell is skipped here and the *correct* contract +// is asserted (expected-fail) in the RoundTripKnownDefects.* +// tests below, so a fix flips them to pass. +// +// Per D2, the FUNCTIONALITY_INVENTORY / issue citation for every DISABLED_ +// expected-fail below lives in TRACEABILITY.md (xfail bookkeeping table). + +#include "al_contract.h" + +#include + +#include +#include +#include +#include +#include + +using al_contract::BackendCase; +using al_contract::PulseId; + +namespace { + +// A magnetics IDS is used purely as an opaque container; the core attaches no +// DD semantics to it (DD paths are opaque strings through the whole C ABI). +constexpr const char* kIds = "magnetics"; + +// The always-on backend tier (decision D4). MDSplus/UDA are compile-guarded and +// covered elsewhere; they are deliberately absent here, not failing. +const BackendCase kBackends[] = { + {HDF5_BACKEND, "HDF5", /*on_disk=*/true}, + {MEMORY_BACKEND, "Memory", /*on_disk=*/false}, + {ASCII_BACKEND, "ASCII", /*on_disk=*/true}, + {FLEXBUFFERS_BACKEND, "Flexbuffers", /*on_disk=*/true}, +}; + +// Datatype axis. The enum indexes a switch that recovers the C++ element type, +// so the matrix stays a single flat Combine() rather than four near-copies. +enum class DType { Char, Int, Double, Complex }; +struct DataTypeCase { + DType dt; + const char* name; +}; +const DataTypeCase kDataTypes[] = { + {DType::Char, "CHAR"}, + {DType::Int, "INTEGER"}, + {DType::Double, "DOUBLE"}, + {DType::Complex, "COMPLEX"}, +}; + +// Per-cell classification (see file header). Everything not named here is a +// plain round trip; the exceptions are the backends' real capability edges, +// each observed empirically and cross-checked against the source. +enum class Expect { RoundTrip, Refused, KnownDefect }; + +Expect classify(int backend_id, DType dt, int rank) { + // Flexbuffers is a serializer, not a pulse store: it accepts the write but + // refuses the read-back within the session, for every datatype and shape. + if (backend_id == FLEXBUFFERS_BACKEND) { + return Expect::Refused; + } + + const bool is_char = (dt == DType::Char); + + // ASCII corrupts / crashes numeric data at the MAXDIM boundary (rank 7): + // INTEGER aborts, DOUBLE/COMPLEX read back wrong. HDF5/Memory are fine at + // rank 7, so this is an ASCII-specific defect, not a shape limit. + if (!is_char && rank == MAXDIM && backend_id == ASCII_BACKEND) { + return Expect::KnownDefect; + } + + if (is_char) { + // CHAR > 2D is documented-unsupported on the tensorizing/text backends + // ("CHAR data > 2D is not implemented yet" on ASCII; a dimension- + // mismatch error on HDF5). Memory keeps raw bytes and round-trips. + if (rank >= 3 && + (backend_id == HDF5_BACKEND || backend_id == ASCII_BACKEND)) { + return Expect::Refused; + } + // A CHAR scalar (dim 0) crashes the HDF5 backend; Memory/ASCII handle + // it. Tracked as a defect, not frozen as "char-0D unsupported". + if (rank == 0 && backend_id == HDF5_BACKEND) { + return Expect::KnownDefect; + } + } + return Expect::RoundTrip; +} + +// A distinct opaque leaf per (datatype, rank) so cells never collide on a path. +template +std::string field_for(int rank) { + return std::string("ids_properties/synthetic_") + al_contract::DType::name + + "_r" + std::to_string(rank); +} + +// The inputs one cell operates on: the opened-entry URI plus the synthetic +// buffer, its shape, and the leaf path to store it at. Bundled so the identical +// setup preamble isn't copy-pasted between the round-trip and refusal helpers. +template +struct Cell { + std::string uri; + std::vector shape; + std::vector written; + std::string field; +}; + +template +Cell make_cell(const BackendCase& backend, const al_contract::TempBase& base, + const PulseId& pulse, int rank) { + if (backend.on_disk) { + base.make_legacy_tree(pulse); + } + Cell c; + c.uri = al_contract::build_uri(backend.id, base.str(), pulse); + c.shape = al_contract::shape_for_rank(rank); + c.written = al_contract::synth_buffer(al_contract::element_count(c.shape)); + c.field = field_for(rank); + return c; +} + +// Write one field into an already-open pulse, in its own WRITE global action. +template +void write_field(int pulse_ctx, const std::string& field, + const std::vector& shape, const std::vector& data) { + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, &op_ctx)); + AL_EXPECT_OK(al_contract::write_data(op_ctx, field.c_str(), shape, data)); + AL_ASSERT_OK(al_end_action(op_ctx)); +} + +// Read one field back and assert it equals what was written, byte-for-byte +// (the round-trip oracle, decision D5): same shape, same count, same values. +template +void read_field_and_expect(int pulse_ctx, const std::string& field, int rank, + const std::vector& shape, + const std::vector& written) { + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", READ_OP, &op_ctx)); + + std::vector read_shape; + std::vector read_data; + AL_EXPECT_OK(al_contract::read_data(op_ctx, field.c_str(), rank, + &read_shape, &read_data)); + + EXPECT_EQ(read_shape, shape) << "shape changed across the round trip"; + ASSERT_EQ(read_data.size(), written.size()) + << "element count changed across the round trip"; + EXPECT_EQ(read_data, written) << "data changed across the round trip"; + + AL_ASSERT_OK(al_end_action(op_ctx)); +} + +// One typed round trip: generate -> write -> read -> assert byte-exact equal. +// Write and read share one open data entry (mandatory for the transient Memory +// backend; fine for the persistent ones). Fatal ASSERTs return from this +// helper, which is the whole cell body, so nothing runs on a broken setup. +template +void run_round_trip(const BackendCase& backend, const al_contract::TempBase& base, + const PulseId& pulse, int rank) { + const Cell c = make_cell(backend, base, pulse, rank); + ASSERT_FALSE(c.uri.empty()); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(c.uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + write_field(pulse_ctx, c.field, c.shape, c.written); + read_field_and_expect(pulse_ctx, c.field, rank, c.shape, c.written); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); +} + +// Paired-negative (decision D4): the operation is documented-unsupported, so +// the round-trip attempt must be refused. The refusal may surface on write +// (ASCII CHAR>2D) or on read (HDF5 CHAR>2D dimension mismatch; Flexbuffers read- +// while-serializing), so we accept either. +template +void expect_refused(const BackendCase& backend, const al_contract::TempBase& base, + const PulseId& pulse, int rank) { + const Cell c = make_cell(backend, base, pulse, rank); + ASSERT_FALSE(c.uri.empty()); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(c.uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + + al_status_t ws, rs; + ws.code = 0; + rs.code = 0; + { + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, + &op_ctx)); + ws = al_contract::write_data(op_ctx, c.field.c_str(), c.shape, + c.written); + AL_EXPECT_OK(al_end_action(op_ctx)); + } + if (ws.code == 0) { // write was accepted; the refusal must come on read + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", READ_OP, + &op_ctx)); + std::vector sh; + std::vector rd; + rs = al_contract::read_data(op_ctx, c.field.c_str(), rank, &sh, &rd); + AL_EXPECT_OK(al_end_action(op_ctx)); + } + EXPECT_TRUE(ws.code != 0 || rs.code != 0) + << "expected the backend to refuse this datatype/shape (a documented " + "limitation — CHAR data > 2D, or a read while the serializer is " + "writing), but the round trip was accepted"; + + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); +} + +template +void dispatch(const BackendCase& backend, const al_contract::TempBase& base, + const PulseId& pulse, int rank, Expect expect) { + if (expect == Expect::Refused) { + expect_refused(backend, base, pulse, rank); + } else { + run_round_trip(backend, base, pulse, rank); + } +} + +using MatrixParam = std::tuple; + +class RoundTripMatrix : public ::testing::TestWithParam { +protected: + al_contract::TempBase base_; + // One fixed pulse address; each parametrized instance has its own base_ + // dir, so on-disk trees can never alias despite the shared address. + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, + /*run=*/0}; +}; + +TEST_P(RoundTripMatrix, ReadEqualsWrite) { + const BackendCase backend = std::get<0>(GetParam()); + const DataTypeCase dcase = std::get<1>(GetParam()); + const int rank = std::get<2>(GetParam()); + + const Expect expect = classify(backend.id, dcase.dt, rank); + if (expect == Expect::KnownDefect) { + GTEST_SKIP() << "known defect for " << backend.name << "/" << dcase.name + << "/rank " << rank + << " — correct contract asserted (expected-fail) in " + "RoundTripKnownDefects.*"; + } + + switch (dcase.dt) { + case DType::Char: + dispatch(backend, base_, pulse_, rank, expect); + break; + case DType::Int: + dispatch(backend, base_, pulse_, rank, expect); + break; + case DType::Double: + dispatch(backend, base_, pulse_, rank, expect); + break; + case DType::Complex: + dispatch>(backend, base_, pulse_, rank, expect); + break; + } +} + +INSTANTIATE_TEST_SUITE_P( + Backends, RoundTripMatrix, + ::testing::Combine(::testing::ValuesIn(kBackends), + ::testing::ValuesIn(kDataTypes), + ::testing::Range(0, MAXDIM + 1)), + [](const ::testing::TestParamInfo& info) { + // ctest-name suffix: __r, e.g. HDF5_DOUBLE_r3. + return std::string(std::get<0>(info.param).name) + "_" + + std::get<1>(info.param).name + "_r" + + std::to_string(std::get<2>(info.param)); + }); + +// =========================================================================== +// Known defects surfaced by the matrix above (decision D2). +// =========================================================================== +// The correct contract is asserted and marked expected-fail (DISABLED_ + a +// paired death test for the crash, mirroring test_known_defects.cpp). The day a +// backend is fixed, the DISABLED_ test flips to pass — a signal, not a +// regression — and its death-test twin starts failing, forcing whoever fixed it +// to enable the correct-contract test. + +const BackendCase kHdf5{HDF5_BACKEND, "HDF5", /*on_disk=*/true}; +const BackendCase kAscii{ASCII_BACKEND, "ASCII", /*on_disk=*/true}; + +// Run a round trip against a freshly-created, self-cleaning data entry — the +// standalone form used by the (non-parametrized) known-defect tests. +template +void run_round_trip_here(const BackendCase& backend, int rank) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + run_round_trip(backend, base, pulse, rank); +} + +// Observe a write->read round trip WITHOUT asserting equality: returns true iff +// the data survived intact (shape + values), after asserting each op itself +// succeeded (so a "did not survive" result means genuine corruption, not a +// refusal or a broken setup). The paired DISABLED_ tests above assert survival +// as the correct contract; the current-behavior tripwires below use this to pin +// today's *silent corruption* (the op returns success but the bytes come back +// wrong), so a fix that restores the data flips the tripwire red. +template +bool maxdim_round_trip_survives(const BackendCase& backend, int rank) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + const Cell c = make_cell(backend, base, pulse, rank); + EXPECT_FALSE(c.uri.empty()); + + int pulse_ctx = -1; + EXPECT_EQ( + al_begin_dataentry_action(c.uri.c_str(), FORCE_CREATE_PULSE, &pulse_ctx) + .code, + 0); + { + int op_ctx = -1; + EXPECT_EQ( + al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, &op_ctx).code, + 0); + EXPECT_EQ(al_contract::write_data(op_ctx, c.field.c_str(), c.shape, + c.written) + .code, + 0); + EXPECT_EQ(al_end_action(op_ctx).code, 0); + } + std::vector read_shape; + std::vector read_data; + { + int op_ctx = -1; + EXPECT_EQ( + al_begin_global_action(pulse_ctx, kIds, "", READ_OP, &op_ctx).code, + 0); + EXPECT_EQ(al_contract::read_data(op_ctx, c.field.c_str(), rank, + &read_shape, &read_data) + .code, + 0); + EXPECT_EQ(al_end_action(op_ctx).code, 0); + } + al_close_pulse(pulse_ctx, CLOSE_PULSE); + return read_shape == c.shape && read_data == c.written; +} + +// --- HDF5 crashes writing/reading a CHAR scalar (dim 0) -------------------- +TEST(RoundTripKnownDefects, DISABLED_Hdf5CharScalarRoundTrips) { + run_round_trip_here(kHdf5, /*rank=*/0); +} + +TEST(RoundTripKnownDefectsDeath, Hdf5CharScalarCurrentlyCrashes) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + // ASSERT_DEATH's argument must be a single call: a brace block would expose + // the commas in the initializers as extra macro arguments. + ASSERT_DEATH(run_round_trip_here(kHdf5, /*rank=*/0), ".*") + << "expected the HDF5 CHAR-scalar defect to crash. If it no longer " + "does, enable RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips."; +} + +// --- ASCII corrupts/crashes numeric data at the MAXDIM boundary (rank 7) ---- +TEST(RoundTripKnownDefects, DISABLED_AsciiIntegerMaxdimRoundTrips) { + run_round_trip_here(kAscii, /*rank=*/MAXDIM); +} +TEST(RoundTripKnownDefects, DISABLED_AsciiDoubleMaxdimRoundTrips) { + run_round_trip_here(kAscii, /*rank=*/MAXDIM); +} +TEST(RoundTripKnownDefects, DISABLED_AsciiComplexMaxdimRoundTrips) { + run_round_trip_here>(kAscii, /*rank=*/MAXDIM); +} + +// Current-behavior tripwires for the ASCII rank-7 defects, so the DISABLED_ +// correct-contract tests above can't rot. INTEGER reliably aborts the process +// (a plain death test). DOUBLE and COMPLEX overrun the heap (issue #9): most +// runs complete the op and return corrupted bytes, but the overrun is UB and +// intermittently faults instead (observed: occasional SIGBUS on macOS). Each +// of those tripwires therefore runs the round trip in a death-test child that +// exits kMaxdimSurvivedExit only on an intact round trip — corruption and +// crash both keep the tripwire green; only a clean round trip turns it red, +// forcing whoever fixed ASCII to enable the paired DISABLED_ test. +// +// NB: under AddressSanitizer the corruption is masked (the data round-trips +// intact there), so the two `…CurrentlyCorrupts` tripwires are excluded from +// the sanitizer CI leg (.github/workflows/sanitizers.yml, +// `-E CurrentlyCorrupts`). Their rot-guard job runs in the blocking functional +// leg (.github/workflows/contract-tests.yml) and in local `ctest` runs. +constexpr int kMaxdimSurvivedExit = 77; + +template +void exit_with_maxdim_survival(const BackendCase& backend) { + const bool survives = + maxdim_round_trip_survives(backend, /*rank=*/MAXDIM); + std::_Exit(survives ? kMaxdimSurvivedExit : 0); +} + +TEST(RoundTripKnownDefectsDeath, AsciiIntegerMaxdimCurrentlyAborts) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + ASSERT_DEATH(run_round_trip_here(kAscii, /*rank=*/MAXDIM), ".*") + << "expected ASCII rank-7 INTEGER to abort. If it no longer does, the " + "defect was likely fixed — enable " + "RoundTripKnownDefects.DISABLED_AsciiIntegerMaxdimRoundTrips."; +} + +TEST(RoundTripKnownDefectsDeath, AsciiDoubleMaxdimCurrentlyCorrupts) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + const ::testing::ExitedWithCode survived(kMaxdimSurvivedExit); + EXPECT_EXIT( + exit_with_maxdim_survival(kAscii), + [&](int status) { return !survived(status); }, "") + << "ASCII rank-7 DOUBLE now round-trips intact — the defect is fixed; " + "enable RoundTripKnownDefects.DISABLED_AsciiDoubleMaxdimRoundTrips."; +} + +TEST(RoundTripKnownDefectsDeath, AsciiComplexMaxdimCurrentlyCorrupts) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + const ::testing::ExitedWithCode survived(kMaxdimSurvivedExit); + EXPECT_EXIT( + exit_with_maxdim_survival>(kAscii), + [&](int status) { return !survived(status); }, "") + << "ASCII rank-7 COMPLEX now round-trips intact — the defect is fixed; " + "enable RoundTripKnownDefects.DISABLED_AsciiComplexMaxdimRoundTrips."; +} + +// --- al_build_uri_from_legacy_parameters can't address FLEXBUFFERS ---------- +// getURIBackend (src/al_context.cpp:280) has no FLEXBUFFERS_BACKEND case and +// throws, even though the parse side accepts the "flexbuffers" scheme — so the +// always-on serializer backend has no legacy-URI builder (the matrix works +// around it by hand-building the URI, see al_contract.h::build_uri). Correct +// contract: the builder should support it, like the other always-on backends. +TEST(RoundTripKnownDefects, DISABLED_BuildUriSupportsFlexbuffers) { + char* uri = nullptr; + al_status_t s = al_build_uri_from_legacy_parameters( + FLEXBUFFERS_BACKEND, 12, 0, "/tmp/al_contract_flexbuffers", "test", "3", + "", &uri); + EXPECT_EQ(s.code, 0) + << "al_build_uri_from_legacy_parameters should support the always-on " + "FLEXBUFFERS backend (getURIBackend, src/al_context.cpp:280)"; + free(uri); +} + +// Current-behavior tripwire: today the builder errors for FLEXBUFFERS (code -4), +// so al_contract.h::build_uri hand-builds the URI to keep the matrix running. +// When the builder is fixed this goes red, forcing whoever fixed it to enable +// DISABLED_BuildUriSupportsFlexbuffers and drop the hand-built workaround. +TEST(RoundTripKnownDefects, BuildUriFlexbuffersCurrentlyFails) { + char* uri = nullptr; + al_status_t s = al_build_uri_from_legacy_parameters( + FLEXBUFFERS_BACKEND, 12, 0, "/tmp/al_contract_flexbuffers", "test", "3", + "", &uri); + EXPECT_NE(s.code, 0) + << "al_build_uri_from_legacy_parameters now supports FLEXBUFFERS — " + "enable RoundTripKnownDefects.DISABLED_BuildUriSupportsFlexbuffers " + "and drop the hand-built URI workaround in al_contract.h::build_uri."; + free(uri); +} + +} // namespace diff --git a/tests/contract/test_structured_data.cpp b/tests/contract/test_structured_data.cpp new file mode 100644 index 00000000..f98501b1 --- /dev/null +++ b/tests/contract/test_structured_data.cpp @@ -0,0 +1,1171 @@ +// Structured-data contract tests (issue #4 / TEST_STRATEGY.md §4 step 2). +// +// Covers what the round-trip matrix (issue #3) and capability-gated suite +// (issue #6) deliberately left as gaps: arrays-of-structures (top-level + +// nested), al_delete_data at its three documented granularities, +// al_get_occurrences, and the realistic equilibrium-seed round trip. +// +// Per D2, the FUNCTIONALITY_INVENTORY / issue citation for every DISABLED_ +// expected-fail below lives in TRACEABILITY.md (xfail bookkeeping table). +// +// Same idioms as the existing suite: al_contract.h for the fixture/generator +// layer, a per-file backend-capability table + TEST_P (test_capability_gated.cpp), +// and local DISABLED_/tripwire pairs for genuine defects, never frozen as spec +// (decision D2). +// +// This is characterization work, not new product code: every "positive" test +// below asserts real, already-shipped C-ABI behavior; every DISABLED_/tripwire +// pair pins a real defect found by reading the backend sources directly +// (src/hdf5/, src/memory_backend.*, src/ascii_backend.*, src/flexbuffers_backend.*) +// and confirmed by running the test against the built library. + +#include "al_contract.h" +#include "equilibrium_seed.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include + +using al_contract::BackendCase; +using al_contract::PulseId; + +namespace { + +constexpr const char* kIds = "magnetics"; + +// =========================================================================== +// Arrays of structures: top-level + nested, write -> iterate -> read. +// =========================================================================== +// Flexbuffers is excluded from the round-trip expectation, consistent with +// RoundTripMatrix's classification (test_roundtrip_matrix.cpp): it is a +// serializer, not a pulse store, and refuses reads within the same session +// for ordinary fields; AOS content is read via the same field read/write path +// so the same refusal applies. +// ASCII is a KnownDefect, not a plain refusal: al_begin_arraystruct_action's +// READ side always reports size 0, for any AOS, regardless of what was +// written — confirmed empirically and traced to AsciiBackend::beginAction +// (READ_OP) consuming the *entire* file into a random-access lookup map +// (`curcontent_map`, ascii_backend.cpp:213-235) before returning, which +// leaves the sequential stream position at EOF. beginReadArraystructAction's +// AOS-size lookup (ascii_backend.cpp:656-682) depends on that same sequential +// position via `getline`, so it always finds nothing. See AosKnownDefects +// below for the paired DISABLED_/tripwire. +// MDSplus is a Divergence, not a defect (issue #14): unlike every other +// backend here, it resolves paths against a real DD-baked model tree, so +// this fixture's synthetic field names ("elements"/"val", "outer"/"inner") +// have no corresponding node. Confirmed empirically: al_begin_arraystruct_action +// and the per-element al_write_data calls all report success (buffered in an +// in-memory MDSplus::Apd), but the AOS flush at al_end_action throws +// "%TREE-W-NNF, Node Not Found". Real DD-conformant AOS paths (e.g. +// equilibrium/time_slice/global_quantities/ip) round-trip through this exact +// same begin/write/iterate/end sequence without error — this is a structural +// requirement of MDSplus's storage model (issue #12 Q2), not a bug, so it is +// not paired with a DISABLED_/tripwire like KnownDefect. See TRACEABILITY.md +// Part 4. +enum class AosExpect { RoundTrip, Refused, KnownDefect, Divergence }; + +struct AosBackendCase { + int id; + const char* name; + bool on_disk; + AosExpect expect; +}; + +inline void PrintTo(const AosBackendCase& b, std::ostream* os) { *os << b.name; } + +const AosBackendCase kAosBackends[] = { + {HDF5_BACKEND, "HDF5", /*on_disk=*/true, AosExpect::RoundTrip}, + {MEMORY_BACKEND, "Memory", /*on_disk=*/false, AosExpect::RoundTrip}, + {ASCII_BACKEND, "ASCII", /*on_disk=*/true, AosExpect::KnownDefect}, + {FLEXBUFFERS_BACKEND, "Flexbuffers", /*on_disk=*/true, AosExpect::Refused}, +#ifdef AL_CONTRACT_HAVE_MDSPLUS + {MDSPLUS_BACKEND, "Mdsplus", /*on_disk=*/true, AosExpect::Divergence}, +#endif +}; + +// Shared by AosMatrix and DeleteMatrix below (their backend-descriptor +// structs differ, but each carries the same id/on_disk pair). +int open_fresh_pulse(int backend_id, bool on_disk, const al_contract::TempBase& base, + const PulseId& pulse) { + if (on_disk) base.make_legacy_tree(pulse); + const std::string uri = al_contract::build_uri(backend_id, base.str(), pulse); + EXPECT_FALSE(uri.empty()); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + return pctx; +} + +class AosMatrix : public ::testing::TestWithParam { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; +}; + +TEST_P(AosMatrix, TopLevelWriteIterateRead) { + const AosBackendCase b = GetParam(); + if (b.expect == AosExpect::KnownDefect) { + GTEST_SKIP() << "known defect for " << b.name + << " AOS read — see AosKnownDefects.*"; + } + if (b.expect == AosExpect::Divergence) { + GTEST_SKIP() << b.name << " requires real DD-conformant AOS paths " + "(issue #12 Q2) -- this fixture's synthetic field " + "names are refused, not a defect. See " + "TRACEABILITY.md Part 4."; + } + const int pctx = open_fresh_pulse(b.id, b.on_disk, base_, pulse_); + constexpr int kN = 3; + + // --- write: 3-element top-level AOS, one INTEGER field per element ----- + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + int size = kN; + int aos = -1; + al_status_t begin_status = + al_begin_arraystruct_action(op, "elements", "", &size, &aos); + for (int i = 0; i < kN && begin_status.code == 0; ++i) { + AL_EXPECT_OK(al_contract::write_data(aos, "val", {}, {100 + i})); + if (i + 1 < kN) AL_EXPECT_OK(al_iterate_over_arraystruct(aos, 1)); + } + if (aos != 0) AL_EXPECT_OK(al_end_action(aos)); + AL_ASSERT_OK(al_end_action(op)); + + // --- read: same shape back ---------------------------------------------- + int op2 = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &op2)); + int rsize = 0; + int raos = -1; + al_status_t read_status = + al_begin_arraystruct_action(op2, "elements", "", &rsize, &raos); + + if (b.expect == AosExpect::RoundTrip) { + AL_EXPECT_OK(begin_status); + AL_EXPECT_OK(read_status); + ASSERT_EQ(rsize, kN) << "AOS size must round-trip"; + for (int i = 0; i < rsize; ++i) { + std::vector shape, data; + AL_EXPECT_OK(al_contract::read_data(raos, "val", 0, &shape, &data)); + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], 100 + i) << "element " << i << " mismatch"; + if (i + 1 < rsize) AL_EXPECT_OK(al_iterate_over_arraystruct(raos, 1)); + } + } else { + EXPECT_TRUE(begin_status.code != 0 || read_status.code != 0 || rsize == 0) + << "expected the serializer backend to refuse the AOS read-back " + "within the same session"; + } + if (raos != 0) al_end_action(raos); + al_end_action(op2); + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_P(AosMatrix, NestedWriteIterateRead) { + const AosBackendCase b = GetParam(); + if (b.expect == AosExpect::KnownDefect) { + GTEST_SKIP() << "known defect for " << b.name + << " AOS read — see AosKnownDefects.*"; + } + if (b.expect == AosExpect::Divergence) { + GTEST_SKIP() << b.name << " requires real DD-conformant AOS paths " + "(issue #12 Q2) -- this fixture's synthetic field " + "names are refused, not a defect. See " + "TRACEABILITY.md Part 4."; + } + const int pctx = open_fresh_pulse(b.id, b.on_disk, base_, pulse_); + constexpr int kOuter = 2, kInner = 2; + + // --- write: outer AOS of size 2, each element holds an inner AOS of size 2 + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + int outer_size = kOuter; + int outer = -1; + al_status_t outer_status = + al_begin_arraystruct_action(op, "outer", "", &outer_size, &outer); + for (int oi = 0; oi < kOuter && outer_status.code == 0; ++oi) { + int inner_size = kInner; + int inner = -1; + AL_EXPECT_OK(al_begin_arraystruct_action(outer, "inner", "", &inner_size, &inner)); + for (int ii = 0; ii < kInner; ++ii) { + AL_EXPECT_OK(al_contract::write_data(inner, "val", {}, {oi * 10 + ii})); + if (ii + 1 < kInner) AL_EXPECT_OK(al_iterate_over_arraystruct(inner, 1)); + } + if (inner != 0) AL_EXPECT_OK(al_end_action(inner)); + if (oi + 1 < kOuter) AL_EXPECT_OK(al_iterate_over_arraystruct(outer, 1)); + } + if (outer != 0) AL_EXPECT_OK(al_end_action(outer)); + AL_ASSERT_OK(al_end_action(op)); + + // --- read: same nested shape back -------------------------------------- + int op2 = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &op2)); + int router_size = 0; + int router = -1; + al_status_t router_status = + al_begin_arraystruct_action(op2, "outer", "", &router_size, &router); + + if (b.expect == AosExpect::RoundTrip) { + AL_EXPECT_OK(outer_status); + AL_EXPECT_OK(router_status); + ASSERT_EQ(router_size, kOuter); + for (int oi = 0; oi < router_size; ++oi) { + int rinner_size = 0; + int rinner = -1; + AL_EXPECT_OK( + al_begin_arraystruct_action(router, "inner", "", &rinner_size, &rinner)); + ASSERT_EQ(rinner_size, kInner) << "nested AOS size must round-trip"; + for (int ii = 0; ii < rinner_size; ++ii) { + std::vector shape, data; + AL_EXPECT_OK(al_contract::read_data(rinner, "val", 0, &shape, &data)); + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], oi * 10 + ii) + << "outer=" << oi << " inner=" << ii << " mismatch"; + if (ii + 1 < rinner_size) AL_EXPECT_OK(al_iterate_over_arraystruct(rinner, 1)); + } + if (rinner != 0) al_end_action(rinner); + if (oi + 1 < router_size) AL_EXPECT_OK(al_iterate_over_arraystruct(router, 1)); + } + } else { + EXPECT_TRUE(outer_status.code != 0 || router_status.code != 0 || router_size == 0) + << "expected the serializer backend to refuse the nested AOS " + "read-back within the same session"; + } + if (router != 0) al_end_action(router); + al_end_action(op2); + al_close_pulse(pctx, CLOSE_PULSE); +} + +INSTANTIATE_TEST_SUITE_P( + Backends, AosMatrix, ::testing::ValuesIn(kAosBackends), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.name); + }); + +// --------------------------------------------------------------------------- +// ASCII AOS-read known defect (decision D2): correct-contract DISABLED_ test +// paired with a current-behavior tripwire. +// --------------------------------------------------------------------------- +int ascii_aos_read_reported_size() { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string uri = al_contract::build_uri(ASCII_BACKEND, base.str(), pulse); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + + int op = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); + int size = 3; + int aos = -1; + EXPECT_EQ(al_begin_arraystruct_action(op, "elements", "", &size, &aos).code, 0); + for (int i = 0; i < 3; ++i) { + EXPECT_EQ(al_contract::write_data(aos, "val", {}, {100 + i}).code, 0); + if (i + 1 < 3) EXPECT_EQ(al_iterate_over_arraystruct(aos, 1).code, 0); + } + EXPECT_EQ(al_end_action(aos).code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + + int rop = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", READ_OP, &rop).code, 0); + int rsize = -1; + int raos = -1; + al_begin_arraystruct_action(rop, "elements", "", &rsize, &raos); + if (raos != 0) al_end_action(raos); + al_end_action(rop); + al_close_pulse(pctx, CLOSE_PULSE); + return rsize; +} + +TEST(AosKnownDefects, DISABLED_AsciiAosReadReportsWrittenSize) { + EXPECT_EQ(ascii_aos_read_reported_size(), 3) + << "reading an AOS back must report the size that was written"; +} + +TEST(AosKnownDefects, AsciiAosReadCurrentlyReportsZero) { + EXPECT_EQ(ascii_aos_read_reported_size(), 0) + << "AsciiBackend now reports the correct AOS size on read — enable " + "AosKnownDefects.DISABLED_AsciiAosReadReportsWrittenSize " + "(ascii_backend.cpp's READ_OP setup consumes the whole stream " + "into curcontent_map before beginReadArraystructAction's " + "sequential lookup ever runs)"; +} + +// =========================================================================== +// Equilibrium seed: realistic scalar + top-level timebase array + a real +// time_slice/profiles_1d/constraints AOS, oracle = a *structural* content hash +// (decision D5; issue #33). Flexbuffers is excluded for the same reason as the +// AOS matrix above (read refused within the session). +// +// MDSplus (issue #14) now joins this fixture as a full participant, not a +// skipped Divergence: issue #33 made every seed path DD-4.1.1-conformant +// (validated against the baked-from IDSDef.xml), so MDSplus resolves them +// against its DD-baked model tree and round-trips the whole composite -- the +// same real time_slice AOS write/read idiom test_mdsplus_unique_surface.cpp +// already proves for time_slice/{time,global_quantities/ip}. The earlier seed +// modelled profiles_1d/psi as a flat top-level leaf and constraints as a +// generic {measured,weight} AOS; those shapes have no node in the real DD, so +// MDSplus and UDA could only skip. That fixture invalidity is gone. +// +// UDA is the one backend that still cannot round-trip the whole seed: its +// remote-mode + backend=hdf5 path returns dynamic leaves nested inside a +// struct_array as absent, so the time_slice AOS leaves diverge (the +// independently-pinned UdaAosKnownDefects defect). That is triaged as a UDA +// xfail in test_uda.cpp, not fixture invalidity. See TRACEABILITY.md Parts 4/5. +// =========================================================================== +class EquilibriumSeedMatrix : public ::testing::TestWithParam { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; +}; + +const BackendCase kSeedBackends[] = { + {HDF5_BACKEND, "HDF5", /*on_disk=*/true}, + {MEMORY_BACKEND, "Memory", /*on_disk=*/false}, + {ASCII_BACKEND, "ASCII", /*on_disk=*/true}, +#ifdef AL_CONTRACT_HAVE_MDSPLUS + {MDSPLUS_BACKEND, "Mdsplus", /*on_disk=*/true}, +#endif +}; + +TEST_P(EquilibriumSeedMatrix, RoundTripHashMatches) { + const BackendCase b = GetParam(); + if (b.on_disk) base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(b.id, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + AL_ASSERT_OK(equilibrium_seed::write(pctx)); + + std::vector obs; + AL_ASSERT_OK(equilibrium_seed::read_back(pctx, &obs)); + + // Assert exact rank/extents field-by-field before hashing (issue #33), so a + // structural drift is a precise message, not just a hash number mismatch. + const std::vector expected = + equilibrium_seed::expected_records(); + ASSERT_EQ(obs.size(), expected.size()) + << "read observed a different field/AOS-element count than the seed"; + for (std::size_t i = 0; i < expected.size(); ++i) { + EXPECT_EQ(obs[i].field, expected[i].field) << "field #" << i; + EXPECT_EQ(obs[i].rank, expected[i].rank) << expected[i].field << " rank"; + EXPECT_EQ(obs[i].extents, expected[i].extents) + << expected[i].field << " extents"; + } + + EXPECT_EQ(equilibrium_seed::canonical_hash(obs), + equilibrium_seed::expected_hash()) + << "equilibrium seed (scalar + top-level timebase array + real " + "time_slice/profiles_1d/constraints AOS) must round-trip exactly, " + "structure and values"; + + EXPECT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); +} + +// A second whole-IDS write on the *same still-open* pulse must succeed and +// overwrite in place, not fail as if the underlying storage were being +// created for the first time (issue #60 follow-up: HDF5Writer::write_ND_Data +// and ::createOrUpdateShapesDataSet decided create-vs-reuse purely from their +// own per-action opened_data_sets cache -- which is cleared at the end of +// every al_begin_global_action/al_end_action pair -- instead of asking the +// file itself via H5Lexists, so any second global WRITE_OP on an already- +// populated open pulse re-attempted H5Dcreate2 on a dataset that already +// existed and threw "Unable to create HDF5 dataset"). Two independent +// al_begin_global_action(WRITE_OP) calls on one pctx reproduce the exact +// shape of the failure regardless of what benchmark or caller triggers it. +TEST_P(EquilibriumSeedMatrix, RepeatedWholeIdsWriteOnSameOpenPulseSucceeds) { + const BackendCase b = GetParam(); + if (b.on_disk) base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(b.id, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + AL_ASSERT_OK(equilibrium_seed::write(pctx)); + AL_ASSERT_OK(equilibrium_seed::write(pctx)) + << "a second whole-IDS write on the same still-open pulse must " + "overwrite in place, not fail as if the dataset didn't exist yet"; + + std::vector obs; + AL_ASSERT_OK(equilibrium_seed::read_back(pctx, &obs)); + EXPECT_EQ(equilibrium_seed::canonical_hash(obs), + equilibrium_seed::expected_hash()) + << "repeated writes must never accumulate stale data"; + + EXPECT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); +} + +// Reproduce the shared benchmark process ordering from issue #60: a complete +// whole-IDS read, followed by the node-by-node read of one time_slice element. +// This keeps the smoke-only HDF5 failure covered without relying on benchmark +// registration order or a standalone filtered process. +TEST_P(EquilibriumSeedMatrix, WholeIdsReadThenTimeSliceElementReadSucceeds) { + const BackendCase b = GetParam(); + // ASCII's READ_OP setup exhausts its sequential stream before + // beginReadArraystructAction can recover the AOS size. This is the + // documented AOS read known defect pinned by AosKnownDefects.* above; + // this regression specifically covers the HDF5 benchmark ordering. + if (b.id == ASCII_BACKEND) { + GTEST_SKIP() << "known defect for " << b.name + << " AOS read — see AosKnownDefects.*"; + } + if (b.on_disk) base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(b.id, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + AL_ASSERT_OK(equilibrium_seed::write(pctx)); + + std::vector whole_ids; + AL_ASSERT_OK(equilibrium_seed::read_back(pctx, &whole_ids)); + + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, equilibrium_seed::kIds, "", + READ_OP, &op)); + int size = 0; + int aos_ctx = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, equilibrium_seed::kAos, + equilibrium_seed::kAosTime, + &size, &aos_ctx)); + ASSERT_EQ(size, equilibrium_seed::kNSlices); + AL_ASSERT_OK(al_iterate_over_arraystruct(aos_ctx, size - 1)); + + std::vector shape; + std::vector data; + auto read_leaf = [&](const char* path, int rank) { + AL_ASSERT_OK(al_contract::read_data(aos_ctx, path, rank, + &shape, &data)); + }; + read_leaf(equilibrium_seed::kSliceTime, 0); + read_leaf(equilibrium_seed::kPsi, 1); + read_leaf(equilibrium_seed::kIp, 0); + read_leaf(equilibrium_seed::kMeasured, 0); + read_leaf(equilibrium_seed::kWeight, 0); + + AL_ASSERT_OK(al_end_action(aos_ctx)); + AL_ASSERT_OK(al_end_action(op)); + EXPECT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); +} + +// --- structural-hash sensitivity (issue #33 acceptance criteria 2 & 3) ------ +// Hermetic proofs that the canonical hash catches the two structural +// corruptions a values-only hash would miss. They operate on the production +// expected_records()/canonical_hash() -- no backend substrate -- so they are +// classified into the `unit` tier. + +// Swapping two extents without changing a single value byte must change the +// hash. Uses a 3x4 -> 4x3 record because the seed's own fields are 0-/1-D; +// this pins the extent-sensitivity of the oracle directly. +TEST(EquilibriumSeedStructuralHash, SwappingExtentsWithSameBytesChangesHash) { + using equilibrium_seed::Obs; + const std::vector bytes(12, 1.5); // 3*4 == 4*3, identical values + std::vector a = {{"profiles_2d/psi", DOUBLE_DATA, 2, {3, 4}, bytes}}; + std::vector b = {{"profiles_2d/psi", DOUBLE_DATA, 2, {4, 3}, bytes}}; + EXPECT_NE(equilibrium_seed::canonical_hash(a), + equilibrium_seed::canonical_hash(b)) + << "a 3x4 -> 4x3 extent transpose with unchanged bytes must not hash " + "the same"; +} + +// Dropping an AOS element must change the hash. +TEST(EquilibriumSeedStructuralHash, DroppingAosElementChangesHash) { + std::vector full = equilibrium_seed::expected_records(); + ASSERT_GT(full.size(), 5u); + std::vector dropped = full; + dropped.pop_back(); // drop the last time_slice element's last leaf + EXPECT_NE(equilibrium_seed::canonical_hash(full), + equilibrium_seed::canonical_hash(dropped)); +} + +// Reordering two AOS elements (swapping their values, structure unchanged) must +// change the hash -- the element index is part of each field's identity. +TEST(EquilibriumSeedStructuralHash, ReorderingAosElementsChangesHash) { + using equilibrium_seed::Obs; + std::vector a = equilibrium_seed::expected_records(); + std::vector b = a; + // Find the two time_slice[i]/global_quantities/ip records and swap their + // values, leaving every field/rank/extent identical. + std::vector ip_idx; + for (std::size_t i = 0; i < b.size(); ++i) { + if (b[i].field.find("global_quantities/ip") != std::string::npos) + ip_idx.push_back(i); + } + ASSERT_GE(ip_idx.size(), 2u); + std::swap(b[ip_idx[0]].values, b[ip_idx[1]].values); + EXPECT_NE(equilibrium_seed::canonical_hash(a), + equilibrium_seed::canonical_hash(b)) + << "swapping two AOS elements' values must change the hash even though " + "the multiset of bytes is unchanged"; +} + +INSTANTIATE_TEST_SUITE_P( + Backends, EquilibriumSeedMatrix, ::testing::ValuesIn(kSeedBackends), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.name); + }); + +// =========================================================================== +// al_delete_data at signal / structure / DATAOBJECT-root granularity. +// =========================================================================== +// The three granularities land on wildly different, non-overlapping support +// across the always-on tier (verified by reading each backend's deleteData): +// - HDF5: all three work. `path` used to be dropped on the floor, so every +// call widened to the whole occurrence (issue #63); it now selects the +// matching datasets out of the IDS group's flat namespace, and only an +// empty path removes the occurrence. The HDF5-specific consequences — +// which files and master-file links survive which granularity, and that a +// same-prefix sibling is not collateral — are pinned in Hdf5Delete.* below, +// since they are not observable through this backend-agnostic matrix. +// - Memory: the only backend that honors `path` — leaf and structure (an +// AOS field) both work for real; there is no code path that special-cases +// "delete the whole IDS", so root is the defect here. +// - ASCII, Flexbuffers: deleteData is a literal no-op (empty body, doc +// comment says so) — none of the three granularities do anything. One +// tripwire (leaf) suffices: an empty function body has no path-dependent +// branch to differ between granularities either. +// +// A "deleted" read is not a documented refusal: al_plugin_read_data returns +// status.code==0 with the datatype's sentinel value when the backend reports +// "no data" (src/al_lowlevel.cpp:1195-1199, Lowlevel::setDefaultValue) — so +// the oracle for "is it gone" is the sentinel, not an error code. +struct DeleteBackendCase { + int id; + const char* name; + bool on_disk; + bool leaf_ok; + bool structure_ok; + bool root_ok; +}; + +inline void PrintTo(const DeleteBackendCase& b, std::ostream* os) { *os << b.name; } + +const DeleteBackendCase kDeleteBackends[] = { + {HDF5_BACKEND, "HDF5", /*on_disk=*/true, /*leaf_ok=*/true, + /*structure_ok=*/true, /*root_ok=*/true}, + {MEMORY_BACKEND, "Memory", /*on_disk=*/false, /*leaf_ok=*/true, + /*structure_ok=*/true, /*root_ok=*/false}, + {ASCII_BACKEND, "ASCII", /*on_disk=*/true, /*leaf_ok=*/false, + /*structure_ok=*/false, /*root_ok=*/false}, + {FLEXBUFFERS_BACKEND, "Flexbuffers", /*on_disk=*/true, /*leaf_ok=*/false, + /*structure_ok=*/false, /*root_ok=*/false}, +}; + +class DeleteMatrix : public ::testing::TestWithParam { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; +}; + +TEST_P(DeleteMatrix, LeafDeleteRemovesJustTheLeaf) { + const DeleteBackendCase b = GetParam(); + if (!b.leaf_ok) { + GTEST_SKIP() << "known defect for " << b.name + << " leaf delete — see DeleteKnownDefects.*"; + } + const int pctx = open_fresh_pulse(b.id, b.on_disk, base_, pulse_); + + // Write and delete in the SAME WRITE_OP session: ASCII truncates its + // pulsefile (std::ios::out|std::ios::trunc) every time a WRITE_OP begins + // (ascii_backend.cpp:238), so a second, separate write-mode session would + // wipe the data itself and make the delete look effective when it isn't. + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_a", {}, {11.0})); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_b", {}, {22.0})); + AL_EXPECT_OK(al_delete_data(op, "leaf_a")); + AL_ASSERT_OK(al_end_action(op)); + + int rop = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &rop)); + std::vector shape; + std::vector a, bb; + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_a", 0, &shape, &a)); + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_b", 0, &shape, &bb)); + EXPECT_EQ(a.at(0), al_contract::kEmptyDouble) << "deleted leaf must read back absent"; + EXPECT_EQ(bb.at(0), 22.0) << "sibling leaf must survive a leaf-granularity delete"; + AL_ASSERT_OK(al_end_action(rop)); + + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_P(DeleteMatrix, StructureDeleteRemovesWholeSubtree) { + const DeleteBackendCase b = GetParam(); + if (!b.structure_ok) { + GTEST_SKIP() << "known defect for " << b.name + << " structure delete — see DeleteKnownDefects.*"; + } + const int pctx = open_fresh_pulse(b.id, b.on_disk, base_, pulse_); + + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + int size = 2; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, "block", "", &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, "x", {}, {1.0})); + AL_EXPECT_OK(al_iterate_over_arraystruct(aos, 1)); + AL_EXPECT_OK(al_contract::write_data(aos, "x", {}, {2.0})); + AL_ASSERT_OK(al_end_action(aos)); + AL_EXPECT_OK(al_contract::write_data(op, "sibling", {}, {99.0})); + AL_EXPECT_OK(al_delete_data(op, "block")); + AL_ASSERT_OK(al_end_action(op)); + + int rop = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &rop)); + int rsize = -1; + int raos = -1; + al_status_t s = al_begin_arraystruct_action(rop, "block", "", &rsize, &raos); + std::vector shape; + std::vector sibling; + AL_EXPECT_OK(al_contract::read_data(rop, "sibling", 0, &shape, &sibling)); + EXPECT_TRUE(s.code != 0 || rsize == 0) << "deleted structure must not report elements"; + EXPECT_EQ(sibling.at(0), 99.0) << "sibling field must survive a structure delete"; + if (raos != 0) al_end_action(raos); + AL_ASSERT_OK(al_end_action(rop)); + + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_P(DeleteMatrix, RootDeleteRemovesWholeOccurrence) { + const DeleteBackendCase b = GetParam(); + if (!b.root_ok) { + GTEST_SKIP() << "known defect for " << b.name + << " root delete — see DeleteKnownDefects.*"; + } + const int pctx = open_fresh_pulse(b.id, b.on_disk, base_, pulse_); + + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_a", {}, {11.0})); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_b", {}, {22.0})); + AL_EXPECT_OK(al_delete_data(op, "")); + AL_ASSERT_OK(al_end_action(op)); + + // A root/occurrence delete may remove the container so thoroughly that + // even opening a fresh READ action on it fails outright (verified: HDF5's + // deleteData removes the external link and the on-disk IDS file, so + // reopening "magnetics" for READ throws "Unable to open HDF5 group"). + // That failure is itself proof the occurrence is gone; if the backend + // instead tolerates reopening, every leaf must read back absent. + int rop = -1; + al_status_t open_read = al_begin_global_action(pctx, kIds, "", READ_OP, &rop); + if (open_read.code == 0) { + std::vector shape; + std::vector a, bb; + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_a", 0, &shape, &a)); + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_b", 0, &shape, &bb)); + EXPECT_EQ(a.at(0), al_contract::kEmptyDouble) << "root delete must remove every leaf"; + EXPECT_EQ(bb.at(0), al_contract::kEmptyDouble) << "root delete must remove every leaf"; + AL_ASSERT_OK(al_end_action(rop)); + } + + al_close_pulse(pctx, CLOSE_PULSE); +} + +INSTANTIATE_TEST_SUITE_P( + Backends, DeleteMatrix, ::testing::ValuesIn(kDeleteBackends), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.name); + }); + +// --------------------------------------------------------------------------- +// Known defects for al_delete_data (decision D2): correct-contract DISABLED_ +// tests, each paired with a current-behavior tripwire. +// --------------------------------------------------------------------------- +class DeleteKnownDefects : public ::testing::Test { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; + + int open(const BackendCase& b) { + if (b.on_disk) base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(b.id, base_.str(), pulse_); + EXPECT_FALSE(uri.empty()); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + return pctx; + } +}; + +const BackendCase kAscii{ASCII_BACKEND, "ASCII", /*on_disk=*/true}; + +// --- Memory: no code path implements DATAOBJECT-root delete ---------------- +bool memory_root_delete_clears_everything(al_contract::TempBase& base, + const PulseId& pulse) { + const std::string uri = al_contract::build_uri(MEMORY_BACKEND, base.str(), pulse); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); + EXPECT_EQ(al_contract::write_data(op, "leaf_a", {}, {11.0}).code, 0); + EXPECT_EQ(al_contract::write_data(op, "leaf_b", {}, {22.0}).code, 0); + EXPECT_EQ(al_delete_data(op, "").code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + int rop = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", READ_OP, &rop).code, 0); + std::vector shape; + std::vector a, bb; + EXPECT_EQ(al_contract::read_data(rop, "leaf_a", 0, &shape, &a).code, 0); + EXPECT_EQ(al_contract::read_data(rop, "leaf_b", 0, &shape, &bb).code, 0); + EXPECT_EQ(al_end_action(rop).code, 0); + al_close_pulse(pctx, CLOSE_PULSE); + return !a.empty() && !bb.empty() && a[0] == al_contract::kEmptyDouble && + bb[0] == al_contract::kEmptyDouble; +} + +TEST_F(DeleteKnownDefects, DISABLED_MemoryRootDeleteClearsWholeIds) { + EXPECT_TRUE(memory_root_delete_clears_everything(base_, pulse_)) + << "a DATAOBJECT-root delete must clear every field of the IDS"; +} + +TEST_F(DeleteKnownDefects, MemoryRootDeleteCurrentlyDoesNothing) { + EXPECT_FALSE(memory_root_delete_clears_everything(base_, pulse_)) + << "MemoryBackend::deleteData now supports root granularity — enable " + "DeleteKnownDefects.DISABLED_MemoryRootDeleteClearsWholeIds " + "(memory_backend.cpp deleteData has no root/occurrence case)"; +} + +// --- ASCII: deleteData is a literal no-op ---------------------------------- +// Flexbuffers is deliberately NOT covered by this pair: it refuses every +// in-session read regardless of delete (RoundTripMatrix classifies the whole +// backend as a must-refuse column — "write accepted, read refused, every +// datatype x shape"), so a read-based oracle can't distinguish "delete did +// nothing" from "this backend never round-trips reads at all." Its +// deleteData is confirmed a no-op by reading the source directly +// (flexbuffers_backend.cpp: empty NOOP body) — noted in the file-header +// comment above, not re-asserted here since it isn't ABI-observable. +bool delete_is_a_noop(const BackendCase& b, al_contract::TempBase& base, + const PulseId& pulse) { + const std::string uri = al_contract::build_uri(b.id, base.str(), pulse); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); + EXPECT_EQ(al_contract::write_data(op, "leaf_a", {}, {11.0}).code, 0); + EXPECT_EQ(al_delete_data(op, "leaf_a").code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + int rop = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", READ_OP, &rop).code, 0); + std::vector shape; + std::vector a; + EXPECT_EQ(al_contract::read_data(rop, "leaf_a", 0, &shape, &a).code, 0); + EXPECT_EQ(al_end_action(rop).code, 0); + al_close_pulse(pctx, CLOSE_PULSE); + return !a.empty() && a[0] == 11.0; // still there => delete did nothing +} + +TEST_F(DeleteKnownDefects, DISABLED_AsciiDeleteRemovesLeaf) { + EXPECT_FALSE(delete_is_a_noop(kAscii, base_, pulse_)) + << "al_delete_data must actually remove data in the ASCII backend"; +} +TEST_F(DeleteKnownDefects, AsciiDeleteIsCurrentlyANoOp) { + EXPECT_TRUE(delete_is_a_noop(kAscii, base_, pulse_)) + << "AsciiBackend::deleteData now has an effect — enable " + "DeleteKnownDefects.DISABLED_AsciiDeleteRemovesLeaf " + "(ascii_backend.cpp deleteData has an empty body)"; +} + + +// =========================================================================== +// HDF5-specific al_delete_data consequences (issue #63). +// =========================================================================== +// DeleteMatrix above asks the backend-agnostic question ("is the named node +// gone, is its sibling still there"). Three things it cannot see matter on +// HDF5, because they are properties of its two-level file layout rather than +// of the C ABI: +// +// 1. Which *files* survive. A non-empty path must leave .h5 alone; only +// an empty path (the whole DATAOBJECT) removes it. Before issue #63 every +// delete removed it. +// 2. Whether master.h5 still links the occurrence. The link has to follow the +// file: leaving it behind advertises an occurrence — al_get_occurrences +// enumerates exactly these links — whose backing file is gone. +// 3. Same-prefix siblings. The IDS group is a flat namespace of mangled path +// names ('/' -> '&', AOS nodes suffixed "[]"), so "time" and +// "time_slice[]&x" are neighbours in one namespace and a prefix match +// alone would take the second with the first. +// 4. Paths that traverse an AOS. The same flattening means "outer/inner" is +// stored as "outer[]&inner&…", so matching has to be per segment or such +// a path silently matches nothing. +// Located by name rather than by rebuilding the backend's own on-disk path +// strategy (hdf5_utils.cpp pulseFilePathFactory), which the test has no +// business restating. +inline bool file_exists_under(const std::string& root, const std::string& name) { + namespace fs = std::filesystem; + std::error_code ec; + for (fs::recursive_directory_iterator it(root, ec), end; it != end; + it.increment(ec)) { + if (ec) break; + if (it->path().filename() == name) return true; + } + return false; +} + +class Hdf5Delete : public ::testing::Test { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; + + int open() { + base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(HDF5_BACKEND, base_.str(), pulse_); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + return pctx; + } + + bool ids_file_exists() const { + return file_exists_under(base_.str(), std::string(kIds) + ".h5"); + } + + // True iff master.h5 still holds an external link for the occurrence. + // al_get_occurrences enumerates those links live (hdf5_reader.cpp + // H5Literate over master_file_id) and never stats what they point at, so + // it reports a link whose file is gone — which is what makes it the oracle + // for the dangling state issue #63 measured, independently of + // ids_file_exists(). + bool occurrence_listed(int pctx) const { + int* occ = nullptr; + int n = -1; + const bool ok = al_get_occurrences(pctx, kIds, &occ, &n).code == 0 && n > 0; + free(occ); + return ok; + } +}; + +TEST_F(Hdf5Delete, LeafDeleteKeepsOccurrenceFileAndMasterLink) { + const int pctx = open(); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_a", {}, {11.0})); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_b", {}, {22.0})); + AL_EXPECT_OK(al_delete_data(op, "leaf_a")); + AL_ASSERT_OK(al_end_action(op)); + + int rop = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &rop)); + std::vector shape; + std::vector a, b; + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_a", 0, &shape, &a)); + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_b", 0, &shape, &b)); + AL_ASSERT_OK(al_end_action(rop)); + + EXPECT_EQ(a.at(0), al_contract::kEmptyDouble) << "the named leaf must be gone"; + EXPECT_EQ(b.at(0), 22.0) + << "a leaf-granularity delete must not remove sibling fields"; + EXPECT_TRUE(ids_file_exists()) + << "a leaf-granularity delete must not remove the occurrence's pulse file"; + EXPECT_TRUE(occurrence_listed(pctx)) + << "the occurrence, and so the master file's link to it, must survive a " + "leaf-granularity delete"; + + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_F(Hdf5Delete, RootDeleteRemovesOccurrenceFileAndMasterLink) { + const int pctx = open(); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_a", {}, {11.0})); + AL_EXPECT_OK(al_delete_data(op, "")); + AL_ASSERT_OK(al_end_action(op)); + + EXPECT_FALSE(ids_file_exists()) + << "an empty path addresses the whole DATAOBJECT: .h5 must go"; + EXPECT_FALSE(occurrence_listed(pctx)) + << "the master file must not keep an external link to a pulse file that " + "was just deleted (issue #63)"; + + al_close_pulse(pctx, CLOSE_PULSE); +} + +// The flat-namespace hazard, in both continuation forms: "time" vs the AOS +// "time_slice[]&…" ("[]"), and "code" vs the leaf "code_name" (neither '&' nor +// "[]", so not a child at all). +TEST_F(Hdf5Delete, SamePrefixSiblingsSurviveASubtreeDelete) { + const int pctx = open(); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "time", {}, {7.0})); + AL_EXPECT_OK(al_contract::write_data(op, "code_name", {}, {8.0})); + AL_EXPECT_OK(al_contract::write_data(op, "code/version", {}, {9.0})); + int size = 1; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, "time_slice", "", &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, "x", {}, {5.0})); + AL_ASSERT_OK(al_end_action(aos)); + AL_EXPECT_OK(al_delete_data(op, "time")); + AL_EXPECT_OK(al_delete_data(op, "code")); + AL_ASSERT_OK(al_end_action(op)); + + int rop = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &rop)); + std::vector shape; + std::vector t, code_name, code_version, x; + AL_EXPECT_OK(al_contract::read_data(rop, "time", 0, &shape, &t)); + AL_EXPECT_OK(al_contract::read_data(rop, "code_name", 0, &shape, &code_name)); + AL_EXPECT_OK(al_contract::read_data(rop, "code/version", 0, &shape, &code_version)); + int rsize = -1; + int raos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(rop, "time_slice", "", &rsize, &raos)); + ASSERT_EQ(rsize, 1) << "the time_slice AOS must survive deleting the leaf \"time\""; + AL_EXPECT_OK(al_contract::read_data(raos, "x", 0, &shape, &x)); + AL_ASSERT_OK(al_end_action(raos)); + AL_ASSERT_OK(al_end_action(rop)); + + EXPECT_EQ(t.at(0), al_contract::kEmptyDouble) << "the named leaf must be gone"; + EXPECT_EQ(code_version.at(0), al_contract::kEmptyDouble) + << "a structure delete must take its children"; + EXPECT_EQ(x.at(0), 5.0) << "\"time_slice[]&x\" is not part of the \"time\" subtree"; + EXPECT_EQ(code_name.at(0), 8.0) + << "\"code_name\" is not part of the \"code\" subtree"; + + al_close_pulse(pctx, CLOSE_PULSE); +} + +// A path may address a node *inside* an AOS. The C ABI gives no way to say +// which element — al_delete_data takes an OperationContext, not an +// arraystruct one — so "outer/inner" can only mean "that subtree in every +// element", which is exactly how the tensorized layout stores it +// ("outer[]&inner&leaf", flattened over the AOS index). Raw prefix matching +// missed these entirely and reported success, so the call was a silent no-op. +TEST_F(Hdf5Delete, PathThroughAnAosDeletesTheSubtreeInEveryElement) { + const int pctx = open(); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + int size = 2; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, "outer", "", &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, "inner/leaf", {}, {1.0})); + AL_EXPECT_OK(al_contract::write_data(aos, "keep", {}, {3.0})); + AL_EXPECT_OK(al_iterate_over_arraystruct(aos, 1)); + AL_EXPECT_OK(al_contract::write_data(aos, "inner/leaf", {}, {2.0})); + AL_EXPECT_OK(al_contract::write_data(aos, "keep", {}, {4.0})); + AL_ASSERT_OK(al_end_action(aos)); + AL_EXPECT_OK(al_delete_data(op, "outer/inner")); + AL_ASSERT_OK(al_end_action(op)); + + int rop = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &rop)); + int rsize = -1; + int raos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(rop, "outer", "", &rsize, &raos)); + ASSERT_EQ(rsize, 2); + std::vector shape; + std::vector leaf, keep; + AL_EXPECT_OK(al_contract::read_data(raos, "inner/leaf", 0, &shape, &leaf)); + AL_EXPECT_OK(al_contract::read_data(raos, "keep", 0, &shape, &keep)); + AL_ASSERT_OK(al_end_action(raos)); + AL_ASSERT_OK(al_end_action(rop)); + EXPECT_EQ(leaf.at(0), al_contract::kEmptyDouble) + << "a path through an AOS must delete its subtree in every element"; + EXPECT_EQ(keep.at(0), 3.0) << "a sibling inside the same AOS element must survive"; + + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_F(Hdf5Delete, DeletingAnAbsentPathChangesNothing) { + const int pctx = open(); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_a", {}, {11.0})); + AL_EXPECT_OK(al_delete_data(op, "never_written")) + << "deleting what is not there is not an error"; + AL_ASSERT_OK(al_end_action(op)); + + int rop = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &rop)); + std::vector shape; + std::vector a; + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_a", 0, &shape, &a)); + AL_ASSERT_OK(al_end_action(rop)); + EXPECT_EQ(a.at(0), 11.0) << "an absent-path delete must not touch anything else"; + + al_close_pulse(pctx, CLOSE_PULSE); +} + +// The whole-IDS delete the HLI actually issues: imas-python's delete_children() +// walks the DD and sends one al_delete_data per leaf, so the *second* call has +// to still find a live group. It did not before issue #63: the first call +// closed the group, and every later one returned early having done nothing. +TEST_F(Hdf5Delete, ManySuccessiveLeafDeletesAllTakeEffect) { + const int pctx = open(); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + for (const char* leaf : {"leaf_a", "leaf_b", "leaf_c"}) + AL_EXPECT_OK(al_contract::write_data(op, leaf, {}, {1.0})); + AL_ASSERT_OK(al_end_action(op)); + + int dop = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &dop)); + for (const char* leaf : {"leaf_a", "leaf_b"}) + AL_EXPECT_OK(al_delete_data(dop, leaf)); + AL_ASSERT_OK(al_end_action(dop)); + + int rop = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &rop)); + std::vector shape; + std::vector a, b, c; + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_a", 0, &shape, &a)); + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_b", 0, &shape, &b)); + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_c", 0, &shape, &c)); + AL_ASSERT_OK(al_end_action(rop)); + EXPECT_EQ(a.at(0), al_contract::kEmptyDouble); + EXPECT_EQ(b.at(0), al_contract::kEmptyDouble) + << "the second delete of a session must take effect too"; + EXPECT_EQ(c.at(0), 1.0) << "a leaf nobody deleted must survive"; + + al_close_pulse(pctx, CLOSE_PULSE); +} + +// =========================================================================== +// al_get_occurrences. +// =========================================================================== +// HDF5 and ASCII implement it for real, each against its own occurrence-naming +// convention (neither backend transforms `dataobjectname`, so the *caller* +// supplies the backend-native form): HDF5 external-links are named literally, +// so occurrence N is "_" (src/hdf5/hdf5_reader.cpp:1550-1588, split on +// the last '_'); ASCII strips exactly one '/' character from `dataobjectname` +// (src/ascii_backend.cpp:194-197), so occurrence N must be passed as +// "/" to produce the on-disk stem "" its own directory scan +// expects. Memory and Flexbuffers throw unconditionally (not implemented). +// +// MDSplus (issue #14) implements it for real too, resolved empirically as a +// THIRD convention: occurrence N must be passed as "/", same slash +// form as ASCII (mdsconvertPath's SEPARATORS, src/mdsplus/mdsplus_backend.cpp:25, +// tokenizes on '/' and turns "equilibrium/2" into the real child node +// "EQUILIBRIUM:2" the baked model already contains -- occurrence subnodes are +// pre-baked structural slots, not dynamically created). get_occurrences +// (src/mdsplus/mdsplus_backend.cpp:4887-4954) walks ids_node's numeric-named +// children and reports an occurrence "filled" iff its +// ids_properties/homogeneous_time has nonzero length, so the write below must +// set homogeneous_time, not just the scalar. list_filled_paths is NOT +// real on MDSplus -- it throws unconditionally +// (src/mdsplus/mdsplus_backend.cpp:4956-4958, "not implemented in the MDSplus +// Backend"), confirmed via CapabilityMatrix below. +TEST(Occurrences, Hdf5ListsWrittenOccurrences) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string uri = al_contract::build_uri(HDF5_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + for (const char* name : {"magnetics", "magnetics_2"}) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, name, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "leaf", {}, {1.0})); + AL_ASSERT_OK(al_end_action(op)); + } + + int* occ = nullptr; + int n = -1; + AL_ASSERT_OK(al_get_occurrences(pctx, "magnetics", &occ, &n)); + ASSERT_EQ(n, 2); + EXPECT_EQ(occ[0], 0); + EXPECT_EQ(occ[1], 2); + free(occ); // malloc'd (src/hdf5/hdf5_reader.cpp:1592) -> free() is correct. + + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST(Occurrences, AsciiListsWrittenOccurrences) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string uri = al_contract::build_uri(ASCII_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + for (const char* name : {"magnetics", "magnetics/2"}) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, name, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "leaf", {}, {1.0})); + AL_ASSERT_OK(al_end_action(op)); + } + + int* occ = nullptr; + int n = -1; + AL_ASSERT_OK(al_get_occurrences(pctx, "magnetics", &occ, &n)); + ASSERT_EQ(n, 2); + EXPECT_EQ(occ[0], 0); + EXPECT_EQ(occ[1], 2); + free(occ); // malloc'd (src/ascii_backend.cpp:706) -> free() is correct. + + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST(Occurrences, MemoryRefuses) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + const std::string uri = al_contract::build_uri(MEMORY_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + int* occ = nullptr; + int n = -1; + EXPECT_NE(al_get_occurrences(pctx, "magnetics", &occ, &n).code, 0) + << "get_occurrences is not implemented in MemoryBackend"; + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST(Occurrences, FlexbuffersRefuses) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string uri = al_contract::build_uri(FLEXBUFFERS_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + int* occ = nullptr; + int n = -1; + EXPECT_NE(al_get_occurrences(pctx, "magnetics", &occ, &n).code, 0) + << "get_occurrences is not implemented in the Serialize (Flexbuffers) backend"; + al_close_pulse(pctx, CLOSE_PULSE); +} + +#ifdef AL_CONTRACT_HAVE_MDSPLUS +// MDSplus resolves paths against a real DD-baked model tree, so this reuses +// equilibrium_seed's real DD path instead of the synthetic "magnetics"/"leaf" +// the other three Occurrences.* cases use (see the divergence notes on +// EquilibriumSeedMatrix/AosMatrix above for why a synthetic path can't +// transfer). homogeneous_time must be written for get_occurrences to see the +// occurrence as filled at all (see comment above). +TEST(Occurrences, MdsplusListsWrittenOccurrences) { + AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string uri = al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + for (const char* name : {equilibrium_seed::kIds, "equilibrium/2"}) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, name, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, equilibrium_seed::kScalar, {}, {6.2})); + int ht = 1; + AL_EXPECT_OK(al_write_data(op, "ids_properties/homogeneous_time", "", &ht, + INTEGER_DATA, 0, nullptr)); + AL_ASSERT_OK(al_end_action(op)); + } + + int* occ = nullptr; + int n = -1; + AL_ASSERT_OK(al_get_occurrences(pctx, equilibrium_seed::kIds, &occ, &n)); + ASSERT_EQ(n, 2); + EXPECT_EQ(occ[0], 0); + EXPECT_EQ(occ[1], 2); + free(occ); // malloc'd (src/mdsplus/mdsplus_backend.cpp:4949) -> free() is correct. + + al_close_pulse(pctx, CLOSE_PULSE); +} +#endif // AL_CONTRACT_HAVE_MDSPLUS + +} // namespace diff --git a/tests/contract/test_uda.cpp b/tests/contract/test_uda.cpp new file mode 100644 index 00000000..af862b88 --- /dev/null +++ b/tests/contract/test_uda.cpp @@ -0,0 +1,241 @@ +// UDA smoke round trip (issue #23, feasibility spike / tracer bullet for #21). +// +// Proves the whole UDA characterization machine end-to-end before any +// behavioral breadth is attempted (the PRD's subtask-1 gate): one equilibrium +// scalar, seeded through the plain HDF5 backend, reads back byte-identical +// through the UDA backend in *remote mode* -- i.e. across the wire, through a +// real UDA server running the `IMAS` server plugin (linked against a server-side +// IMAS-Core), addressed by the public C ABI. This is the UDA analogue of +// test_mdsplus.cpp's tracer bullet, but read-only: UDA has no remote write path +// (PRD Solution), so parity arrives via a seed-then-reopen fixture rather than a +// write->read round trip. Seeding via HDF5 is fixture setup -- it plays the role +// the baked model tree plays for MDSplus; the subject under test is always the +// IMAS-Core UDA client. +// +// The whole reference stack (server + `IMAS` plugin + IMAS-Core + IDSDef.xml +// 4.1.1) runs in one container; docker/uda/run.sh builds it and drives this +// suite. See docker/uda/README.md. +// +// Build-gated by AL_CONTRACT_HAVE_UDA (defined in CMakeLists.txt only when a UDA +// backend is built -- AL_BACKEND_UDA or AL_BACKEND_UDAFAT) so the file compiles +// out entirely otherwise; runtime-skipped (GTEST_SKIP()) when UDA_HOST is unset, +// per TEST_STRATEGY D4 -- absent/skipped, never failed. +// +// Remoteness assumption: run.sh does not set IMAS_LOCAL_HOSTS, so the +// imas://localhost/uda authority is NOT short-circuited to a local backend by +// checkUriHost (src/al_context.cpp) -- the read genuinely traverses the wire to +// uda_server. Do not set IMAS_LOCAL_HOSTS in the reference container. +#ifdef AL_CONTRACT_HAVE_UDA + +#include "al_contract.h" +#include "equilibrium_seed.h" + +#include +#include + +#include + +#include +#include +#include +#include + +namespace { + +class UdaSmokeRoundTrip : public ::testing::Test { +protected: + void SetUp() override { AL_CONTRACT_SKIP_IF_UDA_UNCONFIGURED(); } + + al_contract::TempBase base_; +}; + +TEST_F(UdaSmokeRoundTrip, ScalarSeededViaHdf5ReadsBackThroughUda) { + // The pulse directory both backends address: the HDF5 backend writes + // master.h5 (+ per-IDS files) here (src/hdf5/hdf5_utils.cpp getPulseFilePath + // reads the URI's path= query verbatim), and the UDA client forwards the + // same path= to the server-side HDF5 open. Must live under an allowed path + // the UDA server will read (docker/uda/run.sh sets UDA_ALLOWED_PATHS). + const std::string pulse_dir = base_.str() + "/pulse"; + std::error_code ec; + std::filesystem::create_directories(pulse_dir, ec); + + const double kValue = equilibrium_seed::scalar_r0(); + + // --- seed one scalar through the plain HDF5 backend (fixture setup) --- + { + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(hdf5_uri.c_str(), + FORCE_CREATE_PULSE, &pulse_ctx)); + + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, + "", WRITE_OP, &op_ctx)); + // The UDA client's read path (readData, cache_mode=none) always resolves + // ids_properties/homogeneous_time first (get_homogeneous_flag), and + // throws — uncaught — if it is absent. Seed it so the tracer bullet + // exercises the r0 read, not that precondition. 1 == HOMOGENEOUS_TIME. + AL_EXPECT_OK(al_contract::write_data( + op_ctx, "ids_properties/homogeneous_time", {}, {1})); + AL_EXPECT_OK(al_contract::write_data( + op_ctx, equilibrium_seed::kScalar, {}, {kValue})); + AL_ASSERT_OK(al_end_action(op_ctx)); + + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + + // --- read it back through the UDA backend, remote mode, across the wire --- + { + // cache_mode=none takes readData's direct per-field IMAS::get path -- + // the simplest remote read, sufficient for the tracer bullet. backend= + // hdf5 tells the server which local backend to open behind the plugin. + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, + &pulse_ctx)); + + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, + "", READ_OP, &op_ctx)); + + std::vector shape; + std::vector value; + AL_EXPECT_OK(al_contract::read_data( + op_ctx, equilibrium_seed::kScalar, 0, &shape, &value)); + ASSERT_EQ(value.size(), 1u); + EXPECT_EQ(value.at(0), kValue); + + AL_ASSERT_OK(al_end_action(op_ctx)); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } +} + +// ----------------------------------------------------------------------------- +// Read-only parity fixture (issue #24, TRACEABILITY.md Part 5 row 2). +// +// Broadens the tracer bullet above from one scalar to the full equilibrium-seed +// composite shape (static scalar + top-level timebase array + a real +// time_slice/profiles_1d/constraints AOS, fixtures/equilibrium_seed.h — +// issues #4/#33/D5), reusing the seed's own generator and structural hash +// oracle unchanged: seed via HDF5 (fixture setup, same role the baked model +// tree plays for MDSplus), reopen the identical path through the UDA backend in +// remote mode, and check what UDA reads back. +// +// Empirically (against the docker/uda/ reference stack): the seed is now +// DD-4.1.1-valid (issue #33), so this is no longer a fixture-shape divergence — +// it splits by shape class. The top-level real DD paths (`vacuum_toroidal_field +// /r0` scalar, the `time` timebase array) round-trip cleanly. But the seed's +// dynamic leaves nested inside the `time_slice` struct_array come back absent +// through UDA remote mode + backend=hdf5 — the independently-pinned +// UdaAosKnownDefects defect (test_uda_breadth.cpp), the same one +// UdaRealPathMatrixKnownDefects pins for COMPLEX cells. So the full structural +// hash cannot match. That is a genuine UDA xfail, pinned per D2 below +// (DISABLED_FullSeedRoundTripsThroughUda correct-contract + +// TopLevelRoundTripsButAosLeavesReadAbsent active tripwire), NOT fixture +// invalidity: the identical seed round-trips fully on HDF5/Memory/ASCII/MDSplus +// (EquilibriumSeedMatrix). See TRACEABILITY.md Part 5. +class UdaEquilibriumSeedParity : public ::testing::Test { +protected: + void SetUp() override { AL_CONTRACT_SKIP_IF_UDA_UNCONFIGURED(); } + + al_contract::TempBase base_; +}; + +// Seed the full DD-valid composite through the plain HDF5 backend, then hand a +// UDA remote-mode read context for `pulse_dir` to `check`. +void seed_hdf5_then_open_uda(al_contract::TempBase& base, + const std::function& check) { + const std::string pulse_dir = base.str() + "/pulse"; + std::error_code ec; + std::filesystem::create_directories(pulse_dir, ec); + + { + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(hdf5_uri.c_str(), + FORCE_CREATE_PULSE, &pulse_ctx)); + // The UDA client's cache_mode=none read path always resolves + // ids_properties/homogeneous_time first and throws if absent. Written in + // its own GLOBAL op ahead of the seed so equilibrium_seed::write() stays + // unmodified. + { + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, + "", WRITE_OP, &op_ctx)); + AL_EXPECT_OK(al_contract::write_data( + op_ctx, "ids_properties/homogeneous_time", {}, {1})); + AL_ASSERT_OK(al_end_action(op_ctx)); + } + AL_ASSERT_OK(equilibrium_seed::write(pulse_ctx)); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK( + al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, &pulse_ctx)); + check(pulse_ctx); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); +} + +// Read just the two top-level fields (static scalar + timebase array) in their +// own READ op and confirm they round-trip through UDA. These are real DD paths +// NOT nested in a struct_array, so they are unaffected by the dynamic-leaf- +// inside-AOS defect below. +void expect_top_level_roundtrips(int uda_ctx) { + int op = -1; + AL_ASSERT_OK( + al_begin_global_action(uda_ctx, equilibrium_seed::kIds, "", READ_OP, &op)); + std::vector shape; + std::vector r0, time; + AL_EXPECT_OK(al_contract::read_data(op, equilibrium_seed::kScalar, 0, + &shape, &r0)); + ASSERT_EQ(r0.size(), 1u); + EXPECT_DOUBLE_EQ(r0[0], equilibrium_seed::scalar_r0()); + AL_EXPECT_OK(al_contract::read_data(op, equilibrium_seed::kTimebase, + 1, &shape, &time)); + EXPECT_EQ(time, equilibrium_seed::timebase_values()); + AL_ASSERT_OK(al_end_action(op)); +} + +// Covered (issue #33): the whole DD-valid seed round-trips through UDA remote +// mode -- the static scalar, the top-level timebase array, AND the time_slice +// struct_array's dynamic leaves (time, profiles_1d/psi, global_quantities/ip, +// constraints/ip/{measured,weight}) -- reproducing the structural hash exactly. +// +// This does NOT contradict UdaAosKnownDefects (test_uda_breadth.cpp), which +// pins that a dynamic leaf inside a struct_array reads back absent through UDA: +// that fixture seeds time_slice with an *empty* AOS timebase and no per-element +// `time` leaf. This seed writes the AOS with its real DD timebasepath ("time") +// and a per-element `time`, and empirically UDA then resolves every nested +// dynamic leaf correctly -- a well-formed, DD-conformant time_slice AOS is the +// case that works. The structural hash (rank/extents/AOS-index-aware) is what +// makes this a trustworthy parity oracle rather than a values-only check. +TEST_F(UdaEquilibriumSeedParity, FullSeedRoundTripsThroughUda) { + seed_hdf5_then_open_uda(base_, [](int uda_ctx) { + expect_top_level_roundtrips(uda_ctx); + + std::vector obs; + AL_ASSERT_OK(equilibrium_seed::read_back(uda_ctx, &obs)); + + const std::vector expected = + equilibrium_seed::expected_records(); + ASSERT_EQ(obs.size(), expected.size()) + << "UDA observed a different field/AOS-element count than the seed"; + for (std::size_t i = 0; i < expected.size(); ++i) { + EXPECT_EQ(obs[i].field, expected[i].field) << "field #" << i; + EXPECT_EQ(obs[i].rank, expected[i].rank) + << expected[i].field << " rank"; + EXPECT_EQ(obs[i].extents, expected[i].extents) + << expected[i].field << " extents"; + } + EXPECT_EQ(equilibrium_seed::canonical_hash(obs), + equilibrium_seed::expected_hash()) + << "the DD-valid equilibrium seed must round-trip through UDA " + "remote mode, structure and values"; + }); +} + +} // namespace + +#endif // AL_CONTRACT_HAVE_UDA diff --git a/tests/contract/test_uda_breadth.cpp b/tests/contract/test_uda_breadth.cpp new file mode 100644 index 00000000..2b98f140 --- /dev/null +++ b/tests/contract/test_uda_breadth.cpp @@ -0,0 +1,756 @@ +// UDA parity breadth beyond the real-DD-path matrix (issue #25, +// TRACEABILITY.md Part 5): AOS traversal, occurrences, list_filled_paths, +// pulse open modes / error behavior, and slice/time-range reads -- every +// remaining C-ABI-reachable read-side capability from +// FUNCTIONALITY_INVENTORY.md Part 1 that test_uda.cpp / test_uda_real_paths.cpp +// do not already cover. +// +// Same seed(HDF5)-then-reopen(UDA) shape throughout (issue #24): the plain +// HDF5 backend seeds a pulse dir, the UDA backend in remote mode +// (backend=hdf5&cache_mode=none) reopens the identical path and the test +// asserts what comes back through the public C ABI. Real DD-4.1.1 paths only +// (UDA validates every path against the schema it loads at startup, +// src/uda/uda_xml.cpp) -- see test_uda.cpp's characterization-discovered +// facts for the ids_properties/homogeneous_time precondition every remote +// read needs first. +#ifdef AL_CONTRACT_HAVE_UDA + +#include "al_contract.h" +#include "equilibrium_seed.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace { + +// Exercise ERASE_PULSE entirely through the public C ABI, then report whether +// the same remote pulse remains openable. Shared by the disabled correct- +// contract test and its current-behavior tripwire below. +struct UdaEraseResult { + int create_code; + int erase_code; + int reopen_code; +}; + +UdaEraseResult uda_remote_reopen_after_erase() { + al_contract::TempBase base; + const std::string pulse_dir = base.str() + "/pulse"; + std::error_code ec; + std::filesystem::create_directories(pulse_dir, ec); + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + + int pulse_ctx = -1; + al_status_t created = al_begin_dataentry_action( + uda_uri.c_str(), FORCE_CREATE_PULSE, &pulse_ctx); + if (created.code != 0) return {created.code, -1, -1}; + + al_status_t erased = al_close_pulse(pulse_ctx, ERASE_PULSE); + if (erased.code != 0) return {created.code, erased.code, -1}; + + int reopened_ctx = -1; + al_status_t reopened = + al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, &reopened_ctx); + if (reopened.code == 0) { + AL_EXPECT_OK(al_close_pulse(reopened_ctx, CLOSE_PULSE)); + } + return {created.code, erased.code, reopened.code}; +} + +class UdaBreadthTest : public ::testing::Test { +protected: + void SetUp() override { AL_CONTRACT_SKIP_IF_UDA_UNCONFIGURED(); } + + al_contract::TempBase base_; + + std::string fresh_pulse_dir() const { + const std::string dir = base_.str() + "/pulse"; + std::error_code ec; + std::filesystem::create_directories(dir, ec); + return dir; + } +}; + +// =========================================================================== +// AOS traversal (top-level): temporary/constant_integer3d is a real DD-4.1.1 +// struct_array whose static `value` leaf already round-trips through UDA (the +// INTEGER_r3 real-path matrix cell). Three order-sensitive elements make this +// an active C-ABI proof that al_iterate_over_arraystruct advances the cursor: +// a successful no-op, repeated element, or reordered element all fail. +// =========================================================================== + +TEST_F(UdaBreadthTest, AosTraversalAdvancesAcrossDistinctStaticElements) { + const std::string pulse_dir = fresh_pulse_dir(); + constexpr int kN = 3; + const std::vector kShape = {1, 1, 1}; + const std::vector> kExpected = {{101}, {202}, {303}}; + + // --- seed via HDF5 ------------------------------------------------------- + { + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(hdf5_uri.c_str(), + FORCE_CREATE_PULSE, &pulse_ctx)); + + int op = -1; + AL_ASSERT_OK( + al_begin_global_action(pulse_ctx, "temporary", "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data( + op, "ids_properties/homogeneous_time", {}, {1})); + + int size = kN; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action( + op, "constant_integer3d", "", &size, &aos)); + // Seed physical indexes in 0, 2, 1 order so fixture preparation does + // not mirror the forward 0, 1, 2 traversal asserted below. This makes + // a reordered read observable even if iteration is changed globally. + AL_EXPECT_OK(al_contract::write_data(aos, "value", kShape, + kExpected[0])); + AL_ASSERT_OK(al_iterate_over_arraystruct(aos, 2)); + AL_EXPECT_OK(al_contract::write_data(aos, "value", kShape, + kExpected[2])); + AL_ASSERT_OK(al_iterate_over_arraystruct(aos, -1)); + AL_EXPECT_OK(al_contract::write_data(aos, "value", kShape, + kExpected[1])); + AL_EXPECT_OK(al_end_action(aos)); + AL_EXPECT_OK(al_end_action(op)); + AL_ASSERT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } + + // --- reopen through UDA, remote mode ------------------------------------ + { + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK( + al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, &pulse_ctx)); + + int op = -1; + AL_ASSERT_OK( + al_begin_global_action(pulse_ctx, "temporary", "", READ_OP, &op)); + + int size = 0; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action( + op, "constant_integer3d", "", &size, &aos)); + ASSERT_EQ(size, kN) << "AOS size must round-trip through UDA"; + for (int i = 0; i < size; ++i) { + SCOPED_TRACE(i); + std::vector shape; + std::vector data; + AL_ASSERT_OK( + al_contract::read_data(aos, "value", 3, &shape, &data)); + EXPECT_EQ(shape, kShape); + EXPECT_EQ(data, kExpected[static_cast(i)]) + << "AOS cursor must preserve element order"; + if (i + 1 < size) { + AL_ASSERT_OK(al_iterate_over_arraystruct(aos, 1)); + } + } + AL_ASSERT_OK(al_end_action(aos)); + + // The public C ABI promises a failure status for an invalid context. + EXPECT_NE(al_iterate_over_arraystruct(aos, 1).code, 0); + + AL_EXPECT_OK(al_end_action(op)); + AL_ASSERT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } +} + +// =========================================================================== +// Separate value-transport defect pin: equilibrium/time_slice is a real DD +// struct_array with a dynamic global_quantities/ip leaf. Its size and request +// paths advance, but UDA returns the HDF5 absent sentinel for every element. +// Keep this D2 pair independent of the discriminating traversal test above. +// =========================================================================== + +// Seed 3 elements of equilibrium/time_slice via HDF5 (global_quantities/ip = +// 100+i per element), reopen through UDA remote mode, and return what comes +// back for each element (rather than asserting inline) so the correct- +// contract test and its tripwire can share one seed/read path (mirrors +// test_structured_data.cpp's ascii_aos_read_reported_size() pattern). +std::vector dynamic_leaf_inside_aos_values_via_uda() { + al_contract::TempBase base; + const std::string pulse_dir = base.str() + "/pulse"; + std::error_code ec; + std::filesystem::create_directories(pulse_dir, ec); + constexpr int kN = 3; + + // --- seed via HDF5 ------------------------------------------------------- + { + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + EXPECT_EQ(al_begin_dataentry_action(hdf5_uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx) + .code, + 0); + + int op = -1; + EXPECT_EQ(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + WRITE_OP, &op) + .code, + 0); + EXPECT_EQ(al_contract::write_data( + op, "ids_properties/homogeneous_time", {}, {1}) + .code, + 0); + + int size = kN; + int aos = -1; + EXPECT_EQ(al_begin_arraystruct_action(op, "time_slice", "", &size, &aos) + .code, + 0); + for (int i = 0; i < kN; ++i) { + EXPECT_EQ(al_contract::write_data(aos, "global_quantities/ip", + {}, {100.0 + i}) + .code, + 0); + if (i + 1 < kN) EXPECT_EQ(al_iterate_over_arraystruct(aos, 1).code, 0); + } + EXPECT_EQ(al_end_action(aos).code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + + // --- reopen through UDA, remote mode ------------------------------------ + std::vector values; + { + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + EXPECT_EQ( + al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, &pulse_ctx).code, + 0); + + int op = -1; + EXPECT_EQ(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op) + .code, + 0); + + int rsize = 0; + int raos = -1; + EXPECT_EQ( + al_begin_arraystruct_action(op, "time_slice", "", &rsize, &raos).code, + 0); + EXPECT_EQ(rsize, kN) << "AOS size must round-trip through UDA"; + for (int i = 0; i < rsize; ++i) { + std::vector shape; + std::vector data; + EXPECT_EQ(al_contract::read_data(raos, "global_quantities/ip", + 0, &shape, &data) + .code, + 0); + values.push_back(data.empty() ? al_contract::kEmptyDouble : data[0]); + if (i + 1 < rsize) EXPECT_EQ(al_iterate_over_arraystruct(raos, 1).code, 0); + } + EXPECT_EQ(al_end_action(raos).code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + return values; +} + +// CORRECT-CONTRACT, expected-fail (DISABLED_): every element's written value +// must round-trip. +TEST(UdaAosKnownDefects, DISABLED_DynamicLeafInsideAosRoundTrips) { + AL_CONTRACT_SKIP_IF_UDA_UNCONFIGURED(); + const std::vector values = dynamic_leaf_inside_aos_values_via_uda(); + ASSERT_EQ(values.size(), 3u); + for (int i = 0; i < 3; ++i) { + EXPECT_EQ(values[static_cast(i)], 100.0 + i) + << "element " << i << " must round-trip through UDA remote mode"; + } +} + +// CURRENT-BEHAVIOR tripwire: every element currently comes back as the HDF5 +// "absent" sentinel instead of what was written. When this is fixed, this +// test goes red, forcing whoever fixed it to enable the correct-contract +// test above. +TEST(UdaAosKnownDefects, DynamicLeafInsideAosCurrentlyReturnsSentinel) { + AL_CONTRACT_SKIP_IF_UDA_UNCONFIGURED(); + const std::vector values = dynamic_leaf_inside_aos_values_via_uda(); + ASSERT_EQ(values.size(), 3u); + for (double v : values) { + EXPECT_EQ(v, al_contract::kEmptyDouble) + << "a dynamic leaf nested in an AOS now returns real data through " + "UDA remote mode -- enable " + "UdaAosKnownDefects.DISABLED_DynamicLeafInsideAosRoundTrips " + "(and update UdaRealPathMatrix's COMPLEX_r1/r3/r5 " + "known_defect_reason rows in test_uda_real_paths.cpp)"; + } +} + +// =========================================================================== +// Occurrences (al_get_occurrences): the reference IMAS server plugin +// implements getOccurrences (PRD #21's static finding, confirmed empirically +// here). Seeded exactly like Occurrences.Hdf5ListsWrittenOccurrences +// (test_structured_data.cpp) -- occurrence 0 as "equilibrium", occurrence 2 +// as HDF5's own "_" name "equilibrium_2" -- then listed and read +// through UDA remote mode, whose public occurrence name is "/". +// =========================================================================== +TEST_F(UdaBreadthTest, OccurrencesListsWrittenOccurrencesThroughReopen) { + const std::string pulse_dir = fresh_pulse_dir(); + constexpr const char* kHdf5OccurrenceNames[] = {"equilibrium", + "equilibrium_2"}; + constexpr const char* kUdaOccurrenceNames[] = {"equilibrium", + "equilibrium/2"}; + constexpr double kOccurrenceValues[] = {6.2, 8.4}; + + // --- seed via HDF5: occurrence 0 and occurrence 2 ----------------------- + { + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(hdf5_uri.c_str(), + FORCE_CREATE_PULSE, &pulse_ctx)); + + for (size_t i = 0; i < 2; ++i) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action( + pulse_ctx, kHdf5OccurrenceNames[i], "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data( + op, "ids_properties/homogeneous_time", {}, {1})); + AL_EXPECT_OK(al_contract::write_data( + op, equilibrium_seed::kScalar, {}, {kOccurrenceValues[i]})); + AL_ASSERT_OK(al_end_action(op)); + } + AL_ASSERT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } + + // --- list occurrences through UDA, remote mode -------------------------- + { + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, + &pulse_ctx)); + + int* occ = nullptr; + int n = -1; + AL_ASSERT_OK( + al_get_occurrences(pulse_ctx, equilibrium_seed::kIds, &occ, &n)); + ASSERT_EQ(n, 2); + EXPECT_EQ(occ[0], 0); + EXPECT_EQ(occ[1], 2); + free(occ); // malloc'd by UDABackend::get_occurrences. + + // UDA uses the public "/" occurrence names. + for (size_t i = 0; i < 2; ++i) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action( + pulse_ctx, kUdaOccurrenceNames[i], "", READ_OP, &op)); + std::vector shape; + std::vector data; + AL_ASSERT_OK(al_contract::read_data( + op, equilibrium_seed::kScalar, 0, &shape, &data)); + ASSERT_EQ(data.size(), 1u); + EXPECT_DOUBLE_EQ(data[0], kOccurrenceValues[i]); + AL_ASSERT_OK(al_end_action(op)); + } + + AL_ASSERT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } +} + +// =========================================================================== +// al_list_filled_paths: the reference IMAS server plugin implements +// listFilledPaths (PRD #21's static finding); UDABackend::list_filled_paths +// restricts itself to backend=hdf5 (src/uda/uda_backend.cpp), which this +// fixture already uses throughout. +// =========================================================================== +TEST_F(UdaBreadthTest, ListFilledPathsThroughReopen) { + const std::string pulse_dir = fresh_pulse_dir(); + + // --- seed via HDF5 ------------------------------------------------------- + { + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(hdf5_uri.c_str(), + FORCE_CREATE_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, + "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data( + op, "ids_properties/homogeneous_time", {}, {1})); + AL_EXPECT_OK( + al_contract::write_data(op, equilibrium_seed::kScalar, {}, {6.2})); + AL_ASSERT_OK(al_end_action(op)); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + + // --- list_filled_paths through UDA, remote mode ------------------------- + { + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, + &pulse_ctx)); + + char** paths = nullptr; + int n = -1; + al_status_t s = al_list_filled_paths(pulse_ctx, equilibrium_seed::kIds, + &paths, &n); + AL_EXPECT_OK(s); + ASSERT_GE(n, 0); + std::set got; + for (int i = 0; i < n; ++i) { + got.insert(paths[i]); + free(paths[i]); + } + free(paths); + EXPECT_TRUE(got.count("ids_properties/homogeneous_time")) + << "seeded leaf missing from listing"; + EXPECT_TRUE(got.count(equilibrium_seed::kScalar)) + << "seeded leaf missing from listing"; + + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } +} + +// =========================================================================== +// Pulse open modes and error behavior on absent paths / absent IDSs. +// =========================================================================== + +// OPEN_PULSE against a UDA URI whose remote pulse dir does not exist at all. +// UDA's `openPulse` genuinely round-trips to the server at open time (unlike +// the initial assumption this test started from) -- the debug trace shows +// `al_begin_dataentry_action` itself issuing `IMAS::open(...)`, and the +// server-side HDF5 backend it opens on the plugin's behalf refuses +// immediately ("HDF5 master file not found"), which the UDA client re-throws +// through the C ABI as a non-zero al_status_t. This is genuine **parity** +// with the on-disk backends' own OPEN_PULSE-fails-when-absent contract +// (DataEntryModes.OpenPulseFailsWhenAbsent, test_pulse_lifecycle.cpp) -- UDA +// remote mode does not weaken it. +TEST_F(UdaBreadthTest, OpenPulseFailsWhenRemotePathAbsent) { + const std::string absent_dir = base_.str() + "/never-seeded"; + const std::string uda_uri = al_contract::uda_hdf5_uri_for(absent_dir); + int pulse_ctx = -1; + al_status_t s = + al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, &pulse_ctx); + EXPECT_NE(s.code, 0) << "OPEN_PULSE must fail when the remote pulse does " + "not exist, matching the on-disk backends' " + "parity contract"; + if (s.code == 0) { + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } +} + +TEST_F(UdaBreadthTest, ForceOpenPulseCreatesRemotePulseWhenAbsent) { + const std::string pulse_dir = fresh_pulse_dir(); + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + + int pulse_ctx = -1; + AL_ASSERT_OK( + al_begin_dataentry_action(uda_uri.c_str(), FORCE_OPEN_PULSE, &pulse_ctx)); + AL_ASSERT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + + int reopened_ctx = -1; + al_status_t reopened = + al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, &reopened_ctx); + AL_EXPECT_OK(reopened); + if (reopened.code == 0) { + EXPECT_EQ(al_close_pulse(reopened_ctx, CLOSE_PULSE).code, 0); + } +} + +TEST_F(UdaBreadthTest, CreatePulseRefusesExistingRemotePulse) { + const std::string pulse_dir = fresh_pulse_dir(); + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uda_uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + AL_ASSERT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + + int create_ctx = -1; + al_status_t s = al_begin_dataentry_action(uda_uri.c_str(), CREATE_PULSE, + &create_ctx); + EXPECT_NE(s.code, 0) + << "CREATE_PULSE must refuse an existing remote pulse"; + if (s.code == 0) { + AL_EXPECT_OK(al_close_pulse(create_ctx, CLOSE_PULSE)); + } +} + +TEST_F(UdaBreadthTest, ForceCreatePulseAcceptsExistingRemotePulse) { + const std::string pulse_dir = fresh_pulse_dir(); + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uda_uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + AL_ASSERT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + + int replaced_ctx = -1; + al_status_t replaced = al_begin_dataentry_action( + uda_uri.c_str(), FORCE_CREATE_PULSE, &replaced_ctx); + AL_EXPECT_OK(replaced); + if (replaced.code == 0) { + EXPECT_EQ(al_close_pulse(replaced_ctx, CLOSE_PULSE).code, 0); + } +} + +// CORRECT-CONTRACT, expected-fail (DISABLED_): ERASE_PULSE promises to close +// and remove the pulse, so a later OPEN_PULSE must fail. +TEST_F(UdaBreadthTest, DISABLED_ErasePulseMakesRemotePulseUnopenable) { + const UdaEraseResult result = uda_remote_reopen_after_erase(); + ASSERT_EQ(result.create_code, 0) << "test setup must create the remote pulse"; + ASSERT_EQ(result.erase_code, 0) << "ERASE_PULSE itself must report success"; + EXPECT_NE(result.reopen_code, 0) + << "ERASE_PULSE must remove the remote pulse"; +} + +// CURRENT-BEHAVIOR tripwire: the reference stack's server-side HDF5 backend +// accepts ERASE_PULSE but treats it like CLOSE_PULSE, leaving the pulse +// openable. When this changes, this test goes red and the correct-contract +// test above must be enabled. +TEST_F(UdaBreadthTest, ErasePulseCurrentlyLeavesRemotePulseOpenable) { + const UdaEraseResult result = uda_remote_reopen_after_erase(); + ASSERT_EQ(result.create_code, 0) << "test setup must create the remote pulse"; + ASSERT_EQ(result.erase_code, 0) << "ERASE_PULSE itself must report success"; + EXPECT_EQ(result.reopen_code, 0) + << "ERASE_PULSE now removes the remote pulse -- enable " + "UdaBreadthTest.DISABLED_ErasePulseMakesRemotePulseUnopenable"; +} + +// A read against a real DD leaf that was never written (the IDS/occurrence +// itself was seeded, but this particular field was not): confirmed +// empirically that this succeeds (code==0) and returns the same HDF5 +// "absent leaf" sentinel value (al_contract::kEmptyDouble) the local HDF5 +// backend itself returns for an unwritten scalar read (RoundTripMatrix's +// generator carefully avoids ever *writing* this sentinel, precisely because +// reading an absent scalar returns it rather than erroring -- al_lowlevel.cpp +// Lowlevel::setDefaultValue). This is transparent parity, not a UDA-specific +// behavior: the remote get() forwards straight through to the server-side +// HDF5 backend's own semantics, unchanged. +TEST_F(UdaBreadthTest, ReadOfUnwrittenLeafOnSeededIdsReturnsHdf5AbsentSentinel) { + const std::string pulse_dir = fresh_pulse_dir(); + { + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(hdf5_uri.c_str(), + FORCE_CREATE_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, + "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data( + op, "ids_properties/homogeneous_time", {}, {1})); + // Deliberately do NOT write equilibrium_seed::kScalar. + AL_ASSERT_OK(al_end_action(op)); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op)); + std::vector shape; + std::vector data; + al_status_t s = al_contract::read_data(op, equilibrium_seed::kScalar, + 0, &shape, &data); + AL_EXPECT_OK(s) << "reading an unwritten leaf on a seeded IDS reports " + "success (matching the local HDF5 backend's own " + "absent-scalar contract), not an error"; + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], al_contract::kEmptyDouble) + << "unwritten leaf must read back as the HDF5 absent-scalar " + "sentinel, forwarded transparently through UDA"; + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); +} + +// A read against a real-DD-conformant but never-written IDS (the pulse dir +// exists and one IDS was seeded, but a completely different, never-touched +// IDS is requested) must also fail cleanly. +TEST_F(UdaBreadthTest, ReadFailsForNeverWrittenIds) { + const std::string pulse_dir = fresh_pulse_dir(); + { + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(hdf5_uri.c_str(), + FORCE_CREATE_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, + "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data( + op, "ids_properties/homogeneous_time", {}, {1})); + AL_ASSERT_OK(al_end_action(op)); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, &pulse_ctx)); + // "magnetics" is a real DD 4.1.1 IDS never written to this pulse dir. + int op = -1; + AL_ASSERT_OK( + al_begin_global_action(pulse_ctx, "magnetics", "", READ_OP, &op)); + std::vector shape; + std::vector data; + al_status_t s = al_contract::read_data( + op, "code/output_flag", 1, &shape, &data); + EXPECT_NE(s.code, 0) << "reading a never-written IDS must fail, not " + "return stale/default data"; + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); +} + +// =========================================================================== +// Slice and time-range reads (where existing fixtures cover them for other +// backends -- CapabilityMatrix, test_capability_gated.cpp). Real DD dynamic +// leaf: equilibrium's vacuum_toroidal_field/b0 (FLT_1D, coordinate1 "time"), +// paired with the IDS's own top-level "time" timebase -- both real DD 4.1.1 +// paths, the same shape CapabilityMatrix's seed_signal() uses with opaque +// names. +// =========================================================================== +constexpr double kUdaTimeT0 = 1.0, kUdaTimeT1 = 2.0; +constexpr double kUdaTimeV0 = 10.0, kUdaTimeV1 = 20.0; + +class UdaSliceAndTimeRange : public UdaBreadthTest { +public: + // Seed the 2-point dynamic signal through HDF5, return the pulse dir. + // Public: the timerange-known-defect helper below (a free function, so + // the DISABLED_/tripwire pair can share one seed/read path) calls it on a + // fixture instance passed in by the TEST_F body. + std::string seed_dynamic_signal() { + const std::string pulse_dir = fresh_pulse_dir(); + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + EXPECT_EQ(al_begin_dataentry_action(hdf5_uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx) + .code, + 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + WRITE_OP, &op) + .code, + 0); + int ht = 1; + double t[2] = {kUdaTimeT0, kUdaTimeT1}; + double v[2] = {kUdaTimeV0, kUdaTimeV1}; + int shape[1] = {2}; + EXPECT_EQ(al_write_data(op, "ids_properties/homogeneous_time", "", &ht, + INTEGER_DATA, 0, nullptr) + .code, + 0); + EXPECT_EQ(al_write_data(op, "time", "time", t, DOUBLE_DATA, 1, shape).code, 0); + EXPECT_EQ(al_write_data(op, "vacuum_toroidal_field/b0", "time", v, + DOUBLE_DATA, 1, shape) + .code, + 0); + EXPECT_EQ(al_end_action(op).code, 0); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + return pulse_dir; + } +}; + +TEST_F(UdaSliceAndTimeRange, SliceReadThroughReopen) { + const std::string pulse_dir = seed_dynamic_signal(); + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, &pulse_ctx)); + + int op = -1; + // A time between the two samples; CLOSEST resolves to kT0 -> kV0. + al_status_t s = al_begin_slice_action(pulse_ctx, equilibrium_seed::kIds, + READ_OP, 1.4, CLOSEST_INTERP, &op); + AL_ASSERT_OK(s); + void* buf = nullptr; + int size[MAXDIM] = {0}; + AL_EXPECT_OK(al_read_data(op, "vacuum_toroidal_field/b0", "time", &buf, + DOUBLE_DATA, 1, size)); + ASSERT_NE(buf, nullptr); + EXPECT_EQ(static_cast(buf)[0], kUdaTimeV0) + << "CLOSEST slice to t=1.4 is t=1.0 -> " << kUdaTimeV0; + free(buf); + EXPECT_EQ(al_end_action(op).code, 0); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); +} + +// =========================================================================== +// Time-range read -- NEW DEFECT (not UDA-specific in origin, but only +// observable through UDA's C-ABI surface): al_begin_timerange_action's +// OperationContext constructor (src/al_context.cpp, the +// `(ctx, dataobject, access, range, tmin, tmax, dtime, interp)` overload) +// never assigns the base `interpmode` member -- only +// `time_range.interpolation_method` -- unlike the GLOBAL_OP and SLICE_OP +// constructors, which both set it. Every always-on backend's readData() +// ignores `getInterpmode()` for a TIMERANGE_OP context (they consult +// `time_range` instead), so the uninitialized member is silently never +// observed -- until UDA's remote readData() directive-builder +// (src/uda/uda_backend.cpp) calls `op_ctx->getInterpmode()` +// *unconditionally*, regardless of rangemode, to fill the directive's +// `interp=` field. Confirmed empirically: the garbage value trips +// `uda_utilities.hpp`'s `InterpMode` convertor's default case, throwing +// `std::runtime_error("unknown interp mode: ")`, surfaced +// through the C ABI as `al_plugin_read_data: unknown interp mode: ...`. Pinned +// per D2: correct-contract (DISABLED_) + current-behavior tripwire, sharing +// one read attempt (mirrors UdaAosKnownDefects above). +// =========================================================================== +al_status_t timerange_read_without_resampling_via_uda( + UdaSliceAndTimeRange* fixture, void** buf, int* size) { + const std::string pulse_dir = fixture->seed_dynamic_signal(); + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + EXPECT_EQ( + al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, &pulse_ctx).code, 0); + + int op = -1; + double dtime = 0.0; + int dtime_shape = 0; // no resampling: return the raw stored slices + EXPECT_EQ(al_begin_timerange_action(pulse_ctx, equilibrium_seed::kIds, + READ_OP, kUdaTimeT0, + kUdaTimeT1, &dtime, + &dtime_shape, UNDEFINED_INTERP, &op) + .code, + 0); + al_status_t s = al_read_data(op, "vacuum_toroidal_field/b0", "time", buf, + DOUBLE_DATA, 1, size); + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + return s; +} + +// CORRECT-CONTRACT, expected-fail (DISABLED_): a no-resample timerange read +// through UDA must return both stored slices, matching every other backend's +// CapabilityMatrix.TimeRangeReadPositiveOrRefused/HDF5 contract. +TEST_F(UdaSliceAndTimeRange, DISABLED_TimeRangeReadWithoutResamplingThroughReopenSucceeds) { + void* buf = nullptr; + int size[MAXDIM] = {0}; + al_status_t s = timerange_read_without_resampling_via_uda(this, &buf, size); + AL_ASSERT_OK(s); + ASSERT_EQ(size[0], 2) << "no-resample timerange returns both stored slices"; + EXPECT_EQ(static_cast(buf)[0], kUdaTimeV0); + EXPECT_EQ(static_cast(buf)[1], kUdaTimeV1); + free(buf); +} + +// CURRENT-BEHAVIOR tripwire: today's read fails with the uninitialized- +// interpmode-derived "unknown interp mode" exception every time. When fixed, +// this test goes red, forcing whoever fixed it to enable the correct- +// contract test above. +TEST_F(UdaSliceAndTimeRange, TimeRangeReadWithoutResamplingCurrentlyFailsWithUnknownInterpMode) { + void* buf = nullptr; + int size[MAXDIM] = {0}; + al_status_t s = timerange_read_without_resampling_via_uda(this, &buf, size); + free(buf); + EXPECT_NE(s.code, 0) + << "a UDA time-range read now succeeds -- enable UdaSliceAndTimeRange." + "DISABLED_TimeRangeReadWithoutResamplingThroughReopenSucceeds " + "(OperationContext's TIMERANGE_OP constructor, src/al_context.cpp, " + "now initializes the base interpmode member)"; + EXPECT_NE(std::string(s.message).find("unknown interp mode"), + std::string::npos) + << "expected the documented uninitialized-interpmode failure signature, " + "got a different error instead: " << s.message; +} + +} // namespace + +#endif // AL_CONTRACT_HAVE_UDA diff --git a/tests/contract/test_uda_real_paths.cpp b/tests/contract/test_uda_real_paths.cpp new file mode 100644 index 00000000..20d647c7 --- /dev/null +++ b/tests/contract/test_uda_real_paths.cpp @@ -0,0 +1,367 @@ +// UDA real-DD-path datatype x rank breadth (issue #25, TRACEABILITY.md Part 5, +// acceptance criterion 1/2). +// +// This is the UDA analogue of test_mdsplus_real_paths.cpp (issue #15): the same +// curated real-DD-4.1.1 path set, now the shared real_dd::catalog() (issue +// #46). UDA hits the identical structural wall MDSplus does -- RoundTripMatrix's +// opaque synthetic paths have no node in the schema either backend validates +// against (MDSplus: a baked binary model tree; UDA: the DD XML walked at +// runtime, src/uda/uda_xml.cpp find_node()). See real_dd_path_catalog.h for the +// curation method. +// +// Shape adaptation for UDA (PRD #21 / issue #24): UDA has no remote write, so +// each cell is seed(HDF5)-then-reopen(UDA) instead of MDSplus's direct +// write->read against its own backend -- +// * seed: FORCE_CREATE_PULSE via the plain HDF5 backend, write +// ids_properties/homogeneous_time=1 (UDA's cache_mode=none read path +// always resolves it first and throws if absent -- see test_uda.cpp), +// then the leaf (descending the catalog aos_chain via +// al_begin_arraystruct_action, exactly like the MDSplus fixture); +// * reopen: OPEN_PULSE via the UDA backend in remote mode +// (backend=hdf5&cache_mode=none), descend the same aos_chain, read the +// leaf back, assert self-consistency against what was written. +// +// The path set is shared with MDSplus; only UDA's own verdicts stay here: +// kDivergences (CHAR r0's fixture-backend limitation) and kKnownDefects (the +// dynamic-leaf-inside-AOS defect pinned by UdaRealPathMatrixKnownDefects). +// +// Oracle: per-backend write->read self-consistency (same as MDSplus's version +// of this matrix) -- only the field *path* is real DD; the *content* stays +// opaque synthetic data (al_contract::synth_buffer). +#ifdef AL_CONTRACT_HAVE_UDA + +#include "al_contract.h" +#include "real_dd_path_catalog.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +using al_contract::real_dd::CatalogEntry; +using al_contract::real_dd::DType; + +namespace { + +// UDA's own verdict for a catalog cell, layered on the shared path. +// `divergence_reason` non-null: a legitimate storage/fixture-model difference, +// skipped (not asserted). `known_defect_reason` non-null: the genuine, +// newly-discovered defect pinned by UdaAosKnownDefects (test_uda_breadth.cpp) +// -- a *dynamic* (time-varying) leaf nested inside a struct_array silently +// comes back empty/absent through UDA remote mode + backend=hdf5. Distinct from +// a divergence: this is a real bug (xfail), skipped in the breadth matrix and +// pinned exactly by UdaRealPathMatrixKnownDefects's DISABLED_/tripwire pair. +struct UdaCell { + const CatalogEntry* entry; + const char* divergence_reason; + const char* known_defect_reason; +}; + +// UDA-specific verdicts keyed by (dtype, rank). Distinct from MDSplus's: +// the two backends diverge for different reasons on the same cell. +struct Verdict { + DType dt; + int rank; + const char* divergence_reason; + const char* known_defect_reason; +}; +const Verdict kVerdicts[] = { + // CHAR r0: seeding needs a dim=0 (bare scalar) CHAR write through the plain + // HDF5 backend used for fixture setup -- the pre-existing, independently- + // pinned HDF5 CHAR-scalar crash (RoundTripKnownDefects.DISABLED_ + // Hdf5CharScalarRoundTrips / tripwire …CurrentlyCrashes, TRACEABILITY.md + // Part 1), confirmed to reproduce on this real DD leaf (equilibrium's + // code/name, STR_0D) too. A fixture-backend defect, not a UDA behavior, so + // it is recorded as a divergence (not exercised) rather than run into the + // crash. + {DType::Char, 0, + "seeding this cell needs a dim=0 CHAR write through the plain HDF5 " + "backend (fixture setup) -- the pre-existing, independently-pinned " + "HDF5 CHAR-scalar crash (RoundTripKnownDefects.DISABLED_" + "Hdf5CharScalarRoundTrips / tripwire …CurrentlyCrashes, TRACEABILITY.md " + "Part 1) reproduces on this real DD leaf too; not run to avoid crashing " + "the seed step. A fixture-backend defect, not a UDA behavior", + nullptr}, + // The three dynamic-leaf-inside-AOS known defects (xfail): the field comes + // back empty/absent through UDA remote mode + backend=hdf5 instead of what + // was written. Pinned by UdaRealPathMatrixKnownDefects below. + {DType::Complex, 1, nullptr, + "dynamic leaf nested in a struct_array (3 AOS levels)"}, + {DType::Complex, 3, nullptr, + "dynamic leaf nested in a struct_array (1 AOS level)"}, + {DType::Complex, 5, nullptr, + "dynamic leaf nested in a struct_array (1 AOS level)"}, +}; + +const Verdict* verdict_for(DType dt, int rank) { + for (const Verdict& v : kVerdicts) { + if (v.dt == dt && v.rank == rank) return &v; + } + return nullptr; +} + +// Derive the UDA matrix 1:1 from the shared catalog: one cell per catalog +// entry, in catalog order, with UDA's local verdict attached. The +// CoversEveryCatalogKeyOnce meta-test pins that this stays a bijection. +const std::vector& uda_cells() { + static const std::vector kCells = [] { + std::vector cells; + for (const CatalogEntry& e : al_contract::real_dd::catalog()) { + const Verdict* v = verdict_for(e.dt, e.rank); + cells.push_back({&e, v ? v->divergence_reason : nullptr, + v ? v->known_defect_reason : nullptr}); + } + return cells; + }(); + return kCells; +} + +// The subset of cells carrying the dynamic-leaf-inside-AOS known defect, for +// the DISABLED_/tripwire pair below. Derived from the same uda_cells() so the +// matrix and the pins exercise identical IDS paths, ranks, and AOS nesting. +const std::vector& uda_known_defect_cells() { + static const std::vector kCells = [] { + std::vector cells; + for (const UdaCell& c : uda_cells()) { + if (c.known_defect_reason != nullptr) cells.push_back(c); + } + return cells; + }(); + return kCells; +} + +// --- write/read a leaf field at whatever context it lives in ---------------- +template +void write_leaf(int ctx, const char* leaf, const std::vector& shape, + const std::vector& data) { + AL_EXPECT_OK(al_contract::write_data(ctx, leaf, shape, data)); +} + +template +void read_leaf_and_expect(int ctx, const char* leaf, int rank, + const std::vector& shape, + const std::vector& written) { + std::vector read_shape; + std::vector read_data; + AL_EXPECT_OK(al_contract::read_data(ctx, leaf, rank, &read_shape, + &read_data)); + EXPECT_EQ(read_shape, shape) << "shape changed across the round trip"; + ASSERT_EQ(read_data.size(), written.size()) + << "element count changed across the round trip"; + EXPECT_EQ(read_data, written) << "data changed across the round trip"; +} + +// One real-path round trip: seed a fresh pulse dir through the plain HDF5 +// backend (descending spec.aos_chain, one al_begin_arraystruct_action per +// segment, a single element each, mirroring test_mdsplus_real_paths.cpp's +// run_cell), then reopen the identical pulse dir through the UDA backend in +// remote mode and read the same path back. +template +void run_cell_with_read_expectation(const CatalogEntry& spec, + ReadExpectation&& expect_read) { + al_contract::TempBase base; + const std::string pulse_dir = base.str() + "/pulse"; + std::error_code ec; + std::filesystem::create_directories(pulse_dir, ec); + + const std::vector shape = al_contract::shape_for_rank(spec.rank); + const std::vector written = + al_contract::synth_buffer(al_contract::element_count(shape)); + + // --- seed via the plain HDF5 backend (fixture setup) ------------------- + { + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(hdf5_uri.c_str(), + FORCE_CREATE_PULSE, &pulse_ctx)); + + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, spec.ids, "", WRITE_OP, &op)); + // UDA's cache_mode=none read path always resolves + // ids_properties/homogeneous_time first and throws if absent (see + // test_uda.cpp's characterization-discovered facts) -- seed it ahead + // of the field under test so the round trip exercises the leaf, not + // that precondition. + AL_EXPECT_OK(al_contract::write_data( + op, "ids_properties/homogeneous_time", {}, {1})); + + int ctx = op; + std::vector aos_ctxs; + for (const char* name : spec.aos_chain) { + int size = 1; + int aos = -1; + AL_EXPECT_OK(al_begin_arraystruct_action(ctx, name, "", &size, + &aos)); + aos_ctxs.push_back(aos); + ctx = aos; + } + + write_leaf(ctx, spec.leaf, shape, written); + + for (auto it = aos_ctxs.rbegin(); it != aos_ctxs.rend(); ++it) { + AL_EXPECT_OK(al_end_action(*it)); + } + AL_ASSERT_OK(al_end_action(op)); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + + // --- reopen through UDA, remote mode ------------------------------------ + { + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, + &pulse_ctx)); + + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, spec.ids, "", READ_OP, &op)); + + int ctx = op; + std::vector aos_ctxs; + for (const char* name : spec.aos_chain) { + int size = 0; + int aos = -1; + AL_EXPECT_OK(al_begin_arraystruct_action(ctx, name, "", &size, + &aos)); + EXPECT_EQ(size, 1) << "single-element AOS size must round-trip"; + aos_ctxs.push_back(aos); + ctx = aos; + } + + expect_read(ctx, spec, shape, written); + + for (auto it = aos_ctxs.rbegin(); it != aos_ctxs.rend(); ++it) { + AL_EXPECT_OK(al_end_action(*it)); + } + AL_ASSERT_OK(al_end_action(op)); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } +} + +template +void run_cell(const CatalogEntry& spec) { + run_cell_with_read_expectation( + spec, [](int ctx, const CatalogEntry& read_spec, + const std::vector& shape, + const std::vector& written) { + read_leaf_and_expect(ctx, read_spec.leaf, read_spec.rank, shape, + written); + }); +} + +class UdaRealPathMatrix : public ::testing::TestWithParam { +protected: + void SetUp() override { AL_CONTRACT_SKIP_IF_UDA_UNCONFIGURED(); } +}; + +TEST_P(UdaRealPathMatrix, RealPathRoundTrip) { + const UdaCell cell = GetParam(); + const CatalogEntry& spec = *cell.entry; + if (spec.terminal_gap_reason != nullptr) { + GTEST_SKIP() << "terminal-gap: " << spec.terminal_gap_reason; + } + if (cell.divergence_reason != nullptr) { + GTEST_SKIP() << "divergence: " << cell.divergence_reason; + } + if (cell.known_defect_reason != nullptr) { + GTEST_SKIP() << "known-defect (xfail): " << cell.known_defect_reason; + } + switch (spec.dt) { + case DType::Char: + run_cell(spec); + break; + case DType::Int: + run_cell(spec); + break; + case DType::Double: + run_cell(spec); + break; + case DType::Complex: + run_cell>(spec); + break; + } +} + +// A free function, not an inline lambda: a brace-init-list's top-level commas +// aren't paren-protected, so they would otherwise split this macro's argument +// list at the preprocessor stage (INSTANTIATE_TEST_SUITE_P only tracks paren +// nesting, not brace nesting). Case names come from the shared catalog so they +// stay stable across the #46 extraction (e.g. "COMPLEX_r1"). +std::string NameUdaRealPathMatrixCase( + const ::testing::TestParamInfo& info) { + return al_contract::real_dd::case_name(*info.param.entry); +} + +INSTANTIATE_TEST_SUITE_P(Uda, UdaRealPathMatrix, + ::testing::ValuesIn(uda_cells()), + NameUdaRealPathMatrixCase); + +// Meta-test (issue #46): the UDA matrix maps the shared catalog 1:1 -- one cell +// per catalog key, no duplicate, none dropped. A future refactor that filters +// cells out of uda_cells() turns this red instead of silently shrinking the +// matrix. +TEST(UdaRealPathCatalog, CoversEveryCatalogKeyOnce) { + using Key = std::pair; + std::set matrix_keys; + for (const UdaCell& cell : uda_cells()) { + ASSERT_NE(cell.entry, nullptr); + const Key k{static_cast(cell.entry->dt), cell.entry->rank}; + EXPECT_TRUE(matrix_keys.insert(k).second) + << "duplicate matrix key " + << al_contract::real_dd::case_name(*cell.entry); + } + std::set catalog_keys; + for (const CatalogEntry& e : al_contract::real_dd::catalog()) { + catalog_keys.insert({static_cast(e.dt), e.rank}); + } + EXPECT_EQ(matrix_keys, catalog_keys) + << "UDA matrix and catalog key sets diverge"; +} + +// Exact D2 pins for the COMPLEX matrix cells affected by the dynamic-leaf- +// inside-AOS defect. The disabled correct-contract test and enabled +// current-behavior tripwire deliberately share run_cell_with_read_expectation, +// so both traverse the same public C ABI and the same real DD path. A targeted +// fix to any one rank/path makes that case's tripwire fail independently. +class UdaRealPathMatrixKnownDefects + : public ::testing::TestWithParam { +protected: + void SetUp() override { AL_CONTRACT_SKIP_IF_UDA_UNCONFIGURED(); } +}; + +TEST_P(UdaRealPathMatrixKnownDefects, + DISABLED_DynamicComplexLeafInsideAosRoundTrips) { + run_cell>(*GetParam().entry); +} + +TEST_P(UdaRealPathMatrixKnownDefects, + DynamicComplexLeafInsideAosCurrentlyReadsEmpty) { + run_cell_with_read_expectation>( + *GetParam().entry, [](int ctx, const CatalogEntry& spec, + const std::vector&, + const std::vector>&) { + std::vector read_shape; + std::vector> read_data; + AL_EXPECT_OK(al_contract::read_data>( + ctx, spec.leaf, spec.rank, &read_shape, &read_data)); + EXPECT_TRUE(read_shape.empty() && read_data.empty()) + << "this exact COMPLEX r" << spec.rank + << " dynamic leaf now returns data through UDA remote mode -- " + "enable its paired DISABLED_DynamicComplexLeafInsideAosRoundTrips " + "case"; + }); +} + +INSTANTIATE_TEST_SUITE_P(Uda, UdaRealPathMatrixKnownDefects, + ::testing::ValuesIn(uda_known_defect_cells()), + NameUdaRealPathMatrixCase); + +} // namespace + +#endif // AL_CONTRACT_HAVE_UDA diff --git a/tests/contract/test_uda_unique_surface.cpp b/tests/contract/test_uda_unique_surface.cpp new file mode 100644 index 00000000..255c5fbd --- /dev/null +++ b/tests/contract/test_uda_unique_surface.cpp @@ -0,0 +1,1342 @@ +// UDA-unique surface (issues #26 + #27, TRACEABILITY.md Part 5): the whole of +// UDA's unique surface -- cache modes, runtime DD loading, datapath, +// capability negotiation (#26, areas 1-6), plus fetch mode and the +// write/delete pins (#27, areas 7-10): +// 1. URI option surface (plugin, init_args, dd_version, cache_mode invalid +// value, verbose, host/port). +// 2. Cache-mode invisibility (none/ids/struct identical reads; top-level +// struct-mode data-loss defect; cache cleared on close). +// 3. Runtime DD loading (IDSDef.xml present / absent / wrong-version). +// 4. datapath partial-get via cache_mode=ids -- the only living use of +// al_begin_global_action's datapath argument anywhere in the codebase +// (CLAUDE.md's stale "no backend uses it" claim, corrected alongside +// this test). +// 5. Version-drift check inertness (UDABackend::getVersion is a +// placeholder on both sides of al_lowlevel.cpp's comparison). +// 6. Server-version-gated supportsTimeRangeOperation() (> 1.4.0). +// 7. Fetch mode: download, local-backend handoff, cache reuse. +// 8. Fetch mode: local_cache URI option override. +// 9. Fetch mode: the stale-cache / write-divergence pin -- a fetch-mode +// write mutates only the local cache copy (there is no upload path), so +// the divergent copy keeps being served. +// 10. Remote-mode write and delete pinned unsupported (no writeData / +// deleteData handler on the reference server plugin). +// +// Same seed(HDF5)-then-reopen(UDA) shape as the rest of the tier (issue #24): +// the plain HDF5 backend seeds a pulse dir, the UDA backend in remote mode +// reopens the identical path and the test asserts what comes back through the +// public C ABI. Real DD-4.1.1 paths only (UDA validates every path against +// the schema it loads at startup, src/uda/uda_xml.cpp) -- see test_uda.cpp's +// characterization-discovered facts for the ids_properties/homogeneous_time +// precondition every remote read needs first. +#ifdef AL_CONTRACT_HAVE_UDA + +#include "al_contract.h" +#include "equilibrium_seed.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace { + +// RAII env-var save/restore -- several characterizations here (runtime DD +// loading's absent/wrong-version rows) must mutate process-global getenv() +// state for exactly one UDABackend construction and then put it back, +// because UDABackend::UDABackend loads IDSDef.xml unconditionally from +// $IDSDEF_PATH/$IMAS_PREFIX at construction time (src/uda/uda_xml.cpp +// load_xml()) -- there is no other seam to inject a different DD file. +class ScopedEnv { +public: + ScopedEnv(const char* name, const char* value) : name_(name) { + const char* prev = std::getenv(name); + had_prev_ = prev != nullptr; + if (had_prev_) prev_ = prev; + if (value) { + setenv(name, value, 1); + } else { + unsetenv(name); + } + } + ~ScopedEnv() { + if (had_prev_) { + setenv(name_.c_str(), prev_.c_str(), 1); + } else { + unsetenv(name_.c_str()); + } + } + ScopedEnv(const ScopedEnv&) = delete; + ScopedEnv& operator=(const ScopedEnv&) = delete; + +private: + std::string name_; + bool had_prev_ = false; + std::string prev_; +}; + +class UdaUniqueSurfaceTest : public ::testing::Test { +protected: + void SetUp() override { AL_CONTRACT_SKIP_IF_UDA_UNCONFIGURED(); } + + al_contract::TempBase base_; + + std::string fresh_pulse_dir() const { + const std::string dir = base_.str() + "/pulse"; + std::error_code ec; + std::filesystem::create_directories(dir, ec); + return dir; + } + + // Mirrors UDABackend::fetch_files' default cache-directory formula + // (src/uda/uda_backend.cpp:427-429) exactly, so tests can assert the + // fetch-mode cache landed where the spike (#23) characterized without a + // seam into backend internals: cache_path = $TMPDIR/uda-cache-of-$USER + // (or $TMPDIR/uda-cache if $USER is unset) joined with remote_path's + // relative_path() (an absolute path's relative_path() strips the leading + // "/", so this nests the whole pulse_dir under the cache root). + static std::string default_fetch_cache_dir(const std::string& pulse_dir) { + namespace fs = std::filesystem; + const char* user = std::getenv("USER"); + fs::path root = user && *user + ? fs::temp_directory_path() / ("uda-cache-of-" + std::string{user}) + : fs::temp_directory_path() / "uda-cache"; + return (root / fs::path{pulse_dir}.relative_path()).string(); + } + + // Seed the standard equilibrium precondition + // (ids_properties/homogeneous_time) plus one real DD scalar + // (vacuum_toroidal_field/r0) through the plain HDF5 backend. Returns the + // pulse dir. + std::string seed_scalar(const std::string& pulse_dir, double r0 = 6.2) const { + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + EXPECT_EQ(al_begin_dataentry_action(hdf5_uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx) + .code, + 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + WRITE_OP, &op) + .code, + 0); + EXPECT_EQ(al_contract::write_data( + op, "ids_properties/homogeneous_time", {}, {1}) + .code, + 0); + EXPECT_EQ(al_contract::write_data( + op, equilibrium_seed::kScalar, {}, {r0}) + .code, + 0); + EXPECT_EQ(al_end_action(op).code, 0); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + return pulse_dir; + } +}; + +// =========================================================================== +// Area 1: URI option surface. +// =========================================================================== + +// cache_mode: any value outside {none, ids, struct} throws immediately, purely +// client-side (UDABackend::process_options, src/uda/uda_backend.cpp:298-308) +// -- no network round trip is needed to observe this, but the fixture's skip +// discipline is kept uniform with the rest of the tier regardless. +TEST_F(UdaUniqueSurfaceTest, InvalidCacheModeThrows) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir()); + const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir); + // Replace the valid cache_mode=none with an invalid value. + std::string bad_uri = uri; + const auto pos = bad_uri.find("cache_mode=none"); + ASSERT_NE(pos, std::string::npos); + bad_uri.replace(pos, std::strlen("cache_mode=none"), "cache_mode=bogus"); + + int pulse_ctx = -1; + al_status_t s = al_begin_dataentry_action(bad_uri.c_str(), OPEN_PULSE, &pulse_ctx); + EXPECT_NE(s.code, 0) << "an invalid cache_mode value must be rejected"; + EXPECT_NE(std::string(s.message).find("invalid cache mode"), std::string::npos) + << "expected the documented rejection message, got: " << s.message; + if (s.code == 0) { + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } +} + +// verbose: "1"/"true" -> debug tracing on stdout; anything else (including +// absent) -> off (UDABackend::process_options). Characterized by capturing +// stdout around the open call rather than asserting on internal state. +TEST_F(UdaUniqueSurfaceTest, VerboseTrueEmitsDebugTracingOnStdout) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir()); + const std::string uri = + al_contract::uda_hdf5_uri_for(pulse_dir, "none", "verbose=true"); + + testing::internal::CaptureStdout(); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)); + const std::string captured = testing::internal::GetCapturedStdout(); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + + EXPECT_NE(captured.find("UDABackend openPulse"), std::string::npos) + << "verbose=true must emit UDABackend's debug tracing; captured: " + << captured; +} + +TEST_F(UdaUniqueSurfaceTest, VerboseAbsentEmitsNoDebugTracing) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir()); + const std::string uri = + al_contract::uda_hdf5_uri_for(pulse_dir); // no verbose= at all + + testing::internal::CaptureStdout(); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)); + const std::string captured = testing::internal::GetCapturedStdout(); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + + EXPECT_EQ(captured.find("UDABackend openPulse"), std::string::npos) + << "verbose unset must default to no debug tracing; captured: " + << captured; +} + +// plugin: the URI option that names which server-side plugin to talk to +// (default "IMAS", process_options + openPulse). A name the reference stack +// never registered fails at open -- the client's init directive +// ("::init(...)") reaches a real server that has no such plugin. +TEST_F(UdaUniqueSurfaceTest, PluginOptionNamingUnregisteredPluginFailsAtOpen) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir()); + const std::string uri = al_contract::uda_hdf5_uri_for( + pulse_dir, "none", "plugin=NoSuchImasPlugin"); + + int pulse_ctx = -1; + al_status_t s = al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx); + EXPECT_NE(s.code, 0) << "an unregistered plugin name must fail at open, " + "not silently fall back to the default"; + if (s.code == 0) { + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } +} + +// init_args: passed through to "::init()" with ';' rewritten to +// ',' (UDABackend::openPulse, src/uda/uda_backend.cpp:487-493). The reference +// IMAS server plugin's init() (imas_plugin.cpp: `plugin.init(plugin_interface)` +// inside handle_request, called unconditionally before dispatch) takes no +// arguments from the request at all -- it only tracks a per-connection _init +// bool. So init_args is accepted (never rejected) but has no observable +// effect on this reference plugin: a read with arbitrary init_args succeeds +// identically to one without. +TEST_F(UdaUniqueSurfaceTest, InitArgsAcceptedAndIgnoredByReferencePlugin) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2); + const std::string uri = al_contract::uda_hdf5_uri_for( + pulse_dir, "none", "init_args=some_key=1;other_key=2"); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op)); + std::vector shape; + std::vector data; + AL_EXPECT_OK(al_contract::read_data(op, equilibrium_seed::kScalar, 0, + &shape, &data)); + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], 6.2) << "arbitrary init_args must not change read " + "behavior against the reference plugin"; + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); +} + +// dd_version: an explicit override is forwarded verbatim as a query param on +// every directive the client builds (e.g. src/uda/uda_backend.cpp:657-658), +// but no backend on either side of the wire (client schema resolution is +// driven purely by the file loaded at $IDSDEF_PATH; the server's HDF5 backend +// is DD-agnostic) ever inspects that forwarded string. An arbitrary override +// value is therefore accepted and has no observable effect on a real-path +// read -- a distinct, narrower finding than area 3's DD *file* characterization +// below (this is about the per-request query option, not the client's loaded +// schema). +TEST_F(UdaUniqueSurfaceTest, DdVersionOverrideAcceptedWithNoObservableEffect) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2); + const std::string uri = al_contract::uda_hdf5_uri_for( + pulse_dir, "none", "dd_version=not.a.real.version"); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op)); + std::vector shape; + std::vector data; + AL_EXPECT_OK(al_contract::read_data(op, equilibrium_seed::kScalar, 0, + &shape, &data)); + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], 6.2) << "an arbitrary dd_version override must not " + "change read behavior -- no backend consults it"; + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); +} + +// host/port: parsed from the URI authority (UDABackend constructor + +// openPulse, both call uda::Client::setServerHostName/setServerPort when the +// authority carries a non-empty host / non-zero port). A host that resolves +// but a port nothing listens on fails to connect distinctly from every other +// failure mode characterized above (those are all post-connection request +// failures; this one never reaches the plugin dispatch at all). +TEST_F(UdaUniqueSurfaceTest, WrongPortFailsToConnectDistinctlyFromRequestFailures) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir()); + // Port 1 is reserved (tcpmux) and nothing UDA-shaped listens there. + const std::string uri = "imas://localhost:1/uda?backend=hdf5&cache_mode=none&path=" + + pulse_dir; + + int pulse_ctx = -1; + al_status_t s = al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx); + EXPECT_NE(s.code, 0) << "a port nothing listens on must fail at open"; + if (s.code == 0) { + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } +} + +// =========================================================================== +// Area 2: cache-mode invisibility. +// =========================================================================== +// The three cache modes are performance knobs and must not change physics +// values: none takes readData's direct per-field IMAS::get path; ids +// pre-fetches the whole (or datapath-scoped, area 4) IDS at beginAction time; +// struct pre-fetches each struct_array's leaves at its own +// begin_arraystruct_action. A field covered by all three (a static, +// non-dynamic leaf nested one level inside a struct_array, so struct mode's +// own population point is reached) does return the exact same value. The D2 +// pair below pins the defect for a top-level field that struct mode currently +// drops instead. +TEST_F(UdaUniqueSurfaceTest, CacheModeNoneIdsStructAgreeForAosCoveredField) { + const std::string pulse_dir = fresh_pulse_dir(); + const std::vector kShape = {2, 3, 2}; // shape_for_rank(3) + const std::vector kWritten = {1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12}; + + // --- seed temporary/constant_integer3d(1 element)/value via HDF5 ------- + { + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(hdf5_uri.c_str(), + FORCE_CREATE_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK( + al_begin_global_action(pulse_ctx, "temporary", "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data( + op, "ids_properties/homogeneous_time", {}, {1})); + int size = 1; + int aos = -1; + AL_EXPECT_OK( + al_begin_arraystruct_action(op, "constant_integer3d", "", &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, "value", kShape, kWritten)); + AL_EXPECT_OK(al_end_action(aos)); + AL_ASSERT_OK(al_end_action(op)); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + + // --- reopen through UDA under each cache mode --------------------------- + for (const char* mode : {"none", "ids", "struct"}) { + SCOPED_TRACE(mode); + const std::string uri = + al_contract::uda_hdf5_uri_for(pulse_dir, mode); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK( + al_begin_global_action(pulse_ctx, "temporary", "", READ_OP, &op)); + int rsize = 0; + int raos = -1; + AL_ASSERT_OK( + al_begin_arraystruct_action(op, "constant_integer3d", "", &rsize, &raos)); + EXPECT_EQ(rsize, 1); + std::vector shape; + std::vector data; + AL_EXPECT_OK(al_contract::read_data(raos, "value", 3, &shape, &data)); + EXPECT_EQ(data, kWritten) + << "cache_mode=" << mode << " must return the same value as every " + "other cache mode for a field its own " + "population point covers"; + AL_EXPECT_OK(al_end_action(raos)); + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } +} + +// NEW DEFECT (xfail): cache-mode invisibility currently does not extend to a +// top-level field under struct mode. The mode's implementation only populates +// its cache inside beginArraystructAction: +// UDABackend::beginAction only populates the cache for cache_mode=ids +// (src/uda/uda_backend.cpp:1016); struct mode populates lazily, per +// struct_array, only inside beginArraystructAction (cache_.count(path) miss -> +// populate_cache(ids, path, ...) scoped to that one struct_array's subtree, +// uda_backend.cpp:927-934). A plain top-level field that is never inside any +// struct_array the caller enters is therefore never populated under struct +// mode, and readData's fallthrough (`cache_.count(path)` miss, not one of the +// two homogeneous-time/version preconditions, cache_mode_ != None -> +// `return 0`, uda_backend.cpp:724-726) reports it as the ordinary absent-leaf +// contract. That loses a genuinely written physics value based solely on the +// cache-mode selection, violating issue #26's requirement that none/ids/ +// struct return identical data. + +// CORRECT-CONTRACT, expected-fail (DISABLED_): struct mode must return the +// same written value as none and ids for a top-level field. +TEST_F(UdaUniqueSurfaceTest, DISABLED_CacheModeStructPreservesTopLevelField) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2); + const std::string uri = + al_contract::uda_hdf5_uri_for(pulse_dir, "struct"); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op)); + std::vector shape; + std::vector data; + AL_EXPECT_OK(al_contract::read_data(op, equilibrium_seed::kScalar, + 0, &shape, &data)); + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], 6.2) + << "cache_mode=struct must preserve a written top-level physics value " + "exactly as cache_mode=none and cache_mode=ids do"; + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); +} + +// CURRENT-BEHAVIOR tripwire: struct mode reports success but returns the +// ordinary absent-leaf sentinel for the genuinely written top-level field. +TEST_F(UdaUniqueSurfaceTest, + CacheModeStructCurrentlyReturnsAbsentSentinelForTopLevelField) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2); + const std::string uri = + al_contract::uda_hdf5_uri_for(pulse_dir, "struct"); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op)); + std::vector shape; + std::vector data; + al_status_t s = al_contract::read_data(op, equilibrium_seed::kScalar, + 0, &shape, &data); + AL_EXPECT_OK(s) << "reading a top-level field under cache_mode=struct " + "reports success (the ordinary absent-leaf contract), " + "not an error"; + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], al_contract::kEmptyDouble) + << "struct mode now preserves the written value -- enable " + "DISABLED_CacheModeStructPreservesTopLevelField; until then its " + "per-struct_array population scope must keep reproducing this " + "known data-loss defect"; + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); +} + +// =========================================================================== +// NEW DEFECT (xfail): closing a UDA remote-mode session leaks a server-side +// pulse handle, so a subsequent LOCAL open of the identical on-disk file can +// block on HDF5's file lock. +// +// Root cause, confirmed empirically by tracing the exact directives exchanged +// (verbose=1): UDABackend::openPulse/closePulse both strip `cache_mode` and +// `verbose` before building the `uri=` argument they send to the server +// (src/uda/uda_backend.cpp, e.g. closePulse:557-564), so +// IMAS::open(uri='imas:hdf5?dd_version=4.1.1&path=P') and +// IMAS::close(uri='imas:hdf5?dd_version=4.1.1&path=P') use the identical +// key. But two of the *read*-triggering directive builders don't follow that +// convention: UDABackend::readData's cache_mode=None branch +// (uda_backend.cpp:654-659) and UDABackend::populate_cache +// (uda_backend.cpp:807-812, used by cache_mode=ids and struct alike) each +// build their own `IMAS::get(uri=...)` directive from the SAME query object +// but only ever remove "backend" -- never cache_mode/verbose -- so their +// `uri=` differs from the one `open`/`close` agreed on (confirmed via the +// verbose trace: `get(uri='imas:hdf5?...&cache_mode=none')` vs. +// `open(uri='imas:hdf5?...')`, no cache_mode at all). By contrast +// beginArraystructAction's own None-mode branch (uda_backend.cpp:954-961) +// does strip both, matching open/close -- so this is specifically a +// readData/populate_cache inconsistency, not a blanket "every get() path" +// one. The reference `IMAS` server plugin's own `get()` handler +// (source/imas/imas_plugin.cpp) treats an unrecognized `uri=` as a brand new +// pulse and implicit-opens it (`if (_open_entries.count(uri) == 0) { +// open(...); }`) -- creating a SECOND `_open_entries` record, with its own +// real `al_begin_dataentry_action` and therefore its own HDF5 file handle, +// keyed under a uri the client's subsequent `close()` directive can never +// match (it only ever sends the stripped key). That second handle is never +// closed: it leaks for the lifetime of the server's per-connection process. +// HDF5's default file locking then blocks a later LOCAL open of the +// identical on-disk file until that server process exits -- observed +// directly: after one full UDA seed(HDF5)-then-reopen(UDA)-then-close(UDA) +// cycle, on the exact same pulse_dir a plain `imas:hdf5?path=...` OPEN_PULSE +// in the *same test process* fails with "Unable to open HDF5 master file", +// even though the file is untouched and still on disk. Reproduces +// identically whether the read went through readData's None-mode "get" path +// or populate_cache's ids/struct-mode path (both hit the same unstripped-uri +// bug), so this is not a cache_mode=ids-specific finding despite living in +// the cache-mode area -- it's the actual mechanism behind the "cache cleared +// on close" question this area set out to characterize: the RAM cache_ *is* +// cleared (UDABackend::closePulse, uda_backend.cpp:554), but a server-side +// resource opened on the read path outlives it regardless. +// =========================================================================== + +// Runs one full seed(HDF5)-then-reopen(UDA)-then-close(UDA) cycle, then +// attempts a plain local HDF5 OPEN_PULSE against the identical pulse dir in +// this same process, returning that attempt's status -- shared by the +// correct-contract test and its tripwire below (mirrors UdaAosKnownDefects' +// pattern in test_uda_breadth.cpp). +al_status_t local_reopen_status_after_uda_session_closes(const char* cache_mode) { + al_contract::TempBase base; + const std::string pulse_dir = base.str() + "/pulse"; + std::error_code ec; + std::filesystem::create_directories(pulse_dir, ec); + + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + { + int pulse_ctx = -1; + EXPECT_EQ(al_begin_dataentry_action(hdf5_uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx) + .code, + 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + WRITE_OP, &op) + .code, + 0); + EXPECT_EQ(al_contract::write_data( + op, "ids_properties/homogeneous_time", {}, {1}) + .code, + 0); + EXPECT_EQ(al_contract::write_data( + op, equilibrium_seed::kScalar, {}, {6.2}) + .code, + 0); + EXPECT_EQ(al_end_action(op).code, 0); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + + { + const std::string uda_uri = + al_contract::uda_hdf5_uri_for(pulse_dir, cache_mode); + int pulse_ctx = -1; + EXPECT_EQ( + al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, &pulse_ctx).code, + 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op) + .code, + 0); + std::vector shape; + std::vector data; + EXPECT_EQ(al_contract::read_data(op, equilibrium_seed::kScalar, 0, + &shape, &data) + .code, + 0); + AL_EXPECT_OK(al_end_action(op)); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + + int pctx = -1; + return al_begin_dataentry_action(hdf5_uri.c_str(), OPEN_PULSE, &pctx); +} + +// CORRECT-CONTRACT, expected-fail (DISABLED_): closing a UDA session must +// release every server-side handle it opened, so a subsequent local open of +// the identical file must succeed immediately. +TEST_F(UdaUniqueSurfaceTest, DISABLED_ClosingUdaSessionReleasesEveryServerSideHandle) { + al_status_t s = local_reopen_status_after_uda_session_closes("none"); + AL_EXPECT_OK(s) << "a local HDF5 open of the same pulse dir must succeed " + "right after the UDA session that read it has closed"; +} + +// CURRENT-BEHAVIOR tripwire: the local reopen fails today, every time, +// because of the leaked server-side handle described above. +TEST_F(UdaUniqueSurfaceTest, ClosingUdaSessionCurrentlyLeaksServerSideHandle) { + al_status_t s = local_reopen_status_after_uda_session_closes("none"); + EXPECT_NE(s.code, 0) + << "a local reopen after a UDA session now succeeds -- enable " + "DISABLED_ClosingUdaSessionReleasesEveryServerSideHandle (the " + "get()-directive uri-stripping bug in readData/populate_cache/" + "beginArraystructAction, src/uda/uda_backend.cpp, is now fixed)"; + EXPECT_NE(std::string(s.message).find("Unable to open HDF5 master file"), + std::string::npos) + << "expected the documented HDF5-lock failure signature, got a " + "different error instead: " << s.message; +} + +// =========================================================================== +// Area 3: runtime DD loading (present / absent / wrong-version). +// +// UDABackend::UDABackend unconditionally calls imas::uda::load_xml() + +// get_dd_version() (src/uda/uda_xml.cpp), so every open pays this cost and +// every failure mode surfaces at al_begin_dataentry_action. +// =========================================================================== + +// Present (the default reference-stack configuration): already exercised by +// every other test in the tier; restated here as the baseline area-3 row. +TEST_F(UdaUniqueSurfaceTest, DdPresentLoadsAndOpenSucceeds) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir()); + const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); +} + +// Absent: neither $IDSDEF_PATH nor $IMAS_PREFIX set -> load_xml() throws +// before any network access at all (the constructor loads the DD before it +// ever touches the wire), surfaced through the C ABI as a non-zero +// al_status_t carrying the exact message. +TEST_F(UdaUniqueSurfaceTest, DdAbsentNeitherEnvVarSetFailsWithClearMessage) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir()); + const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir); + + ScopedEnv idsdef(/*name=*/"IDSDEF_PATH", /*value=*/nullptr); + ScopedEnv prefix(/*name=*/"IMAS_PREFIX", /*value=*/nullptr); + + int pulse_ctx = -1; + al_status_t s = al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx); + EXPECT_NE(s.code, 0) << "open must fail when neither DD env var is set"; + EXPECT_NE(std::string(s.message).find( + "neither IMAS_PREFIX or IDSDEF_PATH"), + std::string::npos) + << "expected the documented missing-env-var message, got: " << s.message; + if (s.code == 0) { + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } +} + +// Absent: $IDSDEF_PATH set but points at a file that does not exist -> +// load_xml()'s pugixml parse fails, distinct message from the unset-env case. +TEST_F(UdaUniqueSurfaceTest, DdAbsentFileMissingAtIdsDefPathFailsWithClearMessage) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir()); + const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir); + + ScopedEnv idsdef(/*name=*/"IDSDEF_PATH", + /*value=*/"/nonexistent/path/IDSDef.xml"); + + int pulse_ctx = -1; + al_status_t s = al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx); + EXPECT_NE(s.code, 0) << "open must fail when IDSDEF_PATH names a missing file"; + EXPECT_NE(std::string(s.message).find("IDSDef.xml not found"), std::string::npos) + << "expected the documented not-found message, got: " << s.message; + if (s.code == 0) { + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } +} + +// NEW DEFECT (xfail): $IDSDEF_PATH can point at a real, structurally valid, +// but wrong-version DD without the client rejecting it. The correct contract +// and current-behavior tripwire below use an older DD: +// *older* DD (3.42.0, IDSDEF_PATH_OLDER, docker/uda/Dockerfile) than the +// reference stack's own DD 4.1.1. load_xml()/get_dd_version() do not compare +// the loaded file's version against anything -- there is no cross-check +// against the actual data's DD version, the server's DD version, or any +// pinned expectation. The load succeeds silently, and a real DD path that +// exists identically in both schemas (vacuum_toroidal_field/r0, present at +// the same nesting in both 3.42.0 and 4.1.1) round-trips exactly as if the +// "correct" DD had been loaded: nothing signals that a version-drifted +// schema is in use. This is the DD-loading analogue of area 5's version-drift +// inertness below -- distinct mechanism (a schema file walked at runtime vs. +// a placeholder getVersion()), same practical consequence: nothing here would +// catch a genuine cross-version drift for a path that happens to be +// structurally stable, exactly the class of risk NORTH_STAR.md flags for the +// COCOS-sign-flip-bearing paths that are *not* stable across versions (a +// mismatch there would corrupt values silently rather than erroring, since +// this mechanism checks path existence only, never semantic version +// agreement). Skipped, not failed, if the second DD version was not baked +// into this reference image (docker/uda/Dockerfile). + +// CORRECT-CONTRACT, expected-fail (DISABLED_): opening data written against a +// different DD must be refused rather than silently treating path agreement +// as semantic version compatibility. +TEST_F(UdaUniqueSurfaceTest, DISABLED_DdWrongVersionIsRejected) { + const char* older_dd = std::getenv("IDSDEF_PATH_OLDER"); + if (!older_dd || !*older_dd) { + GTEST_SKIP() << "IDSDEF_PATH_OLDER is unset -- the reference image " + "does not carry a second DD version (docker/uda/" + "Dockerfile); this row is skipped, not failed."; + } + + const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2); + const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir); + + ScopedEnv idsdef(/*name=*/"IDSDEF_PATH", /*value=*/older_dd); + + int pulse_ctx = -1; + al_status_t s = al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx); + EXPECT_NE(s.code, 0) + << "a DD 3.42.0 client must refuse data served from the DD 4.1.1 " + "reference stack instead of silently assuming compatibility"; + if (s.code == 0) { + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } +} + +// CURRENT-BEHAVIOR tripwire: the wrong-version DD loads silently and a path +// stable across the two DD versions reads as if the versions agreed. +TEST_F(UdaUniqueSurfaceTest, + DdWrongVersionCurrentlyLoadsSilentlyWithNoCrossVersionCheck) { + const char* older_dd = std::getenv("IDSDEF_PATH_OLDER"); + if (!older_dd || !*older_dd) { + GTEST_SKIP() << "IDSDEF_PATH_OLDER is unset -- the reference image " + "does not carry a second DD version (docker/uda/" + "Dockerfile); this row is skipped, not failed."; + } + + const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2); + const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir); + + ScopedEnv idsdef(/*name=*/"IDSDEF_PATH", /*value=*/older_dd); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)) + << "loading a real but version-drifted DD file must not itself fail"; + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op)); + std::vector shape; + std::vector data; + AL_EXPECT_OK(al_contract::read_data(op, equilibrium_seed::kScalar, 0, + &shape, &data)) + << "a path stable across DD versions must still resolve and read " + "back correctly under the wrong-version schema"; + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], 6.2) + << "wrong-version loading is now rejected -- enable " + "DISABLED_DdWrongVersionIsRejected; until then this stable path " + "must keep exposing the silent cross-version acceptance defect"; + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); +} + +// =========================================================================== +// Area 4: datapath partial-get via cache_mode=ids. +// +// The only living use of al_begin_global_action's datapath argument +// (FUNCTIONALITY_INVENTORY.md's Cluster 2 entry, CLAUDE.md, both already +// correct on this point -- this test is the evidence that claim cites). +// UDABackend::beginAction (src/uda/uda_backend.cpp:1016-1032) uses +// getDatapath() to scope populate_cache()'s subtree fetch: with +// cache_mode=ids and a non-empty datapath, only paths under +// "/" are pre-fetched into the RAM cache. readData +// (src/uda/uda_backend.cpp:601-727) falls through to `return 0` (reported +// through the C ABI as the ordinary "absent leaf" contract, matching +// UdaBreadthTest.ReadOfUnwrittenLeafOnSeededIdsReturnsHdf5AbsentSentinel) for +// any field neither found in the cache nor one of the two homogeneous- +// time/version preconditions -- so a real, written field *outside* the +// datapath's scope reads back as if it had never been written at all, even +// though the plain HDF5 backend underneath genuinely has it. +// =========================================================================== +TEST_F(UdaUniqueSurfaceTest, DatapathScopesCachePopulationFieldOutsideScopeReadsAsAbsent) { + const std::string pulse_dir = fresh_pulse_dir(); + + // --- seed two real DD fields in different subtrees via HDF5 ------------- + { + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(hdf5_uri.c_str(), + FORCE_CREATE_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, + "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data( + op, "ids_properties/homogeneous_time", {}, {1})); + // In scope for datapath="vacuum_toroidal_field". + AL_EXPECT_OK(al_contract::write_data( + op, equilibrium_seed::kScalar, {}, {6.2})); + // Out of scope: a real DD-4.1.1 leaf under a sibling subtree. + AL_EXPECT_OK(al_contract::write_data(op, "code/output_flag", {}, + {42})); + AL_ASSERT_OK(al_end_action(op)); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + + // --- reopen through UDA, cache_mode=ids, datapath scoped to one subtree --- + const std::string uda_uri = + al_contract::uda_hdf5_uri_for(pulse_dir, "ids"); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, &pulse_ctx)); + + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, + /*datapath=*/"vacuum_toroidal_field", + READ_OP, &op)); + + // In-scope field: real data comes back. + { + std::vector shape; + std::vector data; + AL_EXPECT_OK(al_contract::read_data( + op, equilibrium_seed::kScalar, 0, &shape, &data)); + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], 6.2) + << "the field inside the datapath's own subtree must round-trip"; + } + + // Out-of-scope field: silently reads as absent, even though it was + // genuinely written and the plain HDF5 backend has it. + { + std::vector shape; + std::vector data; + al_status_t s = al_contract::read_data(op, "code/output_flag", + 0, &shape, &data); + AL_EXPECT_OK(s) << "reading outside the datapath's scope reports " + "success (the ordinary absent-leaf contract), not " + "an error"; + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], al_contract::kEmptyInt) + << "a real, written field outside the datapath's cache-populated " + "subtree reads back as absent through cache_mode=ids -- the " + "datapath scope genuinely hides data outside it, not just " + "prioritizes fetch order"; + } + + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); +} + +// =========================================================================== +// Area 5: version-drift check inertness (reworked by issue #39: the tripwire +// now uses a genuinely MISMATCHED stored fixture). +// +// al_begin_dataentry_action (src/al_lowlevel.cpp:868-883) compares +// backend->getVersion(NULL) (the "compiled" version) against +// backend->getVersion(pctx) (the "stored pulse" version) on +// OPEN_PULSE/FORCE_OPEN_PULSE, throwing on a mismatch -- the same mechanism +// MDSplus's version-drift row (TRACEABILITY.md Part 4, test_mdsplus_ +// version_drift.cpp) exercises with a genuine mismatch. For UDA +// (src/uda/uda_backend.cpp:277-290) both sides are placeholders: +// getVersion(NULL) returns the compiled {UDA_BACKEND_VERSION_MAJOR, +// UDA_BACKEND_VERSION_MINOR} = {0, 0} (uda_backend.h:37-38); getVersion(pctx) +// with a non-null ctx always returns {0, 0} too ("temporary placeholder", +// never reads anything from the actual remote pulse). The comparison is +// therefore always false -- the drift check can never fire for UDA, +// regardless of what is genuinely stored remotely. +// +// A fresh matching pulse cannot discriminate that inertness: a future correct +// implementation would ALSO accept it. The pins below therefore open a pulse +// whose stored HDF5_BACKEND_VERSION was deliberately rewritten to an +// impossible value ("999.0") by the isolated fixture producer +// hdf5_fixture_tool (issue #36) -- fixture preparation only; every asserted +// behavior stays on the public C ABI. +// +// Empirically characterized against the reference stack (issue #39): the +// mismatched pulse does NOT open -- but the refusal comes from the SERVER +// side, not the client's drift check. The server's IMAS plugin opens the +// pulse with its own IMAS-Core, whose HDF5BackendFactory rejects any stored +// version other than "1.0" (see test_hdf5_version_drift.cpp), and that error +// is forwarded to the client as UNKNOWN_ERR with the backend's "No backend +// writer with version: 999.0" text. The client-side check stays inert: it +// never produces its own LOWLEVEL_ERR "Compatibility ..." refusal. The +// active tripwire pins exactly that split; implementing real UDA +// stored-version retrieval flips the refusal to the client's own message and +// makes the DISABLED_ correct-contract test pass. +// =========================================================================== + +#ifdef AL_CONTRACT_HDF5_FIXTURE_TOOL +namespace { +// Seeds a pulse, then rewrites its stored backend version out-of-band. +std::string make_version_drifted_pulse(const std::string& pulse_dir) { + const std::string cmd = std::string("\"") + AL_CONTRACT_HDF5_FIXTURE_TOOL + + "\" \"" + pulse_dir + "\" \"999.0\""; + EXPECT_EQ(std::system(cmd.c_str()), 0) << "fixture tool failed: " << cmd; + return pulse_dir; +} +} // namespace + +// Baseline: a matching stored version opens through UDA. +TEST_F(UdaUniqueSurfaceTest, MatchingStoredVersionOpensThroughUda) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir()); + const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + al_status_t s = al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx); + AL_EXPECT_OK(s); + if (s.code == 0) { + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } +} + +// CORRECT-CONTRACT, expected-fail (DISABLED_): the CLIENT's own drift check +// must refuse the mismatched pulse -- LOWLEVEL_ERR with al_lowlevel.cpp's +// "Compatibility ..." diagnostic -- instead of comparing two hardcoded {0,0} +// placeholders and relying on a downstream server error to catch the drift. +TEST_F(UdaUniqueSurfaceTest, + DISABLED_OpenRefusesMismatchedStoredBackendVersion) { + const std::string pulse_dir = + make_version_drifted_pulse(seed_scalar(fresh_pulse_dir())); + const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + al_status_t s = al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx); + EXPECT_EQ(s.code, alerror::lowlevel_err) + << "the client-side drift check must refuse the mismatch itself; " + "message: " << s.message; + EXPECT_NE(std::string(s.message).find("Compatibility"), std::string::npos) + << "message: " << s.message; + if (s.code == 0) { + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } +} + +// CURRENT-BEHAVIOR tripwire: the mismatched pulse is refused, but by the +// SERVER-side HDF5 open (forwarded as UNKNOWN_ERR naming the stored version), +// never by the client's own inert drift check. If this starts failing, the +// refusal moved (or vanished) -- most likely real client-side stored-version +// retrieval was implemented: enable the DISABLED_ correct-contract test. +TEST_F(UdaUniqueSurfaceTest, + VersionDriftCheckCurrentlyDefersToServerSideRefusal) { + const std::string pulse_dir = + make_version_drifted_pulse(seed_scalar(fresh_pulse_dir())); + const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + al_status_t s = al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx); + EXPECT_EQ(s.code, alerror::unknown_err) + << "tripwire: today's refusal is the forwarded server-side backend " + "error, surfacing as UNKNOWN_ERR; message: " << s.message; + EXPECT_NE(std::string(s.message).find("No backend writer with version"), + std::string::npos) + << "the refusal must originate from the server-side HDF5 factory; " + "message: " << s.message; + EXPECT_NE(std::string(s.message).find("999.0"), std::string::npos) + << "message: " << s.message; + EXPECT_EQ(std::string(s.message).find("Compatibility"), std::string::npos) + << "tripwire: the client-side drift check must currently stay inert " + "(placeholder {0,0} comparison) -- if its own 'Compatibility' " + "refusal appears, enable " + "DISABLED_OpenRefusesMismatchedStoredBackendVersion; message: " + << s.message; + if (s.code == 0) { + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } +} +#endif // AL_CONTRACT_HDF5_FIXTURE_TOOL + +// =========================================================================== +// Area 6: server-version-gated supportsTimeRangeOperation(). +// +// UDABackend::supportsTimeRangeOperation() delegates to +// supportsTimeDataInterpolation() (uda_backend.h:194-196), which issues +// "::version()" to the server and returns `version > "1.4.0"_v` +// (src/uda/uda_backend.cpp:1189-1223, semver.hpp) -- the only capability flag +// in the codebase gated on a remote party's reported version rather than a +// compile-time constant. Against this reference stack the plugin reports +// 1.8.0 (docker/uda/README.md), so the capability is granted: an +// al_begin_timerange_action call must return its exact success status +// (`s.code == 0`) after passing al_lowlevel.cpp's capability gate +// (src/al_lowlevel.cpp:1053, "Selected backend does not support time range +// operations."). This is deliberately scoped to the *capability* boundary +// only -- the subsequent read on this +// same call already has its own pinned defect +// (UdaSliceAndTimeRange.TimeRangeReadWithoutResamplingCurrentlyFailsWithUnknownInterpMode, +// test_uda_breadth.cpp), which is a separate, uninitialized-interpmode bug +// unrelated to capability negotiation. +// =========================================================================== +TEST_F(UdaUniqueSurfaceTest, TimeRangeCapabilityGrantedByReferenceServerVersion180) { + const std::string pulse_dir = fresh_pulse_dir(); + { + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(hdf5_uri.c_str(), + FORCE_CREATE_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, + "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data( + op, "ids_properties/homogeneous_time", {}, {1})); + double t[2] = {1.0, 2.0}; + double v[2] = {10.0, 20.0}; + int shape[1] = {2}; + AL_EXPECT_OK(al_write_data(op, "time", "time", t, DOUBLE_DATA, 1, shape)); + AL_EXPECT_OK(al_write_data(op, "vacuum_toroidal_field/b0", "time", v, + DOUBLE_DATA, 1, shape)); + AL_ASSERT_OK(al_end_action(op)); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + + const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)); + + int op = -1; + double dtime = 0.0; + int dtime_shape = 0; + al_status_t s = al_begin_timerange_action(pulse_ctx, equilibrium_seed::kIds, + READ_OP, 1.0, 2.0, &dtime, + &dtime_shape, UNDEFINED_INTERP, &op); + EXPECT_EQ(s.code, 0) + << "the reference server (plugin 1.8.0 > 1.4.0) must pass the " + "time-range capability gate and successfully begin the action; " + "got code " << s.code << ", message: " << s.message; + if (s.code == 0) AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); +} + +// =========================================================================== +// Area 7: fetch mode -- download, local-backend handoff, cache reuse. +// +// UDABackend::UDABackend calls fetch_files(uri) unconditionally when +// fetch=1/true (src/uda/uda_backend.cpp:272-274), which lists the remote +// pulse's files via "::listFiles(...)" (dispatched to the reference +// server's BYTES plugin -- built and registered by ukaea/uda's own +// source/plugins/CMakeLists.txt by default, confirmed present in +// udaPlugins.conf on this reference image; the fetch-mode subtask's own +// server-side dependency, distinct from the IMAS plugin every other test in +// this tier uses), then for backend=hdf5 downloads only "master.h5" via +// "BYTES::read(...)" (fetch_files(backend), uda_backend.cpp:391-393). Once +// access_local_ is set, every subsequent Backend virtual (openPulse, +// readData, writeData, deleteData, closePulse, ...) delegates straight to a +// freshly constructed local backend pointed at the downloaded copy -- so a +// fetch-mode pulse behaves exactly like the plain local backend from here +// on, confirmed by reading back the seeded scalar unchanged. +// =========================================================================== +TEST_F(UdaUniqueSurfaceTest, FetchModeDownloadsHandsOffToLocalBackendAndReusesCacheOnReopen) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2); + const std::string cache_dir = default_fetch_cache_dir(pulse_dir); + // Guard against a stale cache dir from a prior interrupted run polluting + // this test's "must genuinely download" assertion. + std::error_code ec; + std::filesystem::remove_all(cache_dir, ec); + + const std::string uri = al_contract::uda_uri_base() + + "?fetch=1&backend=hdf5&path=" + pulse_dir + + "&verbose=1"; + + // --- first open: must genuinely download ------------------------------- + testing::internal::CaptureStdout(); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)); + std::string captured = testing::internal::GetCapturedStdout(); + + EXPECT_NE(captured.find("UDABackend creating local cache directory"), + std::string::npos) + << "first fetch-mode open must create a fresh local cache directory; " + "captured: " << captured; + EXPECT_NE(captured.find("UDABackend files downloaded to"), std::string::npos) + << "first fetch-mode open must report a genuine download; captured: " + << captured; + ASSERT_TRUE(std::filesystem::exists(cache_dir)) + << "the cache directory must be created at the location #23's spike " + "characterized (local_cache override or " + "$TMPDIR/uda-cache-of-$USER/): " << cache_dir; + ASSERT_TRUE(std::filesystem::exists(std::filesystem::path{cache_dir} / "master.h5")) + << "the downloaded HDF5 master file must exist in the cache dir"; + + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op)); + std::vector shape; + std::vector data; + AL_EXPECT_OK(al_contract::read_data(op, equilibrium_seed::kScalar, 0, + &shape, &data)); + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], 6.2) + << "seeded data must read back correctly once handed off to the " + "local backend"; + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + + // --- second open: must reuse the cache, not re-download ----------------- + testing::internal::CaptureStdout(); + pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)); + captured = testing::internal::GetCapturedStdout(); + + EXPECT_NE(captured.find("UDABackend cache directory already exists"), + std::string::npos) + << "a reopen must find the cache directory already present; " + "captured: " << captured; + EXPECT_NE(captured.find("UDABackend cached local file already exists"), + std::string::npos) + << "a reopen must not re-download master.h5 -- download_file's " + "early-exists-return must fire; captured: " << captured; + + op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op)); + shape.clear(); + data.clear(); + AL_EXPECT_OK(al_contract::read_data(op, equilibrium_seed::kScalar, 0, + &shape, &data)); + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], 6.2) << "the reused cache must still serve correct data"; + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + + std::filesystem::remove_all(cache_dir, ec); +} + +// =========================================================================== +// Area 8: fetch mode -- the local_cache URI option overrides the default +// cache-directory naming (fetch_files(const uri::Uri&), uda_backend.cpp:414, +// 428). Distinct from area 7: this characterizes the override itself, not +// the default-naming/reuse behavior. +// =========================================================================== +TEST_F(UdaUniqueSurfaceTest, FetchModeLocalCacheOptionOverridesDefaultCacheDir) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2); + const std::string explicit_cache_root = base_.str() + "/explicit-cache"; + // local_cache replaces only the cache *root*: fetch_files still joins it + // with remote_path_.relative_path() (uda_backend.cpp:428-429), so the + // download still lands nested under the given root, not at its top level. + const std::string expected_cache_dir = + (std::filesystem::path{explicit_cache_root} / + std::filesystem::path{pulse_dir}.relative_path()) + .string(); + + const std::string uri = al_contract::uda_uri_base() + + "?fetch=1&backend=hdf5&path=" + pulse_dir + + "&local_cache=" + explicit_cache_root; + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)); + + EXPECT_TRUE(std::filesystem::exists( + std::filesystem::path{expected_cache_dir} / "master.h5")) + << "an explicit local_cache overrides the cache *root* only -- the " + "remote path is still nested underneath it, same as the default " + "formula's own nesting (area 7): " << expected_cache_dir; + EXPECT_FALSE(std::filesystem::exists(default_fetch_cache_dir(pulse_dir))) + << "the default cache root must NOT be used when local_cache is given"; + + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op)); + std::vector shape; + std::vector data; + AL_EXPECT_OK(al_contract::read_data(op, equilibrium_seed::kScalar, 0, + &shape, &data)); + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], 6.2); + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); +} + +// =========================================================================== +// Area 9: the stale-cache / write-divergence pin -- the trap an HLI +// implementer could hit. UDABackend::writeData delegates straight to the +// local backend +// once access_local_ is set (uda_backend.cpp:1107-1109): there is no upload +// path back to the server at all. Combined with download_file's +// early-exists-return (area 7/8), a fetch-mode write "succeeds" but only ever +// lands on the local cache copy; because that copy is never re-downloaded, +// the divergent value keeps being served on every later fetch-mode open, +// while the server-side pulse (read through ordinary remote mode) never +// changes. Pinned end-to-end, not fixed. +// +// Writes a real DD-4.1.1 leaf ("code/output_flag") that was never seeded, not +// the already-seeded scalar: HDF5Writer::write_ND_Data unconditionally calls +// HDF5DataSetHandler::create for any dataset not already tracked in this +// session's `opened_data_sets` map (hdf5_writer.cpp:417-423, no H5Lexists +// check against the file) -- so a WRITE_OP that overwrites data seeded in a +// *prior* session hits "Unable to create HDF5 dataset" (H5Dcreate2 fails on +// an already-linked name). That is a genuine, separate HDF5-backend +// Limit on updating pre-existing data in a fresh session, orthogonal to +// UDA fetch mode -- exercising it here would conflate two different defects. +// Writing a brand-new field sidesteps it while still proving the same +// divergence: a "successful" fetch-mode write that never reaches the server. +// =========================================================================== +TEST_F(UdaUniqueSurfaceTest, FetchModeWriteDivergesFromServerAndStalePersistsAcrossReopen) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2); + const std::string cache_root = base_.str() + "/write-divergence-cache"; + const char* kNewField = "code/output_flag"; + + const std::string fetch_uri = al_contract::uda_uri_base() + + "?fetch=1&backend=hdf5&path=" + pulse_dir + + "&local_cache=" + cache_root; + + // --- write through fetch mode: lands only on the local cache copy ------ + { + int pulse_ctx = -1; + AL_ASSERT_OK( + al_begin_dataentry_action(fetch_uri.c_str(), OPEN_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, kNewField, {}, {42})) + << "a write through fetch mode must \"succeed\" (it lands on the " + "local cache copy) -- this is the trap, not a crash"; + AL_ASSERT_OK(al_end_action(op)); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + + // --- confirm the server-side pulse is unchanged (reopen via remote mode) --- + { + const std::string remote_uri = + al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK( + al_begin_dataentry_action(remote_uri.c_str(), OPEN_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op)); + std::vector shape; + std::vector data; + al_status_t s = al_contract::read_data(op, kNewField, 0, &shape, + &data); + AL_EXPECT_OK(s) << "reading an absent field reports success (the " + "ordinary absent-leaf contract), not an error"; + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], al_contract::kEmptyInt) + << "the fetch-mode write must never reach the server -- there is " + "no upload path (UDABackend::writeData delegates straight to " + "the local backend once access_local_ is set)"; + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } + + // --- the divergent local copy persists across close/reopen ------------- + { + int pulse_ctx = -1; + AL_ASSERT_OK( + al_begin_dataentry_action(fetch_uri.c_str(), OPEN_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op)); + std::vector shape; + std::vector data; + AL_EXPECT_OK(al_contract::read_data(op, kNewField, 0, &shape, &data)); + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], 42) + << "the divergent local copy must keep being served on later " + "opens -- download_file's early-exists-return never re-fetches " + "the genuine server-side (absent) value, so the divergence is " + "permanent until the cache dir is manually cleared"; + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } +} + +// =========================================================================== +// Area 10: remote write and delete pinned unsupported. Remote mode is +// read-only by construction: the reference server plugin dispatches no +// writeData/deleteData handler at all -- issue #23's spike confirmed this by +// issuing the exact directives via uda_cli, both returning "[handle_request]: +// Unknown function requested!". But the two C-ABI entry points surface that +// dispatch failure very differently, a divergence only visible by driving the +// actual client library calls (not the spike's uda_cli probing of the raw +// directive): +// - UDABackend::deleteData (uda_backend.cpp:1159-1186) issues +// "IMAS::deleteData(...)" via uda::Client::get(), whose error-checking +// path does surface the server's dispatch failure -- caught as a +// uda::UDAException, re-thrown as an ALException, reaching al_delete_data +// as a non-zero al_status_t carrying that exact text. This is the +// intended, correctly-refused contract -- covered, not a defect. +// - UDABackend::writeData (uda_backend.cpp:1104-1157) issues +// "IMAS::writeData(...)" via uda::Client::put() instead, which never +// throws on an in-band server dispatch failure (only on transport-level +// faults) -- so writeData returns normally and al_write_data reports +// al_status_t.code == 0. NEW DEFECT (xfail): the caller is told the write +// succeeded, with no signal at all that nothing was persisted +// server-side -- more dangerous than a clean refusal (confirmed by +// reopening via ordinary remote mode afterward and finding the scalar +// unchanged). The correct contract mirrors deleteData's: a remote write +// must refuse with the same dispatch-failure text, not report false +// success. +// =========================================================================== + +// Shared by the correct-contract test and its tripwire below (mirrors +// UdaAosKnownDefects' pattern in test_uda_breadth.cpp): writes through remote +// mode and returns the write's own al_status_t plus whether the value +// genuinely reached the server (reopened via a fresh remote-mode session). +al_status_t remote_write_status_and_confirm_unpersisted( + const std::string& pulse_dir, bool* reached_server) { + const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir); + + al_status_t write_status; + { + int pulse_ctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx) + .code, + 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + WRITE_OP, &op) + .code, + 0); + write_status = al_contract::write_data( + op, equilibrium_seed::kScalar, {}, {9.9}); + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } + + int pulse_ctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx).code, + 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op) + .code, + 0); + std::vector shape; + std::vector data; + EXPECT_EQ(al_contract::read_data(op, equilibrium_seed::kScalar, 0, + &shape, &data) + .code, + 0); + *reached_server = (data.size() == 1u && data[0] == 9.9); + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + + return write_status; +} + +// CORRECT-CONTRACT, expected-fail (DISABLED_): a remote write must be refused +// with the same dispatch-failure text deleteData surfaces, not silently +// report success. +TEST_F(UdaUniqueSurfaceTest, DISABLED_RemoteWriteFailsWithDispatchError) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2); + bool reached_server = false; + al_status_t s = remote_write_status_and_confirm_unpersisted( + pulse_dir, &reached_server); + EXPECT_NE(s.code, 0) << "remote mode must refuse a write -- there is no " + "writeData handler on the reference server plugin"; + EXPECT_NE(std::string(s.message).find("Unknown function requested"), + std::string::npos) + << "expected the server's exact dispatch-failure text, got: " + << s.message; +} + +// CURRENT-BEHAVIOR tripwire: al_write_data reports success (code == 0) every +// time, even though the value never reaches the server. +TEST_F(UdaUniqueSurfaceTest, RemoteWriteCurrentlyReportsSuccessButNeverPersists) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2); + bool reached_server = false; + al_status_t s = remote_write_status_and_confirm_unpersisted( + pulse_dir, &reached_server); + EXPECT_EQ(s.code, 0) + << "a remote write now fails -- enable " + "DISABLED_RemoteWriteFailsWithDispatchError (uda::Client::put()'s " + "error path in UDABackend::writeData, src/uda/uda_backend.cpp, now " + "surfaces the server's dispatch failure); got: " << s.message; + EXPECT_FALSE(reached_server) + << "the falsely-successful write must never actually reach the " + "server -- remote mode is read-only by construction regardless of " + "what al_write_data reported"; +} + +TEST_F(UdaUniqueSurfaceTest, RemoteDeleteFailsWithUnknownFunctionRequested) { + const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2); + const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, "", + WRITE_OP, &op)); + + al_status_t s = al_delete_data(op, equilibrium_seed::kScalar); + EXPECT_NE(s.code, 0) << "remote mode must refuse a delete -- there is no " + "deleteData handler on the reference server plugin"; + EXPECT_NE(std::string(s.message).find("Unknown function requested"), + std::string::npos) + << "expected the server's exact dispatch-failure text, got: " + << s.message; + + AL_EXPECT_OK(al_end_action(op)); + AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); +} + +} // namespace + +#endif // AL_CONTRACT_HAVE_UDA diff --git a/tests/contract/test_version_sentinel.cpp b/tests/contract/test_version_sentinel.cpp new file mode 100644 index 00000000..b13c3dda --- /dev/null +++ b/tests/contract/test_version_sentinel.cpp @@ -0,0 +1,112 @@ +// Version sentinel contract test — the tripwire for accidental DD-awareness. +// +// TEST_STRATEGY.md decision D5 + issue #5: the core must treat +// `ids_properties/version_put/data_dictionary` as ordinary opaque string data. +// It is the field the HLI uses to record which DD version wrote the data, but +// per CLAUDE.md the core interprets no DD version anywhere — it stores what it +// is given and returns it verbatim. This test pins that: a write→read of an +// obviously-fake version string comes back byte-for-byte, AND getDDVersion() +// stays the deprecated sentinel, so any future move to version-awareness shows +// up here as a deliberate, visible change rather than an accident (this is the +// current-behavior test that Task 3's version-negotiation work will watch). +// +// Runs on Memory (hermetic, unit-ish) and HDF5 (the real on-disk path): the +// non-interpretation property is backend-independent, so proving it on both +// rules out a backend quietly parsing the field. + +#include "al_contract.h" // pulls in al_lowlevel.h + al_const.h transitively + +#include + +#include + +using al_contract::BackendCase; +using al_contract::PulseId; + +namespace { + +// The DD path the HLI writes the writer's DD version into. Opaque to the core. +constexpr const char* kIds = "magnetics"; +constexpr const char* kVersionPutPath = + "ids_properties/version_put/data_dictionary"; + +// Deliberately NOT a real DD version and NOT the deprecated sentinel: if the +// core ever interpreted this field, storing this value would either be rejected +// or would perturb getDDVersion(). Neither may happen. +constexpr const char* kFakeVersion = "9.9.9-sentinel-not-a-real-dd"; + +// The compile-time value getDDVersion() must keep returning regardless of what +// version string is stored (include/al_defs.h.in:57; test_introspection.cpp +// cross-checks it against the DD_VERSION macro). +constexpr const char* kDeprecatedSentinel = "!!DEPRECATED!!"; + +class VersionSentinel : public ::testing::TestWithParam { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/13, /*run=*/0}; +}; + +TEST_P(VersionSentinel, VersionPutRoundTripsOpaquelyAndIsNotInterpreted) { + const BackendCase backend = GetParam(); + if (backend.on_disk) { + base_.make_legacy_tree(pulse_); + } + + // Baseline: the sentinel before any data is written. + const std::string dd_before = getDDVersion(); + EXPECT_EQ(dd_before, kDeprecatedSentinel); + + const std::string uri = + al_contract::build_uri(backend.id, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + + // --- write the fake version into version_put/data_dictionary --- + { + int op_ctx = -1; + AL_ASSERT_OK( + al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, &op_ctx)); + AL_EXPECT_OK( + al_contract::write_char_array(op_ctx, kVersionPutPath, kFakeVersion)); + AL_ASSERT_OK(al_end_action(op_ctx)); + } + + // Writing a DD-version string must not have taught the core a DD version: + // getDDVersion() is invariant under stored data. Compared to the captured + // baseline (not the literal sentinel) so this asserts *non-interpretation* + // itself — the specific "!!DEPRECATED!!" value is pinned by + // test_introspection.cpp::GetDDVersionReturnsDeprecatedSentinel. + EXPECT_EQ(std::string(getDDVersion()), dd_before) + << "the core must not adopt a stored version_put value as its DD version"; + + // --- read it back; it must be the exact bytes we wrote (opaque) --- + { + int op_ctx = -1; + AL_ASSERT_OK( + al_begin_global_action(pulse_ctx, kIds, "", READ_OP, &op_ctx)); + std::string read_back; + AL_EXPECT_OK( + al_contract::read_char_array(op_ctx, kVersionPutPath, &read_back)); + EXPECT_EQ(read_back, kFakeVersion) + << "version_put/data_dictionary must round-trip verbatim, unparsed"; + AL_ASSERT_OK(al_end_action(op_ctx)); + } + + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + + // Still unchanged after a full write→read cycle. + EXPECT_EQ(std::string(getDDVersion()), dd_before); +} + +INSTANTIATE_TEST_SUITE_P( + Backends, VersionSentinel, + ::testing::Values(BackendCase{HDF5_BACKEND, "HDF5", /*on_disk=*/true}, + BackendCase{MEMORY_BACKEND, "Memory", /*on_disk=*/false}), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.name); + }); + +} // namespace diff --git a/tests/testlowlevel.cpp b/tests/testlowlevel.cpp index 2ffea62d..d2b8e1d8 100644 --- a/tests/testlowlevel.cpp +++ b/tests/testlowlevel.cpp @@ -259,11 +259,11 @@ int main(int argc, char *argv[]) { printf("Pulse number:\t%d\n", iPulse); printf("Run number:\t%d\n", iRun); - printf("Backend:\t%s\n", alconst::backend_id_str[iBackend - BACKEND_ID_0].c_str()); + printf("Backend:\t%d\n", iBackend); printf("User name:\t%s\n", szUser); printf("Machine:\t%s\n", szTokamak); - printf("Open action:\t%s\n", alconst::access_pulse_str[iOpenAction - ACCESS_PULSE_0].c_str()); - printf("Close action:\t%s\n", alconst::access_pulse_str[iCloseAction - ACCESS_PULSE_0].c_str()); + printf("Open action:\t%d\n", iOpenAction); + printf("Close action:\t%d\n", iCloseAction); printf("Parameters:\t%s\n", szParams); // Low Level @@ -309,7 +309,7 @@ int main(int argc, char *argv[]) printf("Writing data...\n"); iGetOpCtx = -1; - alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, WRITE_OP, &iGetOpCtx); + alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, "", WRITE_OP, &iGetOpCtx); if (alStatus.code != 0) { printf("Error opening imas %s for writing: al_begin_global_action = %s\n", szIdsFullName, alStatus.message); @@ -352,7 +352,7 @@ int main(int argc, char *argv[]) printf("Reading data...\n"); iGetOpCtx = -1; - alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, READ_OP, &iGetOpCtx); + alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, "", READ_OP, &iGetOpCtx); if (alStatus.code != 0) { printf("Error opening imas %s for reading: al_begin_global_action = %s\n", szIdsFullName, alStatus.message); diff --git a/tests/testlowlevel_c.c b/tests/testlowlevel_c.c index 3736c1b1..ab3801d5 100644 --- a/tests/testlowlevel_c.c +++ b/tests/testlowlevel_c.c @@ -276,7 +276,7 @@ int main(int argc, char *argv[]) printf("Writing data...\n"); iGetOpCtx = -1; - alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, WRITE_OP, &iGetOpCtx); + alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, "", WRITE_OP, &iGetOpCtx); if (alStatus.code != 0) { printf("Error opening imas %s for writing: al_begin_global_action = %s\n", szIdsFullName, alStatus.message); @@ -319,7 +319,7 @@ int main(int argc, char *argv[]) printf("Reading data...\n"); iGetOpCtx = -1; - alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, READ_OP, &iGetOpCtx); + alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, "", READ_OP, &iGetOpCtx); if (alStatus.code != 0) { printf("Error opening imas %s for reading: al_begin_global_action = %s\n", szIdsFullName, alStatus.message);