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));