diff --git a/.github/workflows/uda-contract.yml b/.github/workflows/uda-contract.yml new file mode 100644 index 00000000..adfbc9d2 --- /dev/null +++ b/.github/workflows/uda-contract.yml @@ -0,0 +1,86 @@ +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." + + - name: Run the UDA contract tier + run: > + docker run --rm --platform linux/arm64 + -v "$PWD":/workspace -w /workspace + "${IMAGE_TAG}" uda-run.sh + + - name: Note + if: always() + 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/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/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..baba1493 --- /dev/null +++ b/docker/uda/run.sh @@ -0,0 +1,113 @@ +#!/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. +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 + break + fi + sleep 0.5 +done +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. +UDA_HOST=localhost UDA_PORT=56565 "${PREFIX}/bin/uda_cli" \ + --host localhost --port 56565 --request "help::help()" >/dev/null 2>&1 \ + && echo "uda_cli help::help() OK" || echo "WARN: uda_cli ping failed (continuing)" + +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" +ctest -L uda --output-on-failure diff --git a/test b/test new file mode 100755 index 00000000..39e43a64 --- /dev/null +++ b/test @@ -0,0 +1,165 @@ +#!/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. +# +# 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" + # No -G so an existing build/ keeps its generator; a fresh one takes CMake's + # default. getconf is portable across macOS and Linux. The && chain returns + # non-zero on the first failing step (configure/build/ctest). + local jobs; jobs=$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4) + cmake -S "$REPO_ROOT" -B "$REPO_ROOT/build" \ + -DAL_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Release \ + && cmake --build "$REPO_ROOT/build" -j"$jobs" \ + && ctest --test-dir "$REPO_ROOT/build" --output-on-failure +} + + +# --- 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" + docker run --rm --platform "$PLATFORM" \ + -v "$REPO_ROOT":/workspace -w /workspace \ + -e MDSPLUS_MODELS_PATH=/workspace/build-mdsplus/models/mdsplus \ + 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)" && + cd build-mdsplus/tests/contract && ctest -L mdsplus --output-on-failure + ' +} + + +# --- 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/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 785c7d68..a9ab5415 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -74,6 +74,10 @@ add_executable(contract_tests test_mdsplus_real_paths.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 GTest::gtest GTest::gtest_main) @@ -105,6 +109,23 @@ if(AL_BACKEND_MDSPLUS) target_link_libraries(contract_tests PRIVATE ${MDSPLUS_LIBRARIES}) 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). # @@ -219,11 +240,28 @@ if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") TEST_FILTER "Introspection.*:*Memory*:KnownDefects*:Plugin*:ReadbackPlugins.*:UriOwnership.*" PROPERTIES LABELS "contract\;unit") gtest_discover_tests(contract_tests - TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:ReadbackPlugins.*:UriOwnership.*:MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*:MdsplusStructAwareSliceInterpolation*:MdsplusTimerangeResampling*:MdsplusSegmentBackedWrites*:MdsplusSliceWriteKnownDefects*:MdsplusSliceWriteKnownDefectsDeath*:MdsplusTimebaseCache*" + TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:ReadbackPlugins.*:UriOwnership.*:MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*:MdsplusStructAwareSliceInterpolation*:MdsplusTimerangeResampling*:MdsplusSegmentBackedWrites*:MdsplusSliceWriteKnownDefects*:MdsplusSliceWriteKnownDefectsDeath*:MdsplusTimebaseCache*:UdaSmokeRoundTrip*:UdaEquilibriumSeedParity*:Uda/UdaRealPathMatrix.*:Uda/UdaRealPathMatrixKnownDefects.*:UdaBreadthTest*:UdaAosKnownDefects*:UdaSliceAndTimeRange*:UdaUniqueSurfaceTest*" PROPERTIES LABELS "contract\;integration") gtest_discover_tests(contract_tests TEST_FILTER "MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*:MdsplusStructAwareSliceInterpolation*:MdsplusTimerangeResampling*:MdsplusSegmentBackedWrites*:MdsplusSliceWriteKnownDefects*:MdsplusSliceWriteKnownDefectsDeath*:MdsplusTimebaseCache*" PROPERTIES LABELS "mdsplus") + # Issue #23: the UDA tier gets its own single-element "uda" label pass (same + # sidestep of the multi-label-drop defect the mdsplus pass uses), carved out + # of the integration pass above, so `ctest -L uda` selects exactly the UDA + # characterization suite without pulling in every other on-disk backend case. + # Issue #24 joins the read-only equilibrium-seed parity fixture + # (UdaEquilibriumSeedParity*) to the same pass. Issue #25 (parity breadth) + # joins the real-DD-path datatype x rank matrix (test_uda_real_paths.cpp, + # Uda/UdaRealPathMatrix.*) and the AOS/occurrences/list_filled_paths/pulse- + # modes/errors/slice/timerange breadth suite (test_uda_breadth.cpp, + # UdaBreadthTest.*, UdaSliceAndTimeRange.*). The exact COMPLEX r1/r3/r5 + # known-defect pairs live in Uda/UdaRealPathMatrixKnownDefects.*. + # Issue #26 (unique-surface, first half) joins UdaUniqueSurfaceTest* to the + # same pass: cache modes, runtime DD loading, datapath, and capability + # negotiation (test_uda_unique_surface.cpp). + gtest_discover_tests(contract_tests + TEST_FILTER "UdaSmokeRoundTrip*:UdaEquilibriumSeedParity*:Uda/UdaRealPathMatrix.*:Uda/UdaRealPathMatrixKnownDefects.*:UdaBreadthTest*:UdaAosKnownDefects*:UdaSliceAndTimeRange*:UdaUniqueSurfaceTest*" + PROPERTIES LABELS "uda") else() gtest_discover_tests(contract_tests PROPERTIES LABELS "contract") diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 522de18e..0a8c076e 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -93,10 +93,10 @@ unchanged). Issue #8 (`6-ownership-sweep.md`) closed every remaining row: | 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 | covered | +| `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 | 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` | **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** | @@ -115,25 +115,25 @@ 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 | 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) | covered | +| `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 | 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 | covered | +| `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 | covered | +| `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`, `RoundTripKnownDefectsDeath.Ascii{Double,Complex}MaxdimCurrentlyCorrupts` (death-test children: corruption *or* crash keeps them green — the overrun's manifestation is machine-dependent) | **xfail** | -| `al_delete_data` at signal / structure / DATAOBJECT-root granularity | :265-272 | ✓ (Memory) | ✓ (HDF5) | Support is disjoint per backend, none implement all three (see xfail rows below): `Backends/DeleteMatrix.{LeafDeleteRemovesJustTheLeaf,StructureDeleteRemovesWholeSubtree}/Memory` (leaf + structure genuinely work only on Memory), `…/RootDeleteRemovesWholeOccurrence/HDF5` (root genuinely works only on HDF5, which ignores `path` and always removes the whole occurrence). MDSplus: not joined to this fixture — terminal gap, see Part 4 (issue #18) | covered | +| `al_delete_data` at signal / structure / DATAOBJECT-root granularity | :265-272 | ✓ (Memory) | ✓ (HDF5) | Support is disjoint per backend, none implement all three (see xfail rows below): `Backends/DeleteMatrix.{LeafDeleteRemovesJustTheLeaf,StructureDeleteRemovesWholeSubtree}/Memory` (leaf + structure genuinely work only on Memory), `…/RootDeleteRemovesWholeOccurrence/HDF5` (root genuinely works only on HDF5, which ignores `path` and always removes the whole occurrence). 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 ignores `path` for leaf/structure delete — **any delete call removes the whole occurrence**, not just the named field | :265-272; src/hdf5/hdf5_writer.cpp deleteData (no path parameter at all) | — | ✓ | One tripwire covers both defective granularities, since there is no leaf-vs-structure branch to differ (`HDF5Writer::deleteData` never receives a path at all): `DeleteKnownDefects.DISABLED_Hdf5LeafDeleteLeavesSiblingIntact` + tripwire `DeleteKnownDefects.Hdf5LeafDeleteCurrentlyWipesWholeOccurrence`; matrix cells skip via `Backends/DeleteMatrix.{Leaf,Structure}*/HDF5` | **xfail** | | ↳ 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`) | 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 | covered | +| `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) @@ -145,7 +145,7 @@ 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}`. MDSplus is instantiated too, but a divergence (the seed's generic shapes don't match the real equilibrium DD layout) — see Part 4 | covered | +| Realistic AOS/timebase/HDF5-tensorization shape round-trips exactly | D5 | ✓ (Memory) | ✓ (HDF5, ASCII) | `Backends/EquilibriumSeedMatrix.RoundTripHashMatches/{HDF5,Memory,ASCII}`. MDSplus is instantiated too, but a divergence (the seed's generic shapes don't match the real equilibrium DD layout) — see Part 4. UDA: same divergence, same root cause (the DD-schema-validating backend has no counterpart for the seed's synthetic shapes) — see Part 5 | covered | ## Cluster 3 — Plugin management (`FUNCTIONALITY_INVENTORY.md:318-430`) @@ -222,11 +222,11 @@ and points at the same test. | **B** `getVersion` (installed vs stored, drift check) | :520-540 | — | — | Reachable in principle: `al_begin_dataentry_action`/`al_plugin_begin_global_action`/`al_plugin_begin_slice_action` all compare `backend->getVersion(NULL)` (compiled) against `backend->getVersion(pctx)` (stored) on open/write and throw `LOWLEVEL_ERR` on a mismatch (src/al_lowlevel.cpp:868-883,956-967,1011-1022). Forcing the mismatch needs writing a pulse then overwriting its on-disk HDF5 backend-version attribute directly via the HDF5 C API — out of this issue's scope (no HDF5 include/link wiring in the test target). **Terminal gap**, not deferred to a future step: reachable via the C ABI, but pinning it needs infrastructure this suite does not have. **MDSplus: this gap is closed** — see Part 4's version-drift check (issue #16), which forces the mismatch via the compile-guarded tier's own raw MDSplus C++ API dependency. This row (HDF5) is unchanged — the two backends hit the identical shape of problem, but only MDSplus's already-separate, already-gated tier made the raw-API workaround low-blast-radius | gap | | **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` | covered | -| **C** `deleteData` | :591-599 | ✓ (Memory) | ✓ (HDF5) | via `al_delete_data` — see Cluster 2 row above and its three xfail rows. MDSplus: terminal gap — see Part 4 (issue #18) | 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 | covered (indirect) | -| **D** `get_occurrences` | :612-616 | — | ✓ | via `al_get_occurrences` — `Occurrences.*`. MDSplus: see Part 4 | 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 | covered | +| **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 three xfail rows. 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 | @@ -394,7 +394,7 @@ explicit open items), not assumed: (`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 limitation. + general MDSplus AOS Limit. | Cluster / Capability | Test(s) | Status | |---|---|---| @@ -586,3 +586,471 @@ stays a `gap` for HDF5 (Part 2 row B). |---|---|---| | 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 (scalar + timebase-carrying 2-D array + constraints AOS, `equilibrium_seed.h`, issue #4/D5), seeded through HDF5 and reopened through UDA, asserted via the seed's own unmodified FNV-1a hash oracle — **divergence, not a defect**: the generator's flat `profiles_1d/psi` leaf and generic `constraints` AOS have no counterpart in equilibrium's real DD-4.1.1 layout (`profiles_1d` is itself a struct_array, not a plain field). Confirmed empirically against the reference stack: `al_plugin_read_data` fails immediately with "cannot find node equilibrium/profiles_1d/psi in data dictionary (profiles_1d not found)". The same wall MDSplus hits on its own `EquilibriumSeedMatrix`/`Mdsplus` row (Part 4) — nothing to fix, the fixture's synthetic composite shape simply cannot transfer to a DD-schema-validating backend; the scalar sub-shape alone is real DD and is already covered by the tracer bullet above. The seed-then-reopen attempt genuinely runs every time (unlike EquilibriumSeedMatrix's per-backend skip, this suite has no other instance to keep it exercised): the test asserts the *specific* failure signature before `GTEST_SKIP()`'ing, so a future change that resolves the divergence fails the assertion loudly instead of this row silently going stale | `UdaEquilibriumSeedParity.HdfSeededReadsBackThroughUda` | divergence | + +**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 +reused as-is** (same 32 cells, same 10 IDSs), adapted to seed(HDF5)-then- +reopen(UDA) instead of MDSplus's direct write→read. `terminal_gap_reason` +carries over unchanged (a DD-4.1.1 fact, independent of backend); a UDA-only +`divergence_reason` and `known_defect_reason` were added where the two +backends diverge for different reasons on the same 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 pin and AOS-mechanism proof, + not a substitute for these exact matrix cases. + +| 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 (issue #25) + +`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) hits the same dynamic-leaf-inside-AOS defect + described above**: `equilibrium/time_slice` (a real DD struct_array, + `timebasepath="time"`) correctly reports its written size (3) through + `al_begin_arraystruct_action`, and iteration genuinely advances (the request + trace shows three distinct `time_slice[0..2]/global_quantities/ip` + directives), but every element's `global_quantities/ip` value comes back as + the HDF5 absent-scalar sentinel (`-9.0e40`) instead of what was written. + Pinned as the canonical instance of the defect: `UdaAosKnownDefects. + DISABLED_DynamicLeafInsideAosRoundTrips` + tripwire `UdaAosKnownDefects. + DynamicLeafInsideAosCurrentlyReturnsSentinel`. Nested (multi-level) AOS + traversal is exercised separately and successfully by the real-path + matrix's *static* AOS-nested cells above (INTEGER r3, DOUBLE r6) — the + traversal mechanism itself (begin/iterate/end across nesting) works; only + a *dynamic* leaf's value fails to come back correctly. +- **`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 (size + iteration mechanism correct; nested traversal proven separately by the real-path matrix's static AOS cells) | `UdaAosKnownDefects.DynamicLeafInsideAosCurrentlyReturnsSentinel` (mechanism proof); see the xfail row below for the value defect | covered (indirect) | +| ↳ 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: the + client-side RAM cache genuinely *is* cleared on close + (`UDABackend::closePulse`), but a server-side resource opened on the read + path outlives it regardless. Pinned: + `UdaUniqueSurfaceTest.DISABLED_ClosingUdaSessionReleasesEveryServerSideHandle` + + tripwire `UdaUniqueSurfaceTest.ClosingUdaSessionCurrentlyLeaksServerSideHandle`. +- **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: + `UdaUniqueSurfaceTest.DISABLED_OpenRefusesWhenStoredBackendVersionCannotBeVerified` + + tripwire + `UdaUniqueSurfaceTest.VersionDriftCheckCurrentlyNeverFiresRegardlessOfStoredPulse`. +- **`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 — `al_begin_timerange_action` must + pass `al_lowlevel.cpp`'s capability gate ("Selected backend does not + support time range operations.") rather than being refused outright, + distinct from the separate, already-pinned uninitialized-interpmode defect + 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** | +| 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: open treats an unavailable stored version as compatible because both sides are hardcoded placeholders | `UdaUniqueSurfaceTest.DISABLED_OpenRefusesWhenStoredBackendVersionCannotBeVerified` + tripwire `UdaUniqueSurfaceTest.VersionDriftCheckCurrentlyNeverFiresRegardlessOfStoredPulse` | **xfail** | +| Server-version-gated `supportsTimeRangeOperation()`: reference plugin 1.8.0 > 1.4.0 grants the capability | `UdaUniqueSurfaceTest.TimeRangeCapabilityGrantedByReferenceServerVersion180` | covered | +| 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 index db17858d..71d44efb 100644 --- a/tests/contract/al_contract.h +++ b/tests/contract/al_contract.h @@ -18,6 +18,8 @@ #include #include +#include +#include #include #include #include @@ -31,6 +33,12 @@ #include #define AL_CONTRACT_GETPID _getpid #else +#if defined(AL_CONTRACT_HAVE_UDA) +#include +#include +#include +#include +#endif #include #define AL_CONTRACT_GETPID getpid #endif @@ -79,6 +87,114 @@ inline constexpr const char* kUnregisteredPluginName = } \ } 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). // --------------------------------------------------------------------------- diff --git a/tests/contract/test_uda.cpp b/tests/contract/test_uda.cpp new file mode 100644 index 00000000..38a256a0 --- /dev/null +++ b/tests/contract/test_uda.cpp @@ -0,0 +1,223 @@ +// 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 + +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 (scalar + timebase-carrying 2-D array + constraints AOS, +// tests/contract/equilibrium_seed.h — issue #4/D5), reusing the seed's own +// generator and FNV-1a 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, recompute the hash over what UDA +// reads back, and assert it matches the seed's own expected_hash(). +// +// Empirically (against the docker/uda/ reference stack): the composite shape +// is a **divergence**, not a defect -- the same wall MDSplus hits against its +// baked model tree (TRACEABILITY.md Part 4's EquilibriumSeedMatrix/Mdsplus +// row). The scalar sub-shape alone is real DD (`vacuum_toroidal_field/r0`, +// already proven read-only-parity by UdaSmokeRoundTrip above), but the +// generator's flat `profiles_1d/psi` leaf and generic `constraints` AOS have +// no counterpart in equilibrium's real DD-4.1.1 layout (`profiles_1d` is +// itself a struct_array, not a plain field): UDA's remote get() resolves +// every path against the DD schema it loads at startup (src/uda/uda_xml.cpp), +// so `al_plugin_read_data` fails immediately on `profiles_1d/psi` with +// "cannot find node equilibrium/profiles_1d/psi in data dictionary +// (profiles_1d not found)". There is nothing to fix -- the fixture's +// synthetic composite shape simply cannot transfer to a DD-schema-validating +// backend. Unlike EquilibriumSeedMatrix's per-backend GTEST_SKIP() (that +// fixture's body genuinely runs for its other, non-MDSplus parameters), this +// suite has no other instance to keep the seed/read machinery exercised, so +// the test runs the real seed-then-reopen attempt every time, asserts the +// *specific* known failure signature, and only then GTEST_SKIP()s -- if the +// divergence ever disappears (e.g. the UDA backend gains partial-path +// resolution), the assertion below fails loudly instead of this test quietly +// staying "skipped" over a silently-changed reality. +class UdaEquilibriumSeedParity : public ::testing::Test { +protected: + void SetUp() override { AL_CONTRACT_SKIP_IF_UDA_UNCONFIGURED(); } + + al_contract::TempBase base_; +}; + +TEST_F(UdaEquilibriumSeedParity, HdfSeededReadsBackThroughUda) { + const std::string pulse_dir = base_.str() + "/pulse"; + std::error_code ec; + std::filesystem::create_directories(pulse_dir, ec); + + // --- seed the full composite shape through the plain HDF5 backend --- + { + 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)); + + // Same precondition as the scalar tracer bullet above: the UDA client's + // cache_mode=none read path always resolves ids_properties/homogeneous_time + // first and throws if it is absent. Written in its own GLOBAL op ahead of + // the seed so equilibrium_seed::write() itself 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); + } + + // --- read the same composite shape back 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)); + + uint64_t hash = 0; + al_status_t s = equilibrium_seed::read_and_hash(pulse_ctx, &hash); + + // Divergence, not a defect -- confirm the read fails with the exact + // known signature (DD-schema resolution rejecting profiles_1d/psi as + // a plain leaf) rather than assuming it and skipping unconditionally. + // A fatal failure here means the divergence no longer holds: someone + // must update this test (and TRACEABILITY.md Part 5) to reflect the + // new behavior, rather than the change going unnoticed. + ASSERT_NE(s.code, 0) + << "equilibrium_seed::read_and_hash unexpectedly succeeded through " + "UDA -- the profiles_1d/psi divergence documented in " + "TRACEABILITY.md Part 5 no longer holds; update this test " + "(and the traceability row) to match the new behavior."; + EXPECT_NE(std::string(s.message).find("profiles_1d"), std::string::npos) + << "expected the documented profiles_1d DD-resolution failure, " + "got a different error instead: " << s.message; + + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + + GTEST_SKIP() << "divergence, not a defect: UDA resolves every remote " + "read path against the DD schema it loads at startup, " + "and the generator's flat profiles_1d/psi leaf + " + "generic constraints AOS have no counterpart in " + "equilibrium's real DD-4.1.1 layout (profiles_1d is " + "itself a struct_array) -- confirmed above via the " + "specific failure message. Same wall MDSplus hits " + "(TRACEABILITY.md Part 4's EquilibriumSeedMatrix/" + "Mdsplus row); the scalar sub-shape alone is real DD " + "and is already covered read-only-parity by " + "UdaSmokeRoundTrip above. See TRACEABILITY.md Part 5."; + } +} + +} // 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..ad6ae01b --- /dev/null +++ b/tests/contract/test_uda_breadth.cpp @@ -0,0 +1,686 @@ +// 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): equilibrium/time_slice is a real DD struct_array +// (timebasepath="time"), each element carrying global_quantities/ip -- the +// same real DD AOS path MDSplus's own unique-surface/parity proof uses +// (TRACEABILITY.md Part 4). Seeded with 3 elements through HDF5, reopened +// through UDA: al_begin_arraystruct_action correctly reports size 3 and +// iterates (confirmed via the debug trace: three distinct +// "time_slice[0..2]/global_quantities/ip" directives are sent), but the +// VALUE that comes back for every element is the HDF5 "absent" sentinel +// (-9.0e40, al_contract::kEmptyDouble), not what was written -- a genuine +// NEW DEFECT, not a legitimate storage difference, pinned per D2 below +// (UdaAosKnownDefects). Root cause traced empirically: `global_quantities/ip` +// is a DD *dynamic* (time-varying) leaf (confirmed via the request trace's +// `dynamic_flags=1`), and every dynamic leaf nested inside a struct_array +// fails identically through UDA remote mode + backend=hdf5 -- see +// UdaRealPathMatrix's COMPLEX_r1/r3/r5 rows (test_uda_real_paths.cpp), which +// hit the exact same wall on a different datatype/AOS-depth (their +// `known_defect_reason`, not `divergence_reason`, cites this test). Static +// (non-dynamic) leaves nested in a struct_array -- e.g. `temporary`'s +// self-test IDS fields -- are unaffected (UdaRealPathMatrix's INTEGER_r3/ +// DOUBLE_r6 pass cleanly). +// =========================================================================== + +// 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..658e7645 --- /dev/null +++ b/tests/contract/test_uda_real_paths.cpp @@ -0,0 +1,384 @@ +// 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, reused as-is (identical kPathSpecs +// paths/ranks/aos_chains), because 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 test_mdsplus_real_paths.cpp's header 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 spec.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. +// +// 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 +#include + +#include + +#include +#include +#include +#include + +namespace { + +enum class DType { Char, Int, Double, Complex }; + +// Same shape as test_mdsplus_real_paths.cpp's PathSpec. `terminal_gap_reason` +// is shared verbatim with the MDSplus matrix (it is a fact about DD 4.1.1 +// itself, not about either backend). `divergence_reason` is UDA-specific -- +// re-derived empirically against the reference stack, not copied from +// MDSplus's own divergence rows, since the two backends can diverge for +// different reasons on the same cell. +struct PathSpec { + DType dt; + int rank; + const char* ids; + std::vector aos_chain; + const char* leaf; + const char* terminal_gap_reason; + const char* divergence_reason = nullptr; + // Non-null iff the cell hits 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, instead of what was written. + // Distinct from divergence_reason -- this is a real bug (xfail), not a + // legitimate storage-model difference; the cell is skipped in the breadth + // matrix (not run into the known-wrong assertion) and pinned exactly by + // UdaRealPathMatrixKnownDefects's DISABLED_/tripwire pair below. + const char* known_defect_reason = nullptr; +}; + +// Keep the three exact known-defect cells as named specs so the breadth matrix +// and the DISABLED_/tripwire pair below exercise identical IDS paths, ranks, +// and AOS nesting without duplicating that configuration. +const PathSpec kComplexR1DynamicAosSpec{ + DType::Complex, 1, "waves", {"coherent_wave", "full_wave", "e_field/plus"}, + "values", nullptr, nullptr, + "dynamic leaf nested in a struct_array (3 AOS levels)"}; +const PathSpec kComplexR3DynamicAosSpec{ + DType::Complex, 3, "runaway_electrons", {"distribution/markers"}, + "orbit_integrals_instant/values", nullptr, nullptr, + "dynamic leaf nested in a struct_array (1 AOS level)"}; +const PathSpec kComplexR5DynamicAosSpec{ + DType::Complex, 5, "runaway_electrons", {"distribution/markers"}, + "orbit_integrals/values", nullptr, nullptr, + "dynamic leaf nested in a struct_array (1 AOS level)"}; + +// Identical path/rank/aos_chain curation to test_mdsplus_real_paths.cpp's +// kPathSpecs (issue #25 acceptance criterion 2: "curated real-path set reused +// as-is"). terminal_gap_reason carries over unchanged (a DD fact). CHAR r0's +// divergence_reason is UDA-specific -- see below. +const PathSpec kPathSpecs[] = { + // --- CHAR --------------------------------------------------------------- + // Skipped WITHOUT attempting the seed write: seeding this cell means + // writing a dim=0 (bare scalar) CHAR value through the plain HDF5 + // backend used for fixture setup, which is the pre-existing, + // independently-pinned HDF5 CHAR-scalar crash + // (RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips / tripwire + // …CurrentlyCrashes, TRACEABILITY.md Part 1) -- confirmed to reproduce on + // a real DD leaf too (equilibrium's code/name, STR_0D), not just the + // synthetic path that test already pins. This is a fixture-setup-backend + // defect, not a UDA behavior, so it is recorded as a divergence (not + // exercised) rather than run into the crash. + {DType::Char, 0, "equilibrium", {}, "code/name", nullptr, + "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"}, + {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"}, + // Known defect (xfail), not attempted: waves/coherent_wave/full_wave/ + // e_field/plus/values is a DD *dynamic* leaf 3 levels deep in nested + // struct_arrays -- confirmed to hit UdaAosKnownDefects's dynamic-leaf- + // inside-AOS defect (test_uda_breadth.cpp): the field comes back + // empty/absent through UDA remote mode + backend=hdf5 instead of what + // was written. + kComplexR1DynamicAosSpec, + {DType::Complex, 2, "gyrokinetics_local", {}, + "non_linear/fields_zonal_2d/phi_potential_perturbed_norm", nullptr}, + // Same known defect as COMPLEX r1 above: orbit_integrals_instant/values + // is a dynamic leaf nested one level inside runaway_electrons/ + // distribution/markers. + kComplexR3DynamicAosSpec, + {DType::Complex, 4, "gyrokinetics_local", {}, + "non_linear/fields_4d/phi_potential_perturbed_norm", nullptr}, + // Same known defect as COMPLEX r1/r3 above. + kComplexR5DynamicAosSpec, + {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"}, +}; + +// --- 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 PathSpec& 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 PathSpec& spec) { + run_cell_with_read_expectation( + spec, [](int ctx, const PathSpec& 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 PathSpec spec = GetParam(); + if (spec.terminal_gap_reason != nullptr) { + GTEST_SKIP() << "terminal-gap: " << spec.terminal_gap_reason; + } + if (spec.divergence_reason != nullptr) { + GTEST_SKIP() << "divergence: " << spec.divergence_reason; + } + if (spec.known_defect_reason != nullptr) { + GTEST_SKIP() << "known-defect (xfail): " << spec.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). +std::string NameUdaRealPathMatrixCase( + const ::testing::TestParamInfo& info) { + static const char* const kNames[] = {"CHAR", "INTEGER", "DOUBLE", + "COMPLEX"}; + return std::string(kNames[static_cast(info.param.dt)]) + "_r" + + std::to_string(info.param.rank); +} + +INSTANTIATE_TEST_SUITE_P(Uda, UdaRealPathMatrix, ::testing::ValuesIn(kPathSpecs), + NameUdaRealPathMatrixCase); + +// Exact D2 pins for the three 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()); +} + +TEST_P(UdaRealPathMatrixKnownDefects, + DynamicComplexLeafInsideAosCurrentlyReadsEmpty) { + run_cell_with_read_expectation>( + GetParam(), [](int ctx, const PathSpec& 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"; + }); +} + +const PathSpec kComplexDynamicAosKnownDefectSpecs[] = { + kComplexR1DynamicAosSpec, + kComplexR3DynamicAosSpec, + kComplexR5DynamicAosSpec, +}; + +INSTANTIATE_TEST_SUITE_P( + Uda, UdaRealPathMatrixKnownDefects, + ::testing::ValuesIn(kComplexDynamicAosKnownDefectSpecs), + 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..ee14b1fd --- /dev/null +++ b/tests/contract/test_uda_unique_surface.cpp @@ -0,0 +1,1285 @@ +// 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. +// +// 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 +// ((ver.first!=sver.first)||(ver.second::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 pass al_lowlevel.cpp's capability gate +// (src/al_lowlevel.cpp:1053, "Selected backend does not support time range +// operations.") rather than being refused outright. 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(std::string(s.message).find( + "does not support time range operations"), + std::string::npos) + << "the reference server (plugin 1.8.0 > 1.4.0) must grant the " + "time-range capability -- al_begin_timerange_action must not be " + "refused with the 'does not support time range operations' " + "message; got: " << 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