From 5fe5177bdb23a98ecc813ae5a56c4a7472efd795 Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Wed, 15 Jul 2026 12:36:25 +0200
Subject: [PATCH 01/17] Add UDA reference-stack Docker spike + C-ABI smoke
round trip (issue #23)
Subtask 1 of the UDA characterization baseline (PRD #21): the time-boxed
feasibility spike. Stands up the pinned UDA reference stack and proves one
end-to-end smoke round trip through the public C ABI.
- docker/uda/: aarch64 image (UDA server 2.9.3 + IDSDef.xml DD 4.1.1 +
UDA-Plugins 1.8.0 source + xinetd) plus run.sh, which builds IMAS-Core
(HDF5 + UDA backends) from the mounted workspace, builds + registers the
IMAS server plugin against that core (failing loudly on the NO_IMAS
mapping-only degradation), starts uda_server, and runs the UDA tier.
Mirrors the MDSplus leg's toolchain-image + build-workspace-at-runtime
pattern so tests exercise the source under review.
- tests/contract/test_uda.cpp: read-only tracer bullet -- a scalar seeded
via the plain HDF5 backend reads back byte-identical through the UDA
backend in remote mode. Build-gated (AL_CONTRACT_HAVE_UDA) and
runtime-skipped when UDA_HOST is unset -- absent/skipped, never failed.
- CMake gate + dedicated 'uda' ctest label; UDA runtime-skip macro and
remote-URI helper in al_contract.h.
Gate: FULL-STACK TIER CONFIRMED (smoke passes; fallback not invoked).
Open items answered empirically and recorded in docker/uda/README.md
(IDSDef.xml lookup + absent/missing errors, remote-write 'Unknown function
requested!' surface, plugin version 1.8.0, pinned stack identifiers;
fetch-mode cache semantics documented from source with deferral rationale).
(cherry picked from commit 82e03aa0cb2f851ccd2dba37627f2b61b75d508d)
---
docker/uda/Dockerfile | 100 +++++++++++++++++++++++
docker/uda/README.md | 149 ++++++++++++++++++++++++++++++++++
docker/uda/run.sh | 113 ++++++++++++++++++++++++++
tests/contract/CMakeLists.txt | 27 +++++-
tests/contract/al_contract.h | 32 ++++++++
tests/contract/test_uda.cpp | 116 ++++++++++++++++++++++++++
6 files changed, 536 insertions(+), 1 deletion(-)
create mode 100644 docker/uda/Dockerfile
create mode 100644 docker/uda/README.md
create mode 100644 docker/uda/run.sh
create mode 100644 tests/contract/test_uda.cpp
diff --git a/docker/uda/Dockerfile b/docker/uda/Dockerfile
new file mode 100644
index 00000000..b9c5cdd4
--- /dev/null
+++ b/docker/uda/Dockerfile
@@ -0,0 +1,100 @@
+# 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
+# - 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 DD_VERSION=4.1.1
+
+LABEL org.iter.imas-core.uda-server-version="2.9.3"
+LABEL org.iter.imas-core.uda-plugins-version="1.8.0"
+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"
+
+# --- 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. 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
+
+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..f5652db9
--- /dev/null
+++ b/docker/uda/README.md
@@ -0,0 +1,149 @@
+# 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** |
+| Data Dictionary | `imas-data-dictionary` (PyPI wheel) | **4.1.1** |
+| 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.
+
+## 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:** the reference `IMAS` server plugin
+ has no `writeData`/`deleteData` handler. Issuing the exact directives the
+ client emits to the running server (`uda_cli --request "IMAS::writeData(...)"`
+ / `"IMAS::deleteData(...)"`) both return `[handle_request]: Unknown function
+ requested!`. The UDA backend catches the resulting `uda::UDAException` and
+ re-throws it as an `ALException` carrying that text, which surfaces at the C
+ ABI as a non-zero `al_status_t`. This empirically confirms the static finding
+ that remote mode is read-only by construction; the exact C-ABI-level pin
+ (correct-contract + tripwire, driven through `al_write_data`) is the
+ write-path subtask's job, not the spike's.
+- **Fetch-mode cache-directory naming/reuse semantics** (from source; full
+ empirical characterization deferred to the fetch-mode subtask): the local
+ cache dir is the `local_cache` URI option when given, 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), so a mutated or stale local copy
+ persists across sessions — the divergence the fetch-mode subtask pins.
+ Empirical exercise is deferred deliberately, not overlooked: fetch mode
+ downloads via a `BYTES::read` directive to a **`BYTES` server plugin** that
+ this reference stack does not register (only `IMAS` is), so a complete
+ download+reuse round trip needs that extra server plugin — which the fetch
+ subtask stands up. The spike's gate does not depend on it.
+- **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**, 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/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt
index 785c7d68..6c4d654a 100644
--- a/tests/contract/CMakeLists.txt
+++ b/tests/contract/CMakeLists.txt
@@ -74,6 +74,7 @@ add_executable(contract_tests
test_mdsplus_real_paths.cpp
test_mdsplus_version_drift.cpp
test_mdsplus_unique_surface.cpp
+ test_uda.cpp
)
target_link_libraries(contract_tests PRIVATE al GTest::gtest GTest::gtest_main)
@@ -105,6 +106,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 +237,18 @@ 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*"
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.
+ gtest_discover_tests(contract_tests
+ TEST_FILTER "UdaSmokeRoundTrip*"
+ PROPERTIES LABELS "uda")
else()
gtest_discover_tests(contract_tests
PROPERTIES LABELS "contract")
diff --git a/tests/contract/al_contract.h b/tests/contract/al_contract.h
index db17858d..fcc86d0a 100644
--- a/tests/contract/al_contract.h
+++ b/tests/contract/al_contract.h
@@ -79,6 +79,38 @@ 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 is the
+// UDA_HOST env var, which docker/uda/run.sh exports before running the suite and
+// which a plain workstation never sets. Same macro-not-function rationale as the
+// MDSplus skip above: GTEST_SKIP() must unwind the calling SetUp()/TEST body.
+// ---------------------------------------------------------------------------
+#define AL_CONTRACT_SKIP_IF_UDA_UNCONFIGURED() \
+ do { \
+ const char* uda_host = std::getenv("UDA_HOST"); \
+ 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/)."; \
+ } \
+ } 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";
+}
+
// ---------------------------------------------------------------------------
// 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..11778150
--- /dev/null
+++ b/tests/contract/test_uda.cpp
@@ -0,0 +1,116 @@
+// 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 = "imas:hdf5?path=" + 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_uri_base() +
+ "?backend=hdf5&cache_mode=none&path=" +
+ 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);
+ }
+}
+
+} // namespace
+
+#endif // AL_CONTRACT_HAVE_UDA
From b442af232472914d5a71b404f58b5a8919a807f4 Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Wed, 15 Jul 2026 14:46:42 +0200
Subject: [PATCH 02/17] Add ./test dispatcher + make targets for the backend
contract tiers
One verb over the three tiers: ./test common (host-native HDF5/memory/ASCII/
flexbuffers), ./test mdsplus and ./test uda (their Docker legs), and ./test all.
Makefile targets (test-common/test-mdsplus/test-uda/test) delegate to it.
Coherent reporting: every tier prints the same section header + PASS/FAIL(elapsed)
line; ./test all runs all tiers even if one fails, prints a summary table, and
exits non-zero if any failed. TTY-aware colour, stripped when piped. Auto-builds
the Docker images on first use; both legs bind-mount the repo so tests exercise
the working copy. PLATFORM overridable for x86 clusters.
(cherry picked from commit 7e0ee7ccd9b27c82d77a174cac5ffe53a04e488f)
---
Makefile | 24 ++++++++
test | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 189 insertions(+)
create mode 100644 Makefile
create mode 100755 test
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/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
From 46ef371404528110647e01716401cf679ec4b82c Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Wed, 15 Jul 2026 15:00:43 +0200
Subject: [PATCH 03/17] Add UDA read-only parity fixture over the equilibrium
seed (issue #24)
Broadens the scalar tracer bullet (#23) to the full equilibrium-seed
composite (scalar + timebase-carrying 2-D array + constraints AOS),
reusing the seed's own generator and FNV-1a hash oracle unchanged.
Run against the docker/uda/ reference stack (UDA server 2.9.3, IMAS
plugin 1.8.0, DD 4.1.1): the composite is a genuine divergence, the
same wall MDSplus hits on EquilibriumSeedMatrix/Mdsplus -- UDA
resolves every remote path against the DD schema, and the seed's flat
profiles_1d/psi leaf + generic constraints AOS have no counterpart in
equilibrium's real DD-4.1.1 layout. The test still runs the
seed-then-reopen attempt every time and asserts the specific failure
signature before GTEST_SKIP()'ing, so a future fix would fail loudly
rather than the row silently going stale.
Adds TRACEABILITY.md Part 5 with the pinned reference-stack header
and the tracer-bullet + parity-fixture verdict rows.
(cherry picked from commit edb19383f8af3ba4522b8a102e4ef935bd61de65)
---
tests/contract/CMakeLists.txt | 6 +-
tests/contract/TRACEABILITY.md | 62 ++++++++++++++++++
tests/contract/test_uda.cpp | 111 +++++++++++++++++++++++++++++++++
3 files changed, 177 insertions(+), 2 deletions(-)
diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt
index 6c4d654a..ded7bf42 100644
--- a/tests/contract/CMakeLists.txt
+++ b/tests/contract/CMakeLists.txt
@@ -237,7 +237,7 @@ 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*:UdaSmokeRoundTrip*"
+ TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:ReadbackPlugins.*:UriOwnership.*:MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*:MdsplusStructAwareSliceInterpolation*:MdsplusTimerangeResampling*:MdsplusSegmentBackedWrites*:MdsplusSliceWriteKnownDefects*:MdsplusSliceWriteKnownDefectsDeath*:MdsplusTimebaseCache*:UdaSmokeRoundTrip*:UdaEquilibriumSeedParity*"
PROPERTIES LABELS "contract\;integration")
gtest_discover_tests(contract_tests
TEST_FILTER "MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*:MdsplusStructAwareSliceInterpolation*:MdsplusTimerangeResampling*:MdsplusSegmentBackedWrites*:MdsplusSliceWriteKnownDefects*:MdsplusSliceWriteKnownDefectsDeath*:MdsplusTimebaseCache*"
@@ -246,8 +246,10 @@ if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
# 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.
gtest_discover_tests(contract_tests
- TEST_FILTER "UdaSmokeRoundTrip*"
+ TEST_FILTER "UdaSmokeRoundTrip*:UdaEquilibriumSeedParity*"
PROPERTIES LABELS "uda")
else()
gtest_discover_tests(contract_tests
diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md
index 522de18e..40f43896 100644
--- a/tests/contract/TRACEABILITY.md
+++ b/tests/contract/TRACEABILITY.md
@@ -586,3 +586,65 @@ 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** |
+| 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 |
diff --git a/tests/contract/test_uda.cpp b/tests/contract/test_uda.cpp
index 11778150..5b8afbe6 100644
--- a/tests/contract/test_uda.cpp
+++ b/tests/contract/test_uda.cpp
@@ -111,6 +111,117 @@ TEST_F(UdaSmokeRoundTrip, ScalarSeededViaHdf5ReadsBackThroughUda) {
}
}
+// -----------------------------------------------------------------------------
+// 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 = "imas:hdf5?path=" + 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_uri_base() +
+ "?backend=hdf5&cache_mode=none&path=" +
+ 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
From 7432446d3165326de16b5f609bf5fa6b0d7a3539 Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Wed, 15 Jul 2026 15:33:26 +0200
Subject: [PATCH 04/17] Add UDA parity breadth: real-path matrix,
AOS/occurrences/list_filled_paths/pulse-modes/slice-timerange (issue #25)
Extends the UDA read-only parity fixture (issue #24) until every C-ABI-reachable
read-side capability from FUNCTIONALITY_INVENTORY Part 1 has a UDA verdict,
closing TRACEABILITY.md Part 5 to zero blank rows:
- test_uda_real_paths.cpp: reuses issue #15's curated real-DD-4.1.1
{CHAR,INTEGER,DOUBLE,COMPLEX} x rank path set as-is, adapted to UDA's
seed(HDF5)-then-reopen(UDA) shape. 15 cells covered, 13 terminal-gap
(unchanged DD facts), 1 divergence (CHAR r0 reproduces the pre-existing
HDF5 CHAR-scalar crash during fixture seeding, not a new UDA finding).
- test_uda_breadth.cpp: AOS traversal, al_get_occurrences, al_list_filled_paths,
pulse open-mode/error-behavior parity, and slice/time-range reads, all
seed(HDF5)-then-reopen(UDA).
Two genuine new defects found and pinned (xfail: correct-contract DISABLED_ +
current-behavior tripwire, never fixed per D2):
1. A *dynamic* (time-varying) DD leaf nested inside a struct_array comes
back empty/absent through UDA remote mode + backend=hdf5 instead of
what was written (UdaAosKnownDefects; also affects real-path matrix
cells COMPLEX_r1/r3/r5, which cite the same pin via known_defect_reason
rather than duplicating it).
2. al_begin_timerange_action's OperationContext constructor never
initializes the base interpmode member (only time_range.interpolation_method),
silently harmless on every backend that ignores getInterpmode() for
TIMERANGE_OP -- except UDA's remote readData() directive-builder, which
reads it unconditionally and throws "unknown interp mode: "
(UdaSliceAndTimeRange).
Also corrected two working assumptions once run against the real reference
stack: UDA's OPEN_PULSE genuinely round-trips to the server at open time
(fails cleanly on an absent remote pulse, matching on-disk-backend parity),
and reading an unwritten leaf on a seeded IDS returns the HDF5 absent-scalar
sentinel with code==0, transparently forwarded, not an error.
Verified: ctest -L uda -> 42/42 (2 intentionally Disabled correct-contract
pairs, rest covered/skipped as designed); full ctest -> 291/291 passing
against docker/uda/'s pinned reference stack (UDA 2.9.3, IMAS plugin 1.8.0,
DD 4.1.1).
(cherry picked from commit 2314b2a58b7971e95e411d04b905bba34a2ffb6f)
---
tests/contract/CMakeLists.txt | 12 +-
tests/contract/TRACEABILITY.md | 167 ++++++++
tests/contract/test_uda_breadth.cpp | 558 +++++++++++++++++++++++++
tests/contract/test_uda_real_paths.cpp | 325 ++++++++++++++
4 files changed, 1059 insertions(+), 3 deletions(-)
create mode 100644 tests/contract/test_uda_breadth.cpp
create mode 100644 tests/contract/test_uda_real_paths.cpp
diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt
index ded7bf42..a1d5dbf2 100644
--- a/tests/contract/CMakeLists.txt
+++ b/tests/contract/CMakeLists.txt
@@ -75,6 +75,8 @@ add_executable(contract_tests
test_mdsplus_version_drift.cpp
test_mdsplus_unique_surface.cpp
test_uda.cpp
+ test_uda_real_paths.cpp
+ test_uda_breadth.cpp
)
target_link_libraries(contract_tests PRIVATE al GTest::gtest GTest::gtest_main)
@@ -237,7 +239,7 @@ 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*:UdaSmokeRoundTrip*:UdaEquilibriumSeedParity*"
+ 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.*:UdaBreadthTest*:UdaAosKnownDefects*:UdaSliceAndTimeRange*"
PROPERTIES LABELS "contract\;integration")
gtest_discover_tests(contract_tests
TEST_FILTER "MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*:MdsplusStructAwareSliceInterpolation*:MdsplusTimerangeResampling*:MdsplusSegmentBackedWrites*:MdsplusSliceWriteKnownDefects*:MdsplusSliceWriteKnownDefectsDeath*:MdsplusTimebaseCache*"
@@ -247,9 +249,13 @@ if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
# 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.
+ # (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.*).
gtest_discover_tests(contract_tests
- TEST_FILTER "UdaSmokeRoundTrip*:UdaEquilibriumSeedParity*"
+ TEST_FILTER "UdaSmokeRoundTrip*:UdaEquilibriumSeedParity*:Uda/UdaRealPathMatrix.*:UdaBreadthTest*:UdaAosKnownDefects*:UdaSliceAndTimeRange*"
PROPERTIES LABELS "uda")
else()
gtest_discover_tests(contract_tests
diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md
index 40f43896..f5b19d7a 100644
--- a/tests/contract/TRACEABILITY.md
+++ b/tests/contract/TRACEABILITY.md
@@ -648,3 +648,170 @@ blocked-by-environment) was not invoked.
|---|---|---|
| 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 34 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 divergence for a different reason than MDSplus's**: MDSplus's
+ own CHAR r0 divergence is a clean, typed dimension-mismatch refusal from its
+ *real* backend. UDA's fixture never even reaches that question: seeding this
+ cell requires writing a dim=0 CHAR value through the *plain HDF5 backend*
+ used for fixture setup, which reproduces the pre-existing, independently-
+ pinned `RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips` /
+ `RoundTripKnownDefectsDeath.Hdf5CharScalarCurrentlyCrashes` crash
+ (TRACEABILITY.md Part 1) on this real DD leaf too. Not run, to avoid
+ crashing the seed step — a fixture-setup-backend defect already pinned
+ elsewhere, not a new UDA finding.
+- **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. Pinned
+ centrally by `UdaAosKnownDefects.DISABLED_DynamicLeafInsideAosRoundTrips` +
+ tripwire `UdaAosKnownDefects.DynamicLeafInsideAosCurrentlyReturnsSentinel`
+ (`test_uda_breadth.cpp`); the three affected matrix cells cite it via their
+ own `known_defect_reason` rather than duplicating the pin three times.
+
+| 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) — **divergence, not a new UDA defect**: seeding this cell would reproduce the already-pinned HDF5 CHAR-scalar crash (`RoundTripKnownDefects`/Part 1) in fixture setup; not attempted | `Uda/UdaRealPathMatrix.RealPathRoundTrip/CHAR_r0` (`GTEST_SKIP()`, not run) | divergence |
+| ↳ 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) — see `UdaAosKnownDefects` below for the pinned tripwire | `Uda/UdaRealPathMatrix.RealPathRoundTrip/{COMPLEX_r1,COMPLEX_r3,COMPLEX_r5}` (`GTEST_SKIP()`, known-defect) | **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
+from `FUNCTIONALITY_INVENTORY.md` Part 1 not already covered by the tracer
+bullet, the equilibrium-seed parity fixture, or the real-path matrix above.
+Same seed(HDF5)-then-reopen(UDA) shape throughout.
+
+**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.
+- **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 `_` naming convention, ownership pinned | `UdaBreadthTest.OccurrencesListsWrittenOccurrencesThroughReopen` | covered |
+| `al_list_filled_paths` real through UDA remote mode (`backend=hdf5` only), ownership pinned | `UdaBreadthTest.ListFilledPathsThroughReopen` | covered |
+| `OPEN_PULSE` refuses an absent remote pulse — genuine parity with the on-disk backends' `DataEntryModes.OpenPulseFailsWhenAbsent` | `UdaBreadthTest.OpenPulseFailsWhenRemotePathAbsent` | covered |
+| 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** |
diff --git a/tests/contract/test_uda_breadth.cpp b/tests/contract/test_uda_breadth.cpp
new file mode 100644
index 00000000..ce77426d
--- /dev/null
+++ b/tests/contract/test_uda_breadth.cpp
@@ -0,0 +1,558 @@
+// 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 {
+
+// Build the UDA remote-mode URI for a seeded HDF5 pulse dir.
+std::string uda_uri_for(const std::string& pulse_dir) {
+ return al_contract::uda_uri_base() + "?backend=hdf5&cache_mode=none&path=" +
+ pulse_dir;
+}
+
+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 = "imas:hdf5?path=" + 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 = uda_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) {
+ 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) {
+ 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 "_" naming convention "equilibrium_2" -- then listed
+// through UDA remote mode.
+// ===========================================================================
+TEST_F(UdaBreadthTest, OccurrencesListsWrittenOccurrencesThroughReopen) {
+ const std::string pulse_dir = fresh_pulse_dir();
+
+ // --- seed via HDF5: occurrence 0 and occurrence 2 -----------------------
+ {
+ const std::string hdf5_uri = "imas:hdf5?path=" + pulse_dir;
+ int pulse_ctx = -1;
+ AL_ASSERT_OK(al_begin_dataentry_action(hdf5_uri.c_str(),
+ FORCE_CREATE_PULSE, &pulse_ctx));
+
+ for (const char* name : {"equilibrium", "equilibrium_2"}) {
+ int op = -1;
+ AL_ASSERT_OK(
+ al_begin_global_action(pulse_ctx, name, "", 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 occurrences through UDA, remote mode --------------------------
+ {
+ const std::string uda_uri = uda_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.
+
+ EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0);
+ }
+}
+
+// ===========================================================================
+// 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 = "imas:hdf5?path=" + 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 = uda_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 = uda_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_close_pulse(pulse_ctx, CLOSE_PULSE);
+}
+
+// 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 = "imas:hdf5?path=" + 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 = uda_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_end_action(op);
+ 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 = "imas:hdf5?path=" + 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 = uda_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_end_action(op);
+ 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 = "imas:hdf5?path=" + 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 = uda_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 = uda_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_end_action(op);
+ 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..10aeaba9
--- /dev/null
+++ b/tests/contract/test_uda_real_paths.cpp
@@ -0,0 +1,325 @@
+// 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 here (not run
+ // into the known-wrong assertion) and the defect itself is pinned once,
+ // centrally, by UdaAosKnownDefects's DISABLED_/tripwire pair.
+ const char* known_defect_reason = nullptr;
+};
+
+// 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.
+ {DType::Complex, 1, "waves", {"coherent_wave", "full_wave", "e_field/plus"},
+ "values", nullptr, nullptr,
+ "dynamic leaf nested in a struct_array (3 AOS levels) -- see "
+ "UdaAosKnownDefects.DynamicLeafInsideAosCurrentlyReturnsSentinel "
+ "(test_uda_breadth.cpp)"},
+ {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.
+ {DType::Complex, 3, "runaway_electrons", {"distribution/markers"},
+ "orbit_integrals_instant/values", nullptr, nullptr,
+ "dynamic leaf nested in a struct_array (1 AOS level) -- see "
+ "UdaAosKnownDefects.DynamicLeafInsideAosCurrentlyReturnsSentinel "
+ "(test_uda_breadth.cpp)"},
+ {DType::Complex, 4, "gyrokinetics_local", {},
+ "non_linear/fields_4d/phi_potential_perturbed_norm", nullptr},
+ // Same known defect as COMPLEX r1/r3 above.
+ {DType::Complex, 5, "runaway_electrons", {"distribution/markers"},
+ "orbit_integrals/values", nullptr, nullptr,
+ "dynamic leaf nested in a struct_array (1 AOS level) -- see "
+ "UdaAosKnownDefects.DynamicLeafInsideAosCurrentlyReturnsSentinel "
+ "(test_uda_breadth.cpp)"},
+ {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(const PathSpec& spec) {
+ 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 = "imas:hdf5?path=" + 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_uri_base() +
+ "?backend=hdf5&cache_mode=none&path=" +
+ 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;
+ }
+
+ read_leaf_and_expect(ctx, spec.leaf, spec.rank, shape, written);
+
+ for (auto it = aos_ctxs.rbegin(); it != aos_ctxs.rend(); ++it) {
+ AL_EXPECT_OK(al_end_action(*it));
+ }
+ AL_ASSERT_OK(al_end_action(op));
+ EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0);
+ }
+}
+
+class 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);
+
+} // namespace
+
+#endif // AL_CONTRACT_HAVE_UDA
From 45806d96fb59cf60c9eab7caa7cc759ed6a39c5e Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Wed, 15 Jul 2026 15:39:05 +0200
Subject: [PATCH 05/17] Fix cell count in TRACEABILITY.md Part 5 (32, not 34)
Code review of issue #25's commit found the real-path matrix section
mis-stated the cell count as 34; test_uda_real_paths.cpp and
test_mdsplus_real_paths.cpp both actually carry 32 cells (4 types x 8
ranks), which the path/rank/aos_chain values already matched field-for-
field. Fixing the count keeps the traceability doc accurate as an audit
trail.
(cherry picked from commit b4282ffcec1a356f3b33e5aa032ffd37596d28de)
---
tests/contract/TRACEABILITY.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md
index f5b19d7a..c54aaed7 100644
--- a/tests/contract/TRACEABILITY.md
+++ b/tests/contract/TRACEABILITY.md
@@ -663,7 +663,7 @@ not fixed — both explained in full below.
`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 34 cells, same 10 IDSs), adapted to seed(HDF5)-then-
+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
From 3aeba0874041b37fe10307c9db6de0e235fff110 Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Wed, 15 Jul 2026 16:33:20 +0200
Subject: [PATCH 06/17] Add UDA unique-surface: cache modes, DD loading,
datapath, capability negotiation (issue #26)
Adds test_uda_unique_surface.cpp characterizing six areas through the
public C ABI against the docker/uda/ reference stack: URI option surface
(cache_mode/verbose/plugin/init_args/dd_version/host-port), cache-mode
invisibility (none/ids/struct), runtime DD loading (present/absent/
wrong-version, backed by a second pinned DD 3.42.0 in the Docker image),
datapath partial-get via cache_mode=ids, version-drift check inertness,
and the server-version-gated supportsTimeRangeOperation(). Found and
pinned (xfail, not fixed) a genuine server-side pulse-handle leak on
close, caused by readData/populate_cache building their IMAS::get() uri
without stripping cache_mode/verbose the way open/close do.
TRACEABILITY.md Part 5 gains a terminal-status row for every area.
(cherry picked from commit ef1e9735dbeaf6ed41d8afe605653b407e8907c5)
---
docker/uda/Dockerfile | 17 +
docker/uda/README.md | 1 +
tests/contract/CMakeLists.txt | 8 +-
tests/contract/TRACEABILITY.md | 124 ++++
tests/contract/test_uda_unique_surface.cpp | 806 +++++++++++++++++++++
5 files changed, 954 insertions(+), 2 deletions(-)
create mode 100644 tests/contract/test_uda_unique_surface.cpp
diff --git a/docker/uda/Dockerfile b/docker/uda/Dockerfile
index b9c5cdd4..f62d9246 100644
--- a/docker/uda/Dockerfile
+++ b/docker/uda/Dockerfile
@@ -85,6 +85,23 @@ RUN python3 -m venv /opt/ddvenv \
&& 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. The plugin is *built* at run
diff --git a/docker/uda/README.md b/docker/uda/README.md
index f5652db9..b62f83c9 100644
--- a/docker/uda/README.md
+++ b/docker/uda/README.md
@@ -18,6 +18,7 @@ Part 5 header:
| 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** |
| 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
diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt
index a1d5dbf2..6ec8ccce 100644
--- a/tests/contract/CMakeLists.txt
+++ b/tests/contract/CMakeLists.txt
@@ -77,6 +77,7 @@ add_executable(contract_tests
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)
@@ -239,7 +240,7 @@ 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*:UdaSmokeRoundTrip*:UdaEquilibriumSeedParity*:Uda/UdaRealPathMatrix.*:UdaBreadthTest*:UdaAosKnownDefects*:UdaSliceAndTimeRange*"
+ 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.*: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*"
@@ -254,8 +255,11 @@ if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
# Uda/UdaRealPathMatrix.*) and the AOS/occurrences/list_filled_paths/pulse-
# modes/errors/slice/timerange breadth suite (test_uda_breadth.cpp,
# UdaBreadthTest.*, UdaSliceAndTimeRange.*).
+ # 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.*:UdaBreadthTest*:UdaAosKnownDefects*:UdaSliceAndTimeRange*"
+ TEST_FILTER "UdaSmokeRoundTrip*:UdaEquilibriumSeedParity*:Uda/UdaRealPathMatrix.*:UdaBreadthTest*:UdaAosKnownDefects*:UdaSliceAndTimeRange*:UdaUniqueSurfaceTest*"
PROPERTIES LABELS "uda")
else()
gtest_discover_tests(contract_tests
diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md
index c54aaed7..7dc6c148 100644
--- a/tests/contract/TRACEABILITY.md
+++ b/tests/contract/TRACEABILITY.md
@@ -815,3 +815,127 @@ Same seed(HDF5)-then-reopen(UDA) shape throughout.
| 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, first half (issue #26): cache modes, runtime DD loading, datapath, capability negotiation
+
+`test_uda_unique_surface.cpp` — the first of two dedicated unique-surface
+files (the write-pin/fetch-mode half is the sibling issue). Six areas, all
+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.
+- **Cache-mode invisibility holds only where a mode's own population point is
+ reached.** `none` and `ids` agree on any field; `ids` populates the whole
+ (or datapath-scoped) IDS at `beginAction`, `struct` populates lazily, per
+ struct_array, only inside `beginArraystructAction` — so for a field
+ genuinely covered by all three (a static leaf nested one level inside a
+ struct_array), all three return the identical value. But a plain top-level
+ field is **never** populated under `cache_mode=struct` (it never enters any
+ struct_array), so it silently reads back as the ordinary absent-leaf
+ sentinel even though it was genuinely written — the caching strategy
+ working as designed (scoped to structs, not IDS-wide), not a defect.
+- **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 is a silent divergence,
+ not an error**: 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.
+- **`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`".
+- **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.
+- **`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).
+
+| 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 never populates a field outside every struct_array — scoped-caching design, not a defect | `UdaUniqueSurfaceTest.CacheModeStructNeverPopulatesFieldsOutsideAnyArraystruct` | divergence |
+| ↳ 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), absent (both failure messages), wrong-version (silent, no cross-check) | `UdaUniqueSurfaceTest.{DdPresentLoadsAndOpenSucceeds,DdAbsentNeitherEnvVarSetFailsWithClearMessage,DdAbsentFileMissingAtIdsDefPathFailsWithClearMessage,DdWrongVersionLoadsSilentlyWithNoCrossVersionCheck}` | covered |
+| ↳ wrong-version DD loads silently with no cross-version check — a genuine gap in the drift-detection story, distinct from a crash/refusal | (same test as above) | divergence |
+| `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 never fails on the version-drift comparison, both sides hardcoded placeholders | `UdaUniqueSurfaceTest.VersionDriftCheckNeverFiresRegardlessOfStoredPulse` | divergence |
+| Server-version-gated `supportsTimeRangeOperation()`: reference plugin 1.8.0 > 1.4.0 grants the capability | `UdaUniqueSurfaceTest.TimeRangeCapabilityGrantedByReferenceServerVersion180` | covered |
diff --git a/tests/contract/test_uda_unique_surface.cpp b/tests/contract/test_uda_unique_surface.cpp
new file mode 100644
index 00000000..40eab0c9
--- /dev/null
+++ b/tests/contract/test_uda_unique_surface.cpp
@@ -0,0 +1,806 @@
+// UDA-unique surface (issue #26, TRACEABILITY.md Part 5): the first half of
+// UDA's unique surface -- cache modes, runtime DD loading, datapath, and
+// capability negotiation. The write-pin / fetch-mode half is the sibling
+// issue. Six areas, each a dedicated block below:
+// 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; 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).
+//
+// 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 {
+
+std::string uda_uri_with(const std::string& pulse_dir, const std::string& extra_query = "") {
+ std::string uri = al_contract::uda_uri_base() +
+ "?backend=hdf5&cache_mode=none&path=" + pulse_dir;
+ if (!extra_query.empty()) uri += "&" + extra_query;
+ return uri;
+}
+
+// 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;
+ }
+
+ // 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 = "imas:hdf5?path=" + 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 = uda_uri_with(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_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 = uda_uri_with(pulse_dir, "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_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 = uda_uri_with(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_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 = uda_uri_with(pulse_dir, "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_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 = uda_uri_with(pulse_dir, "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_end_action(op);
+ 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 = uda_uri_with(pulse_dir, "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_end_action(op);
+ 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_close_pulse(pulse_ctx, CLOSE_PULSE);
+}
+
+// ===========================================================================
+// Area 2: cache-mode invisibility.
+// ===========================================================================
+// The three cache modes are a performance knob: 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, and *only* that (a plain
+// top-level field is never in scope for struct mode -- see the second test
+// below). For a field genuinely covered by all three (a static, non-dynamic
+// leaf nested one level inside a struct_array, so struct mode's own populate
+// point is reached), all three must return the exact same physics value.
+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 = "imas:hdf5?path=" + 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_uri_base() +
+ "?backend=hdf5&cache_mode=" + std::string(mode) +
+ "&path=" + 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, "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_end_action(raos);
+ al_end_action(op);
+ al_close_pulse(pulse_ctx, CLOSE_PULSE);
+ }
+}
+
+// Cache-mode invisibility does NOT extend to struct mode's own *scope*:
+// 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 -- silently wrong-looking unless you know the scope rule. This is
+// the caching strategy working as designed, not a defect: struct mode is
+// scoped to structs, not the whole IDS.
+TEST_F(UdaUniqueSurfaceTest, CacheModeStructNeverPopulatesFieldsOutsideAnyArraystruct) {
+ const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2);
+ const std::string uri = al_contract::uda_uri_base() +
+ "?backend=hdf5&cache_mode=struct&path=" + 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, "",
+ 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)
+ << "cache_mode=struct never populates a field that lives outside "
+ "every struct_array the caller enters, even though it was "
+ "genuinely written -- struct mode's scope is per-struct_array, "
+ "not IDS-wide like ids mode";
+ al_end_action(op);
+ 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 = "imas:hdf5?path=" + 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_uri_base() +
+ "?backend=hdf5&cache_mode=" +
+ std::string(cache_mode) + "&path=" + 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);
+ std::vector shape;
+ std::vector data;
+ EXPECT_EQ(al_contract::read_data(op, equilibrium_seed::kScalar, 0,
+ &shape, &data)
+ .code,
+ 0);
+ 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 = uda_uri_with(pulse_dir);
+ int pulse_ctx = -1;
+ AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx));
+ 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 = uda_uri_with(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_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 = uda_uri_with(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_close_pulse(pulse_ctx, CLOSE_PULSE);
+}
+
+// Wrong-version: $IDSDEF_PATH points at a real, structurally valid, but
+// *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).
+TEST_F(UdaUniqueSurfaceTest, DdWrongVersionLoadsSilentlyWithNoCrossVersionCheck) {
+ 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 = uda_uri_with(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)
+ << "no signal distinguishes this from a correct-version read -- the "
+ "version drift is silent";
+ al_end_action(op);
+ 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 = "imas:hdf5?path=" + 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_uri_base() +
+ "?backend=hdf5&cache_mode=ids&path=" + 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,
+ /*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_end_action(op);
+ 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 = "imas:hdf5?path=" + 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 = uda_uri_with(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_end_action(op);
+ }
+ al_close_pulse(pulse_ctx, CLOSE_PULSE);
+}
+
+} // namespace
+
+#endif // AL_CONTRACT_HAVE_UDA
From 2b5069ed19cb70338d0b146c29e5e23a021e5c7d Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Wed, 15 Jul 2026 21:53:00 +0200
Subject: [PATCH 07/17] Add UDA unique-surface: fetch mode, local_cache,
write-divergence pin, remote write/delete pins (issue #27)
Second half of the UDA-unique-surface tier (sibling of #26): fetch mode's
download/handoff/cache-reuse, local_cache override, the stale-cache
write-divergence pin, and remote write/delete pinned behavior, all driven
against the real reference stack (docker/uda/). One new genuine defect
discovered and pinned (DISABLED_/tripwire): remote write silently reports
success without persisting, unlike remote delete's clean refusal. Also
corrects docker/uda/README.md's unverified spike assumption that the BYTES
plugin wasn't registered on the reference image -- it is, by default.
(cherry picked from commit 623cf177069e0d398e2a953bcc0fec22371b27e9)
---
docker/uda/README.md | 51 +--
tests/contract/TRACEABILITY.md | 85 ++++-
tests/contract/test_uda_unique_surface.cpp | 394 ++++++++++++++++++++-
3 files changed, 498 insertions(+), 32 deletions(-)
diff --git a/docker/uda/README.md b/docker/uda/README.md
index b62f83c9..80d2ebad 100644
--- a/docker/uda/README.md
+++ b/docker/uda/README.md
@@ -118,29 +118,40 @@ subtask (#21 area 3).
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:** the reference `IMAS` server plugin
- has no `writeData`/`deleteData` handler. Issuing the exact directives the
- client emits to the running server (`uda_cli --request "IMAS::writeData(...)"`
- / `"IMAS::deleteData(...)"`) both return `[handle_request]: Unknown function
- requested!`. The UDA backend catches the resulting `uda::UDAException` and
- re-throws it as an `ALException` carrying that text, which surfaces at the C
- ABI as a non-zero `al_status_t`. This empirically confirms the static finding
- that remote mode is read-only by construction; the exact C-ABI-level pin
- (correct-contract + tripwire, driven through `al_write_data`) is the
- write-path subtask's job, not the spike's.
-- **Fetch-mode cache-directory naming/reuse semantics** (from source; full
- empirical characterization deferred to the fetch-mode subtask): the local
- cache dir is the `local_cache` URI option when given, else
+- **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), so a mutated or stale local copy
- persists across sessions — the divergence the fetch-mode subtask pins.
- Empirical exercise is deferred deliberately, not overlooked: fetch mode
- downloads via a `BYTES::read` directive to a **`BYTES` server plugin** that
- this reference stack does not register (only `IMAS` is), so a complete
- download+reuse round trip needs that extra server plugin — which the fetch
- subtask stands up. The spike's gate does not depend on it.
+ (`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**,
diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md
index 7dc6c148..657827d0 100644
--- a/tests/contract/TRACEABILITY.md
+++ b/tests/contract/TRACEABILITY.md
@@ -816,15 +816,14 @@ Same seed(HDF5)-then-reopen(UDA) shape throughout.
| 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, first half (issue #26): cache modes, runtime DD loading, datapath, capability negotiation
+## UDA-unique surface (issues #26 + #27): cache modes, runtime DD loading, datapath, capability negotiation, fetch mode, write/delete pins
-`test_uda_unique_surface.cpp` — the first of two dedicated unique-surface
-files (the write-pin/fetch-mode half is the sibling issue). Six areas, all
-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.
+`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:**
@@ -927,6 +926,61 @@ here alongside the header's existing stack identifiers.
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 limitation — `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 |
|---|---|---|
@@ -939,3 +993,18 @@ here alongside the header's existing stack identifiers.
| `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 never fails on the version-drift comparison, both sides hardcoded placeholders | `UdaUniqueSurfaceTest.VersionDriftCheckNeverFiresRegardlessOfStoredPulse` | divergence |
| 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.
diff --git a/tests/contract/test_uda_unique_surface.cpp b/tests/contract/test_uda_unique_surface.cpp
index 40eab0c9..f612fcab 100644
--- a/tests/contract/test_uda_unique_surface.cpp
+++ b/tests/contract/test_uda_unique_surface.cpp
@@ -1,7 +1,7 @@
-// UDA-unique surface (issue #26, TRACEABILITY.md Part 5): the first half of
-// UDA's unique surface -- cache modes, runtime DD loading, datapath, and
-// capability negotiation. The write-pin / fetch-mode half is the sibling
-// issue. Six areas, each a dedicated block below:
+// 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; cache
@@ -14,6 +14,13 @@
// 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
@@ -94,6 +101,22 @@ class UdaUniqueSurfaceTest : public ::testing::Test {
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
@@ -801,6 +824,369 @@ TEST_F(UdaUniqueSurfaceTest, TimeRangeCapabilityGrantedByReferenceServerVersion1
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_end_action(op);
+ 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_end_action(op);
+ 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_end_action(op);
+ al_close_pulse(pulse_ctx, CLOSE_PULSE);
+}
+
+// ===========================================================================
+// Area 9: the stale-cache / write-divergence pin -- the trap a physicist
+// 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
+// limitation 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 = uda_uri_with(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_end_action(op);
+ 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_end_action(op);
+ 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_uri_base() +
+ "?backend=hdf5&cache_mode=none&path=" + 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_end_action(op);
+ 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_end_action(op);
+ 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 = uda_uri_with(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_end_action(op);
+ al_close_pulse(pulse_ctx, CLOSE_PULSE);
+}
+
} // namespace
#endif // AL_CONTRACT_HAVE_UDA
From 71ef604998085321b323b8ff959903d52a41648d Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 10:12:53 +0200
Subject: [PATCH 08/17] Close TRACEABILITY Part 5: cross-reference notes +
closure summary (issue #28)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Audited Part 5 against issue #28's acceptance criteria: zero blank rows,
every row at a terminal status, every xfail's DISABLED_/tripwire pair
present in test_uda_breadth.cpp/test_uda_unique_surface.cpp, every
divergence explained in prose, and the pinned reference stack matching
docker/uda/ verbatim — all already true from issues #25/#26/#27. The one
gap: Part 1/Part 2's shared always-on rows had no "UDA: see Part 5" note
mirroring their existing "MDSplus: see Part 4" convention (added by issue
mirroring Part 4's own issue-#18 closure paragraph.
(cherry picked from commit c00552d7e63b60bddf2b43d33224ea71b1db0112)
---
tests/contract/TRACEABILITY.md | 47 ++++++++++++++++++++++------------
1 file changed, 31 insertions(+), 16 deletions(-)
diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md
index 657827d0..f51df7b3 100644
--- a/tests/contract/TRACEABILITY.md
+++ b/tests/contract/TRACEABILITY.md
@@ -93,9 +93,9 @@ 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: `OPEN_PULSE` genuinely round-trips to the server and fails when the remote pulse dir is absent, genuine parity — 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) |
+| `al_close_pulse` closes (`CLOSE_PULSE`) / erases (`ERASE_PULSE`) | :109-116 | ✓ (Memory) | ✓ (HDF5, ASCII) | `CLOSE_PULSE` asserted `code==0` in every suite. UDA: closes locally but leaks a server-side pulse handle (uri-stripping mismatch) — xfail, 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` | **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 |
@@ -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 |
@@ -1008,3 +1008,18 @@ success without persisting, unlike remote delete's clean refusal. The
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 28 rows is terminal (17 `covered`/`covered (indirect)`, 5
+`divergence`, 5 `xfail`, 1 `terminal-gap`), each xfail row's `DISABLED_`
+correct-contract test and current-behavior tripwire both verified present in
+`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, 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).
From ba58c8358f1414fd7b238c945432a866d9e390be Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 10:26:54 +0200
Subject: [PATCH 09/17] Add non-blocking uda-contract CI leg (issue #29)
Mirrors mdsplus-contract.yml: caches the docker/uda/ reference-stack
image across pushes, verifies its pinned stack identifiers against
TRACEABILITY.md Part 5's header, then runs the UDA contract tier via
run.sh. continue-on-error keeps it informational now that issue #28
has triaged the whole baseline to terminal statuses.
(cherry picked from commit d73f13476d7e03ee136ce4b4a7de96ac14403f37)
---
.github/workflows/uda-contract.yml | 84 ++++++++++++++++++++++++++++++
1 file changed, 84 insertions(+)
create mode 100644 .github/workflows/uda-contract.yml
diff --git a/.github/workflows/uda-contract.yml b/.github/workflows/uda-contract.yml
new file mode 100644
index 00000000..135a7a64
--- /dev/null
+++ b/.github/workflows/uda-contract.yml
@@ -0,0 +1,84 @@
+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}")
+ 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} dd=${dd_version}"
+ header=tests/contract/TRACEABILITY.md
+ grep -q "tag \*\*${uda_server}\*\*" "${header}"
+ grep -q "tag \*\*${uda_plugins}\*\*" "${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."
From dbb054817ba2cca5ad33d9d0629f3efcd8e2a3e8 Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 10:56:20 +0200
Subject: [PATCH 10/17] Characterize UDA pulse lifecycle modes
(cherry picked from commit 2612c1d2d71074bcde6dd95b9bc7c805fdb693ac)
---
tests/contract/TRACEABILITY.md | 31 ++++++--
tests/contract/test_uda_breadth.cpp | 107 ++++++++++++++++++++++++++++
2 files changed, 131 insertions(+), 7 deletions(-)
diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md
index f51df7b3..8914cebc 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. UDA: `OPEN_PULSE` genuinely round-trips to the server and fails when the remote pulse dir is absent, genuine parity — see Part 5 | 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. UDA: closes locally but leaks a server-side pulse handle (uri-stripping mismatch) — xfail, 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` | **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** |
@@ -725,9 +725,10 @@ backends diverge for different reasons on the same cell.
## 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
-from `FUNCTIONALITY_INVENTORY.md` Part 1 not already covered by the tracer
-bullet, the equilibrium-seed parity fixture, or the real-path matrix above.
-Same seed(HDF5)-then-reopen(UDA) shape throughout.
+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:**
@@ -766,6 +767,20 @@ Same seed(HDF5)-then-reopen(UDA) shape throughout.
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
@@ -810,7 +825,9 @@ Same seed(HDF5)-then-reopen(UDA) shape throughout.
| ↳ 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 `_` naming convention, ownership pinned | `UdaBreadthTest.OccurrencesListsWrittenOccurrencesThroughReopen` | covered |
| `al_list_filled_paths` real through UDA remote mode (`backend=hdf5` only), ownership pinned | `UdaBreadthTest.ListFilledPathsThroughReopen` | covered |
-| `OPEN_PULSE` refuses an absent remote pulse — genuine parity with the on-disk backends' `DataEntryModes.OpenPulseFailsWhenAbsent` | `UdaBreadthTest.OpenPulseFailsWhenRemotePathAbsent` | 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 |
diff --git a/tests/contract/test_uda_breadth.cpp b/tests/contract/test_uda_breadth.cpp
index ce77426d..c9749892 100644
--- a/tests/contract/test_uda_breadth.cpp
+++ b/tests/contract/test_uda_breadth.cpp
@@ -37,6 +37,37 @@ std::string uda_uri_for(const std::string& pulse_dir) {
pulse_dir;
}
+// 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 = uda_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_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(); }
@@ -319,6 +350,82 @@ TEST_F(UdaBreadthTest, OpenPulseFailsWhenRemotePathAbsent) {
if (s.code == 0) al_close_pulse(pulse_ctx, CLOSE_PULSE);
}
+TEST_F(UdaBreadthTest, ForceOpenPulseCreatesRemotePulseWhenAbsent) {
+ const std::string pulse_dir = fresh_pulse_dir();
+ const std::string uda_uri = uda_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 = uda_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_close_pulse(create_ctx, CLOSE_PULSE);
+}
+
+TEST_F(UdaBreadthTest, ForceCreatePulseAcceptsExistingRemotePulse) {
+ const std::string pulse_dir = fresh_pulse_dir();
+ const std::string uda_uri = uda_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
From cd1f8c9db3e6d880f498476b2a989142091d5c47 Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 10:57:17 +0200
Subject: [PATCH 11/17] Pin the UDA-Plugins source commit
(cherry picked from commit e4911ec7c841dd401b7538ab6a67c7ea86980a57)
---
.github/workflows/uda-contract.yml | 4 +++-
docker/uda/Dockerfile | 25 ++++++++++++++++++++-----
docker/uda/README.md | 13 ++++++++++---
tests/contract/TRACEABILITY.md | 2 +-
4 files changed, 34 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/uda-contract.yml b/.github/workflows/uda-contract.yml
index 135a7a64..adfbc9d2 100644
--- a/.github/workflows/uda-contract.yml
+++ b/.github/workflows/uda-contract.yml
@@ -65,11 +65,13 @@ jobs:
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} dd=${dd_version}"
+ 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."
diff --git a/docker/uda/Dockerfile b/docker/uda/Dockerfile
index f62d9246..fe5eb95c 100644
--- a/docker/uda/Dockerfile
+++ b/docker/uda/Dockerfile
@@ -10,7 +10,8 @@
#
# 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
+# - IMAS UDA plugin .. iterorganization/UDA-Plugins tag 1.8.0,
+# commit ede25b921081d8fc2d66c5b5ca152c664b50ee78
# - Data Dictionary .. IDSDef.xml DD 4.1.1
FROM ubuntu:24.04
@@ -19,10 +20,12 @@ 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="1.8.0"
+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 ---
@@ -104,12 +107,24 @@ 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. The plugin is *built* at run
-# time (docker/uda/run.sh) because it must link the workspace's IMAS-Core
+# 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
+ 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
diff --git a/docker/uda/README.md b/docker/uda/README.md
index 80d2ebad..7f707dd8 100644
--- a/docker/uda/README.md
+++ b/docker/uda/README.md
@@ -16,7 +16,7 @@ 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** |
+| `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 |
@@ -27,6 +27,12 @@ running stack): **1.8.0**. Since 1.8.0 > 1.4.0, the UDA backend's
(`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,
@@ -155,7 +161,8 @@ subtask (#21 area 3).
- **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**, DD **4.1.1**, base `ubuntu:24.04` (arm64). Also
- recorded as `org.iter.imas-core.*` image labels.
+ `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/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md
index 8914cebc..ffdda925 100644
--- a/tests/contract/TRACEABILITY.md
+++ b/tests/contract/TRACEABILITY.md
@@ -617,7 +617,7 @@ whole tier.
| 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** |
+| `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 |
From 42724442ad04a93cc4deeb0597990d3af2af61c4 Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 10:57:57 +0200
Subject: [PATCH 12/17] Pin exact UDA complex AOS defects
(cherry picked from commit 5165a137cc730641606d7055f12cb4ba1e1701c5)
---
tests/contract/CMakeLists.txt | 7 +-
tests/contract/TRACEABILITY.md | 16 ++--
tests/contract/test_uda_real_paths.cpp | 103 ++++++++++++++++++++-----
3 files changed, 96 insertions(+), 30 deletions(-)
diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt
index 6ec8ccce..a9ab5415 100644
--- a/tests/contract/CMakeLists.txt
+++ b/tests/contract/CMakeLists.txt
@@ -240,7 +240,7 @@ 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*:UdaSmokeRoundTrip*:UdaEquilibriumSeedParity*:Uda/UdaRealPathMatrix.*:UdaBreadthTest*:UdaAosKnownDefects*:UdaSliceAndTimeRange*:UdaUniqueSurfaceTest*"
+ 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*"
@@ -254,12 +254,13 @@ if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
# 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.*).
+ # 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.*:UdaBreadthTest*:UdaAosKnownDefects*:UdaSliceAndTimeRange*:UdaUniqueSurfaceTest*"
+ TEST_FILTER "UdaSmokeRoundTrip*:UdaEquilibriumSeedParity*:Uda/UdaRealPathMatrix.*:Uda/UdaRealPathMatrixKnownDefects.*:UdaBreadthTest*:UdaAosKnownDefects*:UdaSliceAndTimeRange*:UdaUniqueSurfaceTest*"
PROPERTIES LABELS "uda")
else()
gtest_discover_tests(contract_tests
diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md
index ffdda925..fa676ebd 100644
--- a/tests/contract/TRACEABILITY.md
+++ b/tests/contract/TRACEABILITY.md
@@ -709,18 +709,22 @@ backends diverge for different reasons on the same cell.
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. Pinned
- centrally by `UdaAosKnownDefects.DISABLED_DynamicLeafInsideAosRoundTrips` +
- tripwire `UdaAosKnownDefects.DynamicLeafInsideAosCurrentlyReturnsSentinel`
- (`test_uda_breadth.cpp`); the three affected matrix cells cite it via their
- own `known_defect_reason` rather than duplicating the pin three times.
+ 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) — **divergence, not a new UDA defect**: seeding this cell would reproduce the already-pinned HDF5 CHAR-scalar crash (`RoundTripKnownDefects`/Part 1) in fixture setup; not attempted | `Uda/UdaRealPathMatrix.RealPathRoundTrip/CHAR_r0` (`GTEST_SKIP()`, not run) | divergence |
| ↳ 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) — see `UdaAosKnownDefects` below for the pinned tripwire | `Uda/UdaRealPathMatrix.RealPathRoundTrip/{COMPLEX_r1,COMPLEX_r3,COMPLEX_r5}` (`GTEST_SKIP()`, known-defect) | **xfail** |
+| ↳ 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)
diff --git a/tests/contract/test_uda_real_paths.cpp b/tests/contract/test_uda_real_paths.cpp
index 10aeaba9..2782d882 100644
--- a/tests/contract/test_uda_real_paths.cpp
+++ b/tests/contract/test_uda_real_paths.cpp
@@ -61,12 +61,28 @@ struct PathSpec {
// 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 here (not run
- // into the known-wrong assertion) and the defect itself is pinned once,
- // centrally, by UdaAosKnownDefects's DISABLED_/tripwire pair.
+ // 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
@@ -136,29 +152,17 @@ const PathSpec kPathSpecs[] = {
// inside-AOS defect (test_uda_breadth.cpp): the field comes back
// empty/absent through UDA remote mode + backend=hdf5 instead of what
// was written.
- {DType::Complex, 1, "waves", {"coherent_wave", "full_wave", "e_field/plus"},
- "values", nullptr, nullptr,
- "dynamic leaf nested in a struct_array (3 AOS levels) -- see "
- "UdaAosKnownDefects.DynamicLeafInsideAosCurrentlyReturnsSentinel "
- "(test_uda_breadth.cpp)"},
+ 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.
- {DType::Complex, 3, "runaway_electrons", {"distribution/markers"},
- "orbit_integrals_instant/values", nullptr, nullptr,
- "dynamic leaf nested in a struct_array (1 AOS level) -- see "
- "UdaAosKnownDefects.DynamicLeafInsideAosCurrentlyReturnsSentinel "
- "(test_uda_breadth.cpp)"},
+ kComplexR3DynamicAosSpec,
{DType::Complex, 4, "gyrokinetics_local", {},
"non_linear/fields_4d/phi_potential_perturbed_norm", nullptr},
// Same known defect as COMPLEX r1/r3 above.
- {DType::Complex, 5, "runaway_electrons", {"distribution/markers"},
- "orbit_integrals/values", nullptr, nullptr,
- "dynamic leaf nested in a struct_array (1 AOS level) -- see "
- "UdaAosKnownDefects.DynamicLeafInsideAosCurrentlyReturnsSentinel "
- "(test_uda_breadth.cpp)"},
+ kComplexR5DynamicAosSpec,
{DType::Complex, 6, nullptr, {}, nullptr,
"DD 4.1.1 has no CPX_6D anywhere"},
{DType::Complex, 7, nullptr, {}, nullptr,
@@ -191,8 +195,9 @@ void read_leaf_and_expect(int ctx, const char* leaf, int rank,
// 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(const PathSpec& spec) {
+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;
@@ -263,7 +268,7 @@ void run_cell(const PathSpec& spec) {
ctx = aos;
}
- read_leaf_and_expect(ctx, spec.leaf, spec.rank, shape, written);
+ expect_read(ctx, spec, shape, written);
for (auto it = aos_ctxs.rbegin(); it != aos_ctxs.rend(); ++it) {
AL_EXPECT_OK(al_end_action(*it));
@@ -273,6 +278,17 @@ void run_cell(const PathSpec& spec) {
}
}
+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(); }
@@ -320,6 +336,51 @@ std::string NameUdaRealPathMatrixCase(
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
From 3d3e4f6b0f73b6185784d53e48239ad92401e204 Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 10:58:22 +0200
Subject: [PATCH 13/17] Correct UDA traceability closure counts
(cherry picked from commit 8d8a284261b3658fbc1dbab28f54edd4f435465c)
---
tests/contract/TRACEABILITY.md | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md
index fa676ebd..26acd251 100644
--- a/tests/contract/TRACEABILITY.md
+++ b/tests/contract/TRACEABILITY.md
@@ -1031,13 +1031,15 @@ 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 28 rows is terminal (17 `covered`/`covered (indirect)`, 5
-`divergence`, 5 `xfail`, 1 `terminal-gap`), each xfail row's `DISABLED_`
+of its 31 rows is terminal (19 `covered`/`covered (indirect)`, 5
+`divergence`, 6 `xfail`, 1 `terminal-gap`), each xfail row's `DISABLED_`
correct-contract test and current-behavior tripwire both verified present in
-`test_uda_breadth.cpp`/`test_uda_unique_surface.cpp`, and every divergence
+`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, DD 4.1.1, plus the older DD 3.42.0 pin) match `docker/uda/`
+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
From 7f4b9354447cf6e1414adbfec15fe9921f7e5c1b Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 11:28:57 +0200
Subject: [PATCH 14/17] Skip UDA contracts when the server is unreachable
(cherry picked from commit 6a9b8e49dcd948a152fedf5246b51bbb88196e05)
---
tests/contract/al_contract.h | 76 +++++++++++++++++++++++++++--
tests/contract/test_uda_breadth.cpp | 2 +
2 files changed, 74 insertions(+), 4 deletions(-)
diff --git a/tests/contract/al_contract.h b/tests/contract/al_contract.h
index fcc86d0a..0bf665c0 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
@@ -83,19 +91,79 @@ inline constexpr const char* kUnregisteredPluginName =
// 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 is the
-// UDA_HOST env var, which docker/uda/run.sh exports before running the suite and
-// which a plain workstation never sets. Same macro-not-function rationale as the
-// MDSplus skip above: GTEST_SKIP() must unwind the calling SetUp()/TEST body.
+// 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
diff --git a/tests/contract/test_uda_breadth.cpp b/tests/contract/test_uda_breadth.cpp
index c9749892..47ec0d5d 100644
--- a/tests/contract/test_uda_breadth.cpp
+++ b/tests/contract/test_uda_breadth.cpp
@@ -194,6 +194,7 @@ std::vector dynamic_leaf_inside_aos_values_via_uda() {
// 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) {
@@ -207,6 +208,7 @@ TEST(UdaAosKnownDefects, DISABLED_DynamicLeafInsideAosRoundTrips) {
// 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) {
From 3290024a5ac461a447ec8acf065c9f94ce81e3ee Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 11:29:10 +0200
Subject: [PATCH 15/17] Read every reported UDA occurrence
(cherry picked from commit 09bb450fd98d7f4ec443102cf562e21a44e8f6cd)
---
tests/contract/test_uda_breadth.cpp | 35 ++++++++++++++++++++++-------
1 file changed, 27 insertions(+), 8 deletions(-)
diff --git a/tests/contract/test_uda_breadth.cpp b/tests/contract/test_uda_breadth.cpp
index 47ec0d5d..01ae04d2 100644
--- a/tests/contract/test_uda_breadth.cpp
+++ b/tests/contract/test_uda_breadth.cpp
@@ -226,11 +226,16 @@ TEST(UdaAosKnownDefects, DynamicLeafInsideAosCurrentlyReturnsSentinel) {
// 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 "_" naming convention "equilibrium_2" -- then listed
-// through UDA remote mode.
+// 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 -----------------------
{
@@ -239,17 +244,17 @@ TEST_F(UdaBreadthTest, OccurrencesListsWrittenOccurrencesThroughReopen) {
AL_ASSERT_OK(al_begin_dataentry_action(hdf5_uri.c_str(),
FORCE_CREATE_PULSE, &pulse_ctx));
- for (const char* name : {"equilibrium", "equilibrium_2"}) {
+ for (size_t i = 0; i < 2; ++i) {
int op = -1;
- AL_ASSERT_OK(
- al_begin_global_action(pulse_ctx, name, "", WRITE_OP, &op));
+ 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, {}, {6.2}));
+ op, equilibrium_seed::kScalar, {}, {kOccurrenceValues[i]}));
AL_ASSERT_OK(al_end_action(op));
}
- EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0);
+ AL_ASSERT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE));
}
// --- list occurrences through UDA, remote mode --------------------------
@@ -268,7 +273,21 @@ TEST_F(UdaBreadthTest, OccurrencesListsWrittenOccurrencesThroughReopen) {
EXPECT_EQ(occ[1], 2);
free(occ); // malloc'd by UDABackend::get_occurrences.
- EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0);
+ // 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));
}
}
From c2241f934ae92a510e30934e5b50a919277a0ea4 Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 11:31:31 +0200
Subject: [PATCH 16/17] Pin UDA characterization defects accurately
(cherry picked from commit cf123d6b384e6f8766e67afd96467c1fd1bbba8a)
---
tests/contract/TRACEABILITY.md | 80 +++----
tests/contract/test_uda_unique_surface.cpp | 237 +++++++++++++++------
2 files changed, 211 insertions(+), 106 deletions(-)
diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md
index 26acd251..0a8c076e 100644
--- a/tests/contract/TRACEABILITY.md
+++ b/tests/contract/TRACEABILITY.md
@@ -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 |
|---|---|---|
@@ -675,16 +675,15 @@ backends diverge for different reasons on the same cell.
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 divergence for a different reason than MDSplus's**: MDSplus's
- own CHAR r0 divergence is a clean, typed dimension-mismatch refusal from its
- *real* backend. UDA's fixture never even reaches that question: seeding this
- cell requires writing a dim=0 CHAR value through the *plain HDF5 backend*
- used for fixture setup, which reproduces the pre-existing, independently-
- pinned `RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips` /
- `RoundTripKnownDefectsDeath.Hdf5CharScalarCurrentlyCrashes` crash
- (TRACEABILITY.md Part 1) on this real DD leaf too. Not run, to avoid
- crashing the seed step — a fixture-setup-backend defect already pinned
- elsewhere, not a new UDA finding.
+- **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,
@@ -722,7 +721,7 @@ backends diverge for different reasons on the same cell.
| 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) — **divergence, not a new UDA defect**: seeding this cell would reproduce the already-pinned HDF5 CHAR-scalar crash (`RoundTripKnownDefects`/Part 1) in fixture setup; not attempted | `Uda/UdaRealPathMatrix.RealPathRoundTrip/CHAR_r0` (`GTEST_SKIP()`, not run) | divergence |
+| ↳ 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** |
@@ -827,7 +826,7 @@ the lifecycle cases drive the remote UDA URI directly through the C ABI.
|---|---|---|
| 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 `_` naming convention, ownership pinned | `UdaBreadthTest.OccurrencesListsWrittenOccurrencesThroughReopen` | covered |
+| `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) |
@@ -865,16 +864,18 @@ recorded here alongside the header's existing stack identifiers.
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.
-- **Cache-mode invisibility holds only where a mode's own population point is
- reached.** `none` and `ids` agree on any field; `ids` populates the whole
- (or datapath-scoped) IDS at `beginAction`, `struct` populates lazily, per
- struct_array, only inside `beginArraystructAction` — so for a field
- genuinely covered by all three (a static leaf nested one level inside a
- struct_array), all three return the identical value. But a plain top-level
- field is **never** populated under `cache_mode=struct` (it never enters any
- struct_array), so it silently reads back as the ordinary absent-leaf
- sentinel even though it was genuinely written — the caching strategy
- working as designed (scoped to structs, not IDS-wide), not a defect.
+- **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
@@ -912,9 +913,9 @@ recorded here alongside the header's existing stack identifiers.
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 is a silent divergence,
- not an error**: 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
+ $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
@@ -922,7 +923,9 @@ recorded here alongside the header's existing stack identifiers.
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.
+ 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
@@ -933,13 +936,18 @@ recorded here alongside the header's existing stack identifiers.
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`".
-- **Version-drift check inertness, confirmed from source**:
+- **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.
+ 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
@@ -976,7 +984,7 @@ recorded here alongside the header's existing stack identifiers.
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 limitation — `HDF5Writer::write_ND_Data` unconditionally calls
+ 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
@@ -1007,12 +1015,12 @@ recorded here alongside the header's existing stack identifiers.
|---|---|---|
| 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 never populates a field outside every struct_array — scoped-caching design, not a defect | `UdaUniqueSurfaceTest.CacheModeStructNeverPopulatesFieldsOutsideAnyArraystruct` | divergence |
+| ↳ `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), absent (both failure messages), wrong-version (silent, no cross-check) | `UdaUniqueSurfaceTest.{DdPresentLoadsAndOpenSucceeds,DdAbsentNeitherEnvVarSetFailsWithClearMessage,DdAbsentFileMissingAtIdsDefPathFailsWithClearMessage,DdWrongVersionLoadsSilentlyWithNoCrossVersionCheck}` | covered |
-| ↳ wrong-version DD loads silently with no cross-version check — a genuine gap in the drift-detection story, distinct from a crash/refusal | (same test as above) | divergence |
+| 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 never fails on the version-drift comparison, both sides hardcoded placeholders | `UdaUniqueSurfaceTest.VersionDriftCheckNeverFiresRegardlessOfStoredPulse` | divergence |
+| 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 |
@@ -1031,8 +1039,8 @@ 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)`, 5
-`divergence`, 6 `xfail`, 1 `terminal-gap`), each xfail row's `DISABLED_`
+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
diff --git a/tests/contract/test_uda_unique_surface.cpp b/tests/contract/test_uda_unique_surface.cpp
index f612fcab..63964e44 100644
--- a/tests/contract/test_uda_unique_surface.cpp
+++ b/tests/contract/test_uda_unique_surface.cpp
@@ -4,8 +4,8 @@
// 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; cache
-// cleared on close).
+// 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
@@ -169,7 +169,9 @@ TEST_F(UdaUniqueSurfaceTest, InvalidCacheModeThrows) {
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_close_pulse(pulse_ctx, CLOSE_PULSE);
+ if (s.code == 0) {
+ AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE));
+ }
}
// verbose: "1"/"true" -> debug tracing on stdout; anything else (including
@@ -183,7 +185,7 @@ TEST_F(UdaUniqueSurfaceTest, VerboseTrueEmitsDebugTracingOnStdout) {
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_close_pulse(pulse_ctx, CLOSE_PULSE);
+ 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: "
@@ -198,7 +200,7 @@ TEST_F(UdaUniqueSurfaceTest, VerboseAbsentEmitsNoDebugTracing) {
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_close_pulse(pulse_ctx, CLOSE_PULSE);
+ 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: "
@@ -217,7 +219,9 @@ TEST_F(UdaUniqueSurfaceTest, PluginOptionNamingUnregisteredPluginFailsAtOpen) {
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_close_pulse(pulse_ctx, CLOSE_PULSE);
+ if (s.code == 0) {
+ AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE));
+ }
}
// init_args: passed through to "::init()" with ';' rewritten to
@@ -244,8 +248,8 @@ TEST_F(UdaUniqueSurfaceTest, InitArgsAcceptedAndIgnoredByReferencePlugin) {
ASSERT_EQ(data.size(), 1u);
EXPECT_EQ(data[0], 6.2) << "arbitrary init_args must not change read "
"behavior against the reference plugin";
- al_end_action(op);
- al_close_pulse(pulse_ctx, CLOSE_PULSE);
+ 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
@@ -273,8 +277,8 @@ TEST_F(UdaUniqueSurfaceTest, DdVersionOverrideAcceptedWithNoObservableEffect) {
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_end_action(op);
- al_close_pulse(pulse_ctx, CLOSE_PULSE);
+ 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 +
@@ -292,20 +296,23 @@ TEST_F(UdaUniqueSurfaceTest, WrongPortFailsToConnectDistinctlyFromRequestFailure
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_close_pulse(pulse_ctx, CLOSE_PULSE);
+ if (s.code == 0) {
+ AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE));
+ }
}
// ===========================================================================
// Area 2: cache-mode invisibility.
// ===========================================================================
-// The three cache modes are a performance knob: 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, and *only* that (a plain
-// top-level field is never in scope for struct mode -- see the second test
-// below). For a field genuinely covered by all three (a static, non-dynamic
-// leaf nested one level inside a struct_array, so struct mode's own populate
-// point is reached), all three must return the exact same physics value.
+// 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)
@@ -356,13 +363,15 @@ TEST_F(UdaUniqueSurfaceTest, CacheModeNoneIdsStructAgreeForAosCoveredField) {
<< "cache_mode=" << mode << " must return the same value as every "
"other cache mode for a field its own "
"population point covers";
- al_end_action(raos);
- al_end_action(op);
- al_close_pulse(pulse_ctx, CLOSE_PULSE);
+ AL_EXPECT_OK(al_end_action(raos));
+ AL_EXPECT_OK(al_end_action(op));
+ AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE));
}
}
-// Cache-mode invisibility does NOT extend to struct mode's own *scope*:
+// 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 ->
@@ -372,10 +381,38 @@ TEST_F(UdaUniqueSurfaceTest, CacheModeNoneIdsStructAgreeForAosCoveredField) {
// 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 -- silently wrong-looking unless you know the scope rule. This is
-// the caching strategy working as designed, not a defect: struct mode is
-// scoped to structs, not the whole IDS.
-TEST_F(UdaUniqueSurfaceTest, CacheModeStructNeverPopulatesFieldsOutsideAnyArraystruct) {
+// 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_uri_base() +
+ "?backend=hdf5&cache_mode=struct&path=" + 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, "",
+ 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_uri_base() +
"?backend=hdf5&cache_mode=struct&path=" + pulse_dir;
@@ -394,12 +431,12 @@ TEST_F(UdaUniqueSurfaceTest, CacheModeStructNeverPopulatesFieldsOutsideAnyArrays
"not an error";
ASSERT_EQ(data.size(), 1u);
EXPECT_EQ(data[0], al_contract::kEmptyDouble)
- << "cache_mode=struct never populates a field that lives outside "
- "every struct_array the caller enters, even though it was "
- "genuinely written -- struct mode's scope is per-struct_array, "
- "not IDS-wide like ids mode";
- al_end_action(op);
- al_close_pulse(pulse_ctx, CLOSE_PULSE);
+ << "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));
}
// ===========================================================================
@@ -502,7 +539,7 @@ al_status_t local_reopen_status_after_uda_session_closes(const char* cache_mode)
&shape, &data)
.code,
0);
- al_end_action(op);
+ AL_EXPECT_OK(al_end_action(op));
EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0);
}
@@ -549,7 +586,7 @@ TEST_F(UdaUniqueSurfaceTest, DdPresentLoadsAndOpenSucceeds) {
const std::string uri = uda_uri_with(pulse_dir);
int pulse_ctx = -1;
AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx));
- al_close_pulse(pulse_ctx, CLOSE_PULSE);
+ AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE));
}
// Absent: neither $IDSDEF_PATH nor $IMAS_PREFIX set -> load_xml() throws
@@ -570,7 +607,9 @@ TEST_F(UdaUniqueSurfaceTest, DdAbsentNeitherEnvVarSetFailsWithClearMessage) {
"neither IMAS_PREFIX or IDSDEF_PATH"),
std::string::npos)
<< "expected the documented missing-env-var message, got: " << s.message;
- if (s.code == 0) al_close_pulse(pulse_ctx, CLOSE_PULSE);
+ 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 ->
@@ -587,10 +626,14 @@ TEST_F(UdaUniqueSurfaceTest, DdAbsentFileMissingAtIdsDefPathFailsWithClearMessag
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_close_pulse(pulse_ctx, CLOSE_PULSE);
+ if (s.code == 0) {
+ AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE));
+ }
}
-// Wrong-version: $IDSDEF_PATH points at a real, structurally valid, but
+// 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
@@ -609,7 +652,37 @@ TEST_F(UdaUniqueSurfaceTest, DdAbsentFileMissingAtIdsDefPathFailsWithClearMessag
// 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).
-TEST_F(UdaUniqueSurfaceTest, DdWrongVersionLoadsSilentlyWithNoCrossVersionCheck) {
+
+// 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 = uda_uri_with(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 "
@@ -636,10 +709,11 @@ TEST_F(UdaUniqueSurfaceTest, DdWrongVersionLoadsSilentlyWithNoCrossVersionCheck)
"back correctly under the wrong-version schema";
ASSERT_EQ(data.size(), 1u);
EXPECT_EQ(data[0], 6.2)
- << "no signal distinguishes this from a correct-version read -- the "
- "version drift is silent";
- al_end_action(op);
- al_close_pulse(pulse_ctx, CLOSE_PULSE);
+ << "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));
}
// ===========================================================================
@@ -724,8 +798,8 @@ TEST_F(UdaUniqueSurfaceTest, DatapathScopesCachePopulationFieldOutsideScopeReads
"prioritizes fetch order";
}
- al_end_action(op);
- al_close_pulse(pulse_ctx, CLOSE_PULSE);
+ AL_EXPECT_OK(al_end_action(op));
+ AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE));
}
// ===========================================================================
@@ -745,10 +819,32 @@ TEST_F(UdaUniqueSurfaceTest, DatapathScopesCachePopulationFieldOutsideScopeReads
// ((ver.first!=sver.first)||(ver.second(
op, equilibrium_seed::kScalar, {}, {9.9});
- al_end_action(op);
- al_close_pulse(pulse_ctx, CLOSE_PULSE);
+ AL_EXPECT_OK(al_end_action(op));
+ AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE));
}
int pulse_ctx = -1;
@@ -1125,8 +1222,8 @@ al_status_t remote_write_status_and_confirm_unpersisted(
.code,
0);
*reached_server = (data.size() == 1u && data[0] == 9.9);
- al_end_action(op);
- al_close_pulse(pulse_ctx, CLOSE_PULSE);
+ AL_EXPECT_OK(al_end_action(op));
+ AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE));
return write_status;
}
@@ -1183,8 +1280,8 @@ TEST_F(UdaUniqueSurfaceTest, RemoteDeleteFailsWithUnknownFunctionRequested) {
<< "expected the server's exact dispatch-failure text, got: "
<< s.message;
- al_end_action(op);
- al_close_pulse(pulse_ctx, CLOSE_PULSE);
+ AL_EXPECT_OK(al_end_action(op));
+ AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE));
}
} // namespace
From 0036f311a59f91e37797fda09b88cc2709ccca13 Mon Sep 17 00:00:00 2001
From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 11:39:23 +0200
Subject: [PATCH 17/17] Centralize UDA test addressing and assert cleanup
(cherry picked from commit f6bc1811fce7e8b4a6eb24bbb3d274308ee8d00c)
---
tests/contract/al_contract.h | 16 +++++
tests/contract/test_uda.cpp | 12 ++--
tests/contract/test_uda_breadth.cpp | 72 +++++++++----------
tests/contract/test_uda_real_paths.cpp | 6 +-
tests/contract/test_uda_unique_surface.cpp | 80 ++++++++++------------
5 files changed, 96 insertions(+), 90 deletions(-)
diff --git a/tests/contract/al_contract.h b/tests/contract/al_contract.h
index 0bf665c0..71d44efb 100644
--- a/tests/contract/al_contract.h
+++ b/tests/contract/al_contract.h
@@ -179,6 +179,22 @@ inline std::string uda_uri_base() {
(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
index 5b8afbe6..38a256a0 100644
--- a/tests/contract/test_uda.cpp
+++ b/tests/contract/test_uda.cpp
@@ -62,7 +62,7 @@ TEST_F(UdaSmokeRoundTrip, ScalarSeededViaHdf5ReadsBackThroughUda) {
// --- seed one scalar through the plain HDF5 backend (fixture setup) ---
{
- const std::string hdf5_uri = "imas:hdf5?path=" + 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));
@@ -88,9 +88,7 @@ TEST_F(UdaSmokeRoundTrip, ScalarSeededViaHdf5ReadsBackThroughUda) {
// 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_uri_base() +
- "?backend=hdf5&cache_mode=none&path=" +
- 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(), OPEN_PULSE,
&pulse_ctx));
@@ -157,7 +155,7 @@ TEST_F(UdaEquilibriumSeedParity, HdfSeededReadsBackThroughUda) {
// --- seed the full composite shape through the plain HDF5 backend ---
{
- const std::string hdf5_uri = "imas:hdf5?path=" + 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));
@@ -181,9 +179,7 @@ TEST_F(UdaEquilibriumSeedParity, HdfSeededReadsBackThroughUda) {
// --- read the same composite shape back through UDA, remote mode ---
{
- const std::string uda_uri = al_contract::uda_uri_base() +
- "?backend=hdf5&cache_mode=none&path=" +
- 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(), OPEN_PULSE,
&pulse_ctx));
diff --git a/tests/contract/test_uda_breadth.cpp b/tests/contract/test_uda_breadth.cpp
index 01ae04d2..ad6ae01b 100644
--- a/tests/contract/test_uda_breadth.cpp
+++ b/tests/contract/test_uda_breadth.cpp
@@ -31,12 +31,6 @@
namespace {
-// Build the UDA remote-mode URI for a seeded HDF5 pulse dir.
-std::string uda_uri_for(const std::string& pulse_dir) {
- return al_contract::uda_uri_base() + "?backend=hdf5&cache_mode=none&path=" +
- pulse_dir;
-}
-
// 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.
@@ -51,7 +45,7 @@ UdaEraseResult uda_remote_reopen_after_erase() {
const std::string pulse_dir = base.str() + "/pulse";
std::error_code ec;
std::filesystem::create_directories(pulse_dir, ec);
- const std::string uda_uri = uda_uri_for(pulse_dir);
+ 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(
@@ -64,7 +58,9 @@ UdaEraseResult uda_remote_reopen_after_erase() {
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_close_pulse(reopened_ctx, CLOSE_PULSE);
+ if (reopened.code == 0) {
+ AL_EXPECT_OK(al_close_pulse(reopened_ctx, CLOSE_PULSE));
+ }
return {created.code, erased.code, reopened.code};
}
@@ -119,7 +115,7 @@ std::vector dynamic_leaf_inside_aos_values_via_uda() {
// --- seed via HDF5 -------------------------------------------------------
{
- const std::string hdf5_uri = "imas:hdf5?path=" + 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)
@@ -156,7 +152,7 @@ std::vector dynamic_leaf_inside_aos_values_via_uda() {
// --- reopen through UDA, remote mode ------------------------------------
std::vector values;
{
- const std::string uda_uri = uda_uri_for(pulse_dir);
+ 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,
@@ -239,7 +235,7 @@ TEST_F(UdaBreadthTest, OccurrencesListsWrittenOccurrencesThroughReopen) {
// --- seed via HDF5: occurrence 0 and occurrence 2 -----------------------
{
- const std::string hdf5_uri = "imas:hdf5?path=" + 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));
@@ -259,7 +255,7 @@ TEST_F(UdaBreadthTest, OccurrencesListsWrittenOccurrencesThroughReopen) {
// --- list occurrences through UDA, remote mode --------------------------
{
- const std::string uda_uri = uda_uri_for(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(), OPEN_PULSE,
&pulse_ctx));
@@ -302,7 +298,7 @@ TEST_F(UdaBreadthTest, ListFilledPathsThroughReopen) {
// --- seed via HDF5 -------------------------------------------------------
{
- const std::string hdf5_uri = "imas:hdf5?path=" + 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));
@@ -319,7 +315,7 @@ TEST_F(UdaBreadthTest, ListFilledPathsThroughReopen) {
// --- list_filled_paths through UDA, remote mode -------------------------
{
- const std::string uda_uri = uda_uri_for(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(), OPEN_PULSE,
&pulse_ctx));
@@ -361,19 +357,21 @@ TEST_F(UdaBreadthTest, ListFilledPathsThroughReopen) {
// remote mode does not weaken it.
TEST_F(UdaBreadthTest, OpenPulseFailsWhenRemotePathAbsent) {
const std::string absent_dir = base_.str() + "/never-seeded";
- const std::string uda_uri = uda_uri_for(absent_dir);
- int pulse_ctx = -1;
- al_status_t s = al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE,
- &pulse_ctx);
+ 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_close_pulse(pulse_ctx, CLOSE_PULSE);
+ 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 = uda_uri_for(pulse_dir);
+ const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir);
int pulse_ctx = -1;
AL_ASSERT_OK(
@@ -391,7 +389,7 @@ TEST_F(UdaBreadthTest, ForceOpenPulseCreatesRemotePulseWhenAbsent) {
TEST_F(UdaBreadthTest, CreatePulseRefusesExistingRemotePulse) {
const std::string pulse_dir = fresh_pulse_dir();
- const std::string uda_uri = uda_uri_for(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,
@@ -403,12 +401,14 @@ TEST_F(UdaBreadthTest, CreatePulseRefusesExistingRemotePulse) {
&create_ctx);
EXPECT_NE(s.code, 0)
<< "CREATE_PULSE must refuse an existing remote pulse";
- if (s.code == 0) al_close_pulse(create_ctx, CLOSE_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 = uda_uri_for(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,
@@ -460,7 +460,7 @@ TEST_F(UdaBreadthTest, ErasePulseCurrentlyLeavesRemotePulseOpenable) {
TEST_F(UdaBreadthTest, ReadOfUnwrittenLeafOnSeededIdsReturnsHdf5AbsentSentinel) {
const std::string pulse_dir = fresh_pulse_dir();
{
- const std::string hdf5_uri = "imas:hdf5?path=" + 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));
@@ -474,7 +474,7 @@ TEST_F(UdaBreadthTest, ReadOfUnwrittenLeafOnSeededIdsReturnsHdf5AbsentSentinel)
EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0);
}
- const std::string uda_uri = uda_uri_for(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(), OPEN_PULSE, &pulse_ctx));
int op = -1;
@@ -491,8 +491,8 @@ TEST_F(UdaBreadthTest, ReadOfUnwrittenLeafOnSeededIdsReturnsHdf5AbsentSentinel)
EXPECT_EQ(data[0], al_contract::kEmptyDouble)
<< "unwritten leaf must read back as the HDF5 absent-scalar "
"sentinel, forwarded transparently through UDA";
- al_end_action(op);
- al_close_pulse(pulse_ctx, CLOSE_PULSE);
+ 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
@@ -501,7 +501,7 @@ TEST_F(UdaBreadthTest, ReadOfUnwrittenLeafOnSeededIdsReturnsHdf5AbsentSentinel)
TEST_F(UdaBreadthTest, ReadFailsForNeverWrittenIds) {
const std::string pulse_dir = fresh_pulse_dir();
{
- const std::string hdf5_uri = "imas:hdf5?path=" + 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));
@@ -514,7 +514,7 @@ TEST_F(UdaBreadthTest, ReadFailsForNeverWrittenIds) {
EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0);
}
- const std::string uda_uri = uda_uri_for(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(), OPEN_PULSE, &pulse_ctx));
// "magnetics" is a real DD 4.1.1 IDS never written to this pulse dir.
@@ -527,8 +527,8 @@ TEST_F(UdaBreadthTest, ReadFailsForNeverWrittenIds) {
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_end_action(op);
- al_close_pulse(pulse_ctx, CLOSE_PULSE);
+ AL_EXPECT_OK(al_end_action(op));
+ AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE));
}
// ===========================================================================
@@ -550,7 +550,7 @@ class UdaSliceAndTimeRange : public UdaBreadthTest {
// 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 = "imas:hdf5?path=" + 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)
@@ -582,7 +582,7 @@ class UdaSliceAndTimeRange : public UdaBreadthTest {
TEST_F(UdaSliceAndTimeRange, SliceReadThroughReopen) {
const std::string pulse_dir = seed_dynamic_signal();
- const std::string uda_uri = uda_uri_for(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(), OPEN_PULSE, &pulse_ctx));
@@ -626,7 +626,7 @@ TEST_F(UdaSliceAndTimeRange, SliceReadThroughReopen) {
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 = uda_uri_for(pulse_dir);
+ 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);
@@ -642,8 +642,8 @@ al_status_t timerange_read_without_resampling_via_uda(
0);
al_status_t s = al_read_data(op, "vacuum_toroidal_field/b0", "time", buf,
DOUBLE_DATA, 1, size);
- al_end_action(op);
- al_close_pulse(pulse_ctx, CLOSE_PULSE);
+ AL_EXPECT_OK(al_end_action(op));
+ AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE));
return s;
}
diff --git a/tests/contract/test_uda_real_paths.cpp b/tests/contract/test_uda_real_paths.cpp
index 2782d882..658e7645 100644
--- a/tests/contract/test_uda_real_paths.cpp
+++ b/tests/contract/test_uda_real_paths.cpp
@@ -209,7 +209,7 @@ void run_cell_with_read_expectation(const PathSpec& spec,
// --- seed via the plain HDF5 backend (fixture setup) -------------------
{
- const std::string hdf5_uri = "imas:hdf5?path=" + 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));
@@ -246,9 +246,7 @@ void run_cell_with_read_expectation(const PathSpec& spec,
// --- reopen through UDA, remote mode ------------------------------------
{
- const std::string uda_uri = al_contract::uda_uri_base() +
- "?backend=hdf5&cache_mode=none&path=" +
- 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(), OPEN_PULSE,
&pulse_ctx));
diff --git a/tests/contract/test_uda_unique_surface.cpp b/tests/contract/test_uda_unique_surface.cpp
index 63964e44..ee14b1fd 100644
--- a/tests/contract/test_uda_unique_surface.cpp
+++ b/tests/contract/test_uda_unique_surface.cpp
@@ -47,13 +47,6 @@
namespace {
-std::string uda_uri_with(const std::string& pulse_dir, const std::string& extra_query = "") {
- std::string uri = al_contract::uda_uri_base() +
- "?backend=hdf5&cache_mode=none&path=" + pulse_dir;
- if (!extra_query.empty()) uri += "&" + extra_query;
- return uri;
-}
-
// 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,
@@ -122,7 +115,7 @@ class UdaUniqueSurfaceTest : public ::testing::Test {
// (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 = "imas:hdf5?path=" + 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)
@@ -157,7 +150,7 @@ class UdaUniqueSurfaceTest : public ::testing::Test {
// 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 = uda_uri_with(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");
@@ -179,7 +172,8 @@ TEST_F(UdaUniqueSurfaceTest, InvalidCacheModeThrows) {
// 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 = uda_uri_with(pulse_dir, "verbose=true");
+ const std::string uri =
+ al_contract::uda_hdf5_uri_for(pulse_dir, "none", "verbose=true");
testing::internal::CaptureStdout();
int pulse_ctx = -1;
@@ -194,7 +188,8 @@ TEST_F(UdaUniqueSurfaceTest, VerboseTrueEmitsDebugTracingOnStdout) {
TEST_F(UdaUniqueSurfaceTest, VerboseAbsentEmitsNoDebugTracing) {
const std::string pulse_dir = seed_scalar(fresh_pulse_dir());
- const std::string uri = uda_uri_with(pulse_dir); // no verbose= at all
+ const std::string uri =
+ al_contract::uda_hdf5_uri_for(pulse_dir); // no verbose= at all
testing::internal::CaptureStdout();
int pulse_ctx = -1;
@@ -213,7 +208,8 @@ TEST_F(UdaUniqueSurfaceTest, VerboseAbsentEmitsNoDebugTracing) {
// ("::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 = uda_uri_with(pulse_dir, "plugin=NoSuchImasPlugin");
+ 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);
@@ -234,7 +230,8 @@ TEST_F(UdaUniqueSurfaceTest, PluginOptionNamingUnregisteredPluginFailsAtOpen) {
// 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 = uda_uri_with(pulse_dir, "init_args=some_key=1;other_key=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));
@@ -263,7 +260,8 @@ TEST_F(UdaUniqueSurfaceTest, InitArgsAcceptedAndIgnoredByReferencePlugin) {
// schema).
TEST_F(UdaUniqueSurfaceTest, DdVersionOverrideAcceptedWithNoObservableEffect) {
const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2);
- const std::string uri = uda_uri_with(pulse_dir, "dd_version=not.a.real.version");
+ 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));
@@ -321,7 +319,7 @@ TEST_F(UdaUniqueSurfaceTest, CacheModeNoneIdsStructAgreeForAosCoveredField) {
// --- seed temporary/constant_integer3d(1 element)/value via HDF5 -------
{
- const std::string hdf5_uri = "imas:hdf5?path=" + 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));
@@ -343,9 +341,8 @@ TEST_F(UdaUniqueSurfaceTest, CacheModeNoneIdsStructAgreeForAosCoveredField) {
// --- reopen through UDA under each cache mode ---------------------------
for (const char* mode : {"none", "ids", "struct"}) {
SCOPED_TRACE(mode);
- const std::string uri = al_contract::uda_uri_base() +
- "?backend=hdf5&cache_mode=" + std::string(mode) +
- "&path=" + pulse_dir;
+ 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;
@@ -389,8 +386,8 @@ TEST_F(UdaUniqueSurfaceTest, CacheModeNoneIdsStructAgreeForAosCoveredField) {
// 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_uri_base() +
- "?backend=hdf5&cache_mode=struct&path=" + pulse_dir;
+ 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));
@@ -414,8 +411,8 @@ TEST_F(UdaUniqueSurfaceTest, DISABLED_CacheModeStructPreservesTopLevelField) {
TEST_F(UdaUniqueSurfaceTest,
CacheModeStructCurrentlyReturnsAbsentSentinelForTopLevelField) {
const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2);
- const std::string uri = al_contract::uda_uri_base() +
- "?backend=hdf5&cache_mode=struct&path=" + pulse_dir;
+ 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));
@@ -496,7 +493,7 @@ al_status_t local_reopen_status_after_uda_session_closes(const char* cache_mode)
std::error_code ec;
std::filesystem::create_directories(pulse_dir, ec);
- const std::string hdf5_uri = "imas:hdf5?path=" + 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,
@@ -521,9 +518,8 @@ al_status_t local_reopen_status_after_uda_session_closes(const char* cache_mode)
}
{
- const std::string uda_uri = al_contract::uda_uri_base() +
- "?backend=hdf5&cache_mode=" +
- std::string(cache_mode) + "&path=" + pulse_dir;
+ 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,
@@ -583,7 +579,7 @@ TEST_F(UdaUniqueSurfaceTest, ClosingUdaSessionCurrentlyLeaksServerSideHandle) {
// 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 = uda_uri_with(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));
@@ -595,7 +591,7 @@ TEST_F(UdaUniqueSurfaceTest, DdPresentLoadsAndOpenSucceeds) {
// 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 = uda_uri_with(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);
@@ -616,7 +612,7 @@ TEST_F(UdaUniqueSurfaceTest, DdAbsentNeitherEnvVarSetFailsWithClearMessage) {
// 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 = uda_uri_with(pulse_dir);
+ const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir);
ScopedEnv idsdef(/*name=*/"IDSDEF_PATH",
/*value=*/"/nonexistent/path/IDSDef.xml");
@@ -665,7 +661,7 @@ TEST_F(UdaUniqueSurfaceTest, DISABLED_DdWrongVersionIsRejected) {
}
const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2);
- const std::string uri = uda_uri_with(pulse_dir);
+ const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir);
ScopedEnv idsdef(/*name=*/"IDSDEF_PATH", /*value=*/older_dd);
@@ -691,7 +687,7 @@ TEST_F(UdaUniqueSurfaceTest,
}
const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2);
- const std::string uri = uda_uri_with(pulse_dir);
+ const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir);
ScopedEnv idsdef(/*name=*/"IDSDEF_PATH", /*value=*/older_dd);
@@ -739,7 +735,7 @@ TEST_F(UdaUniqueSurfaceTest, DatapathScopesCachePopulationFieldOutsideScopeReads
// --- seed two real DD fields in different subtrees via HDF5 -------------
{
- const std::string hdf5_uri = "imas:hdf5?path=" + 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));
@@ -759,8 +755,8 @@ TEST_F(UdaUniqueSurfaceTest, DatapathScopesCachePopulationFieldOutsideScopeReads
}
// --- reopen through UDA, cache_mode=ids, datapath scoped to one subtree ---
- const std::string uda_uri = al_contract::uda_uri_base() +
- "?backend=hdf5&cache_mode=ids&path=" + pulse_dir;
+ 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));
@@ -830,7 +826,7 @@ TEST_F(UdaUniqueSurfaceTest, DatapathScopesCachePopulationFieldOutsideScopeReads
TEST_F(UdaUniqueSurfaceTest,
DISABLED_OpenRefusesWhenStoredBackendVersionCannotBeVerified) {
const std::string pulse_dir = seed_scalar(fresh_pulse_dir());
- const std::string uri = uda_uri_with(pulse_dir);
+ const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir);
int pulse_ctx = -1;
al_status_t s = al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx);
EXPECT_NE(s.code, 0)
@@ -846,7 +842,7 @@ TEST_F(UdaUniqueSurfaceTest,
TEST_F(UdaUniqueSurfaceTest,
VersionDriftCheckCurrentlyNeverFiresRegardlessOfStoredPulse) {
const std::string pulse_dir = seed_scalar(fresh_pulse_dir());
- const std::string uri = uda_uri_with(pulse_dir);
+ const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir);
int pulse_ctx = -1;
al_status_t s = al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx);
AL_EXPECT_OK(s) << "OPEN_PULSE must never fail on the version-drift "
@@ -878,7 +874,7 @@ TEST_F(UdaUniqueSurfaceTest,
TEST_F(UdaUniqueSurfaceTest, TimeRangeCapabilityGrantedByReferenceServerVersion180) {
const std::string pulse_dir = fresh_pulse_dir();
{
- const std::string hdf5_uri = "imas:hdf5?path=" + 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));
@@ -897,7 +893,7 @@ TEST_F(UdaUniqueSurfaceTest, TimeRangeCapabilityGrantedByReferenceServerVersion1
EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0);
}
- const std::string uri = uda_uri_with(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));
@@ -1109,7 +1105,8 @@ TEST_F(UdaUniqueSurfaceTest, FetchModeWriteDivergesFromServerAndStalePersistsAcr
// --- confirm the server-side pulse is unchanged (reopen via remote mode) ---
{
- const std::string remote_uri = uda_uri_with(pulse_dir);
+ 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));
@@ -1187,8 +1184,7 @@ TEST_F(UdaUniqueSurfaceTest, FetchModeWriteDivergesFromServerAndStalePersistsAcr
// 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_uri_base() +
- "?backend=hdf5&cache_mode=none&path=" + pulse_dir;
+ const std::string uri = al_contract::uda_hdf5_uri_for(pulse_dir);
al_status_t write_status;
{
@@ -1264,7 +1260,7 @@ TEST_F(UdaUniqueSurfaceTest, RemoteWriteCurrentlyReportsSuccessButNeverPersists)
TEST_F(UdaUniqueSurfaceTest, RemoteDeleteFailsWithUnknownFunctionRequested) {
const std::string pulse_dir = seed_scalar(fresh_pulse_dir(), /*r0=*/6.2);
- const std::string uri = uda_uri_with(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));