From 24bd5bc834ce9f54dee06ed00f1c1cdecf592336 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:11:41 +0200 Subject: [PATCH 01/91] .gitignore up to date --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f5697b3c..bfbb764a 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ python/lib/ /.venv/ /vcpkg/ /UDA-*/ +.idea \ No newline at end of file From e7d817409d3a8a57aead0e36a48d16647c3470c2 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:42:56 +0200 Subject: [PATCH 02/91] Fix : updates tests to add the datapath --- CMakeLists.txt | 9 +++++ tests/CMakeLists.txt | 91 ++++++++++++++++++++---------------------- tests/testlowlevel.cpp | 10 ++--- tests/testlowlevel_c.c | 4 +- 4 files changed, 59 insertions(+), 55 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e69e98eb..be0d4da9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,6 +35,8 @@ endif() # ############################################################################## option(AL_PYTHON_BINDINGS "Build Python bindings" OFF) +option(AL_BUILD_TESTS "Build the C/C++ low-level ABI smoke tests (tests/)" OFF) + # When ON, the al C++ library is not built here; instead it is located with # find_package(al-core CONFIG). Enables a two-stage build where libimas-core # (the C/C++ library) and imas-core (the Python wrapper) are packaged @@ -225,6 +227,13 @@ if(NOT WIN32 AND NOT APPLE) endif() add_dependencies( imas_print_version al ) +# Low-level ABI smoke tests (opt-in) +# ############################################################################## +if( AL_BUILD_TESTS ) + enable_testing() + add_subdirectory(tests) +endif() + # Install # ############################################################################## diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index db5b6b13..8b25dd03 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,73 +1,68 @@ project( lowlevel-tests ) -######################################################################################################################## -# Dependencies - - -######################################################################################################################## -# Sources - -include_directories( - ${CMAKE_CURRENT_SOURCE_DIR} - ${CMAKE_SOURCE_DIR}/lowlevel -) - - ######################################################################################################################## # Targets +# +# These smoke tests exercise the public C ABI (al_lowlevel.h) end-to-end and, +# by building the same program as both C++ and C, guard that the header stays +# C-compatible. The `al` target exports its include dirs PUBLICly, so linking +# it is enough to resolve al_lowlevel.h / al_defs.h. set( EXE_FLAG ) set( EXE_EXT ) -if( BUILD_SHARED_LIBS ) - set( LOWLEVEL_LINK lowlevel-shared ) -else() - set( LOWLEVEL_LINK lowlevel-static ) -endif() - add_executable( testlowlevel ${EXE_FLAG} testlowlevel.cpp ) -target_link_libraries( testlowlevel PRIVATE - ${LOWLEVEL_LINK} -) +target_link_libraries( testlowlevel PRIVATE al ) +add_dependencies( testlowlevel al ) +set_target_properties( testlowlevel PROPERTIES OUTPUT_NAME testlowlevel${EXE_EXT} ) -if( BUILD_SHARED_LIBS ) - add_dependencies( testlowlevel lowlevel-shared ) -else() - add_dependencies( testlowlevel lowlevel-static ) +add_executable( testlowlevel_c ${EXE_FLAG} testlowlevel_c.c ) +target_link_libraries( testlowlevel_c PRIVATE al ) +add_dependencies( testlowlevel_c al ) +set_target_properties( testlowlevel_c PROPERTIES OUTPUT_NAME testlowlevel_c${EXE_EXT} ) + +if(NOT WIN32 AND NOT APPLE) + # stdc++fs is only needed on Linux with older GCC + target_link_libraries( testlowlevel PRIVATE stdc++fs ) + target_link_libraries( testlowlevel_c PRIVATE stdc++fs ) endif() -set_target_properties( testlowlevel - PROPERTIES - OUTPUT_NAME testlowlevel${EXE_EXT} - COMPILE_FLAGS -DNOIMPLIB -) -add_executable( testlowlevel_c ${EXE_FLAG} testlowlevel_c.c ) -target_link_libraries( testlowlevel_c PRIVATE - ${LOWLEVEL_LINK} -) +######################################################################################################################## +# CTest registration +# +# The legacy `imas_public` user is not a real account, so the pulse path is +# supplied directly by passing an absolute base directory as the "user" (-u): +# the core then builds ////. Each test gets +# its own base dir (created by a fixture) to stay parallel-safe, and uses the +# DD-agnostic ASCII backend so no MDSplus model tree is required. -set_target_properties( testlowlevel_c - PROPERTIES - OUTPUT_NAME testlowlevel_c${EXE_EXT} - COMPILE_FLAGS -DNOIMPLIB -) +set( _testdb ${CMAKE_CURRENT_BINARY_DIR}/testdb ) + +add_test( NAME testlowlevel_c_setup + COMMAND ${CMAKE_COMMAND} -E make_directory ${_testdb}/c/west/3/12/0 ) +set_tests_properties( testlowlevel_c_setup PROPERTIES FIXTURES_SETUP TESTDB_C ) + +add_test( NAME testlowlevel_cpp_setup + COMMAND ${CMAKE_COMMAND} -E make_directory ${_testdb}/cpp/west/3/12/0 ) +set_tests_properties( testlowlevel_cpp_setup PROPERTIES FIXTURES_SETUP TESTDB_CPP ) + +add_test( NAME testlowlevel_c + COMMAND testlowlevel_c -b=A -s=12 -r=0 -o=C -c=C -u=${_testdb}/c ) +set_tests_properties( testlowlevel_c PROPERTIES FIXTURES_REQUIRED TESTDB_C ) + +add_test( NAME testlowlevel_cpp + COMMAND testlowlevel -b=A -s=12 -r=0 -o=C -c=C -u=${_testdb}/cpp ) +set_tests_properties( testlowlevel_cpp PROPERTIES FIXTURES_REQUIRED TESTDB_CPP ) ######################################################################################################################## # Install install( - TARGETS testlowlevel + TARGETS testlowlevel testlowlevel_c RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} ) - -install( - TARGETS testlowlevel_c - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} - LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} - ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} -) \ No newline at end of file diff --git a/tests/testlowlevel.cpp b/tests/testlowlevel.cpp index 2ffea62d..d2b8e1d8 100644 --- a/tests/testlowlevel.cpp +++ b/tests/testlowlevel.cpp @@ -259,11 +259,11 @@ int main(int argc, char *argv[]) { printf("Pulse number:\t%d\n", iPulse); printf("Run number:\t%d\n", iRun); - printf("Backend:\t%s\n", alconst::backend_id_str[iBackend - BACKEND_ID_0].c_str()); + printf("Backend:\t%d\n", iBackend); printf("User name:\t%s\n", szUser); printf("Machine:\t%s\n", szTokamak); - printf("Open action:\t%s\n", alconst::access_pulse_str[iOpenAction - ACCESS_PULSE_0].c_str()); - printf("Close action:\t%s\n", alconst::access_pulse_str[iCloseAction - ACCESS_PULSE_0].c_str()); + printf("Open action:\t%d\n", iOpenAction); + printf("Close action:\t%d\n", iCloseAction); printf("Parameters:\t%s\n", szParams); // Low Level @@ -309,7 +309,7 @@ int main(int argc, char *argv[]) printf("Writing data...\n"); iGetOpCtx = -1; - alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, WRITE_OP, &iGetOpCtx); + alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, "", WRITE_OP, &iGetOpCtx); if (alStatus.code != 0) { printf("Error opening imas %s for writing: al_begin_global_action = %s\n", szIdsFullName, alStatus.message); @@ -352,7 +352,7 @@ int main(int argc, char *argv[]) printf("Reading data...\n"); iGetOpCtx = -1; - alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, READ_OP, &iGetOpCtx); + alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, "", READ_OP, &iGetOpCtx); if (alStatus.code != 0) { printf("Error opening imas %s for reading: al_begin_global_action = %s\n", szIdsFullName, alStatus.message); diff --git a/tests/testlowlevel_c.c b/tests/testlowlevel_c.c index 3736c1b1..ab3801d5 100644 --- a/tests/testlowlevel_c.c +++ b/tests/testlowlevel_c.c @@ -276,7 +276,7 @@ int main(int argc, char *argv[]) printf("Writing data...\n"); iGetOpCtx = -1; - alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, WRITE_OP, &iGetOpCtx); + alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, "", WRITE_OP, &iGetOpCtx); if (alStatus.code != 0) { printf("Error opening imas %s for writing: al_begin_global_action = %s\n", szIdsFullName, alStatus.message); @@ -319,7 +319,7 @@ int main(int argc, char *argv[]) printf("Reading data...\n"); iGetOpCtx = -1; - alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, READ_OP, &iGetOpCtx); + alStatus = al_begin_global_action(iPulseCtx, szIdsFullName, "", READ_OP, &iGetOpCtx); if (alStatus.code != 0) { printf("Error opening imas %s for reading: al_begin_global_action = %s\n", szIdsFullName, alStatus.message); From 3ab1010bf29898a9046590434071335d066ce35c Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:33:46 +0200 Subject: [PATCH 03/91] Add Task-2 contract test suite scaffold (GoogleTest + CTest) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Step 1 (the "scaffold slice") of TEST_STRATEGY.md: build the machine on one example so every mechanism is proven before the matrix is broadened. Binds only to the public C ABI (al_lowlevel.h + al_const.h) per decision D1. What lands: - tests/contract/al_contract.h — reusable harness: RAII temp pulse dir, legacy-parameter URI builder, and typed round-trip helpers over al_write_data/al_read_data honoring the buffer-ownership contract (scalar caller-owned; char-array core-allocated + freed). - test_roundtrip.cpp — TEST_P write->read round trip on HDF5 AND Memory; oracle is round-trip self-consistency, needing zero DD artifacts (D5). - test_known_defects.cpp — the al_setvalue_*_parameter_plugin unregistered-name null-deref (FUNCTIONALITY_INVENTORY.md:410) encoded per D2: a DISABLED_ test asserting the CORRECT contract (returns an error), plus a companion death test pinning today's crash to SIGSEGV specifically. The bug is not frozen as spec; the day it is fixed the xfail flips to pass. - CMake wiring (D3): GoogleTest via find_package-or-FetchContent (pinned to the v1.15.2 commit), gtest_discover_tests with a "contract" label, and an AL_CONTRACT_SANITIZE toggle (D7) that instruments both the tests and libal with ASan/UBSan — non-blocking. The pure-C canary (testlowlevel_c.c) is retained unchanged. Deferred (recorded): D7's dedicated sanitizer CI leg + suppressions file, and the unit/integration label split, arrive with the broader matrix in later steps. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/CMakeLists.txt | 10 ++ tests/contract/CMakeLists.txt | 106 +++++++++++++++ tests/contract/al_contract.h | 183 ++++++++++++++++++++++++++ tests/contract/test_known_defects.cpp | 74 +++++++++++ tests/contract/test_roundtrip.cpp | 100 ++++++++++++++ 5 files changed, 473 insertions(+) create mode 100644 tests/contract/CMakeLists.txt create mode 100644 tests/contract/al_contract.h create mode 100644 tests/contract/test_known_defects.cpp create mode 100644 tests/contract/test_roundtrip.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8b25dd03..8ad3cb53 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -57,6 +57,16 @@ add_test( NAME testlowlevel_cpp set_tests_properties( testlowlevel_cpp PROPERTIES FIXTURES_REQUIRED TESTDB_CPP ) +######################################################################################################################## +# Contract test suite (GoogleTest — Task 2, TEST_STRATEGY.md) +# +# The C/C++ programs above stay as smoke tests + the pure-C compile canary +# (testlowlevel_c.c guards al_lowlevel.h staying C-compatible, per decision D3). +# The GoogleTest-based contract suite lives in the contract/ subdirectory. + +add_subdirectory(contract) + + ######################################################################################################################## # Install diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt new file mode 100644 index 00000000..a17ea558 --- /dev/null +++ b/tests/contract/CMakeLists.txt @@ -0,0 +1,106 @@ +######################################################################################################################## +# Contract test suite (Task 2 — TEST_STRATEGY.md) +# +# GoogleTest + CTest suite binding the public C ABI (al_lowlevel.h + al_const.h) +# — decisions D1 (C-ABI seam) and D3 (GoogleTest for death tests + TEST_P). This +# is the scaffold slice (step 1): it proves every mechanism — round trip on an +# on-disk and an in-RAM backend, and one expected-fail on a real defect — before +# the matrix is broadened in later steps. +# +# The whole suite lives under AL_BUILD_TESTS (OFF by default), so it never +# perturbs the normal library / wheel build. + +######################################################################################################################## +# GoogleTest — prefer an installed copy, fall back to fetching a pinned release. +# +# find_package first keeps CI/offline builds fast when GTest is already provided +# (e.g. a module or system package). FetchContent is the portable fallback; it +# only runs when tests are being built. + +find_package(GTest QUIET) + +if(NOT GTest_FOUND) + include(FetchContent) + FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + # Pinned to the immutable commit for v1.15.2 (a tag can be re-pointed). + GIT_TAG b514bdc898e2951020cbdca1304b75f5950d1f59 + ) + # Honor the parent project's shared-CRT choice on Windows. + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + set(INSTALL_GTEST OFF CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(googletest) +endif() + +include(GoogleTest) + +######################################################################################################################## +# Optional sanitizer build (decision D7): compile the same functional suite with +# AddressSanitizer + UndefinedBehaviorSanitizer so it self-checks for UB / UAF / +# overruns with no per-test code. Non-blocking / informational — a dedicated CI +# leg, not a hard gate, until the baseline is triaged. Enable with +# -DAL_CONTRACT_SANITIZE=ON. LeakSanitizer's leak detection is Linux-only; macOS +# gives crash/overrun detection but not reliable leak detection. + +option(AL_CONTRACT_SANITIZE + "Build the contract test suite with ASan/UBSan (non-blocking)" OFF) + +######################################################################################################################## +# The contract test executable. + +add_executable(contract_tests + test_roundtrip.cpp + test_known_defects.cpp +) + +target_link_libraries(contract_tests PRIVATE al GTest::gtest GTest::gtest_main) +add_dependencies(contract_tests al) +target_compile_features(contract_tests PRIVATE cxx_std_17) + +if(NOT WIN32 AND NOT APPLE) + # stdc++fs is only needed on Linux with older GCC (std::filesystem in the harness). + target_link_libraries(contract_tests PRIVATE stdc++fs) +endif() + +if(AL_CONTRACT_SANITIZE) + if(MSVC) + message(WARNING "AL_CONTRACT_SANITIZE not supported with MSVC; ignoring.") + else() + set(_al_sanitize_flags -fsanitize=address,undefined -fno-omit-frame-pointer -g) + target_compile_options(contract_tests PRIVATE ${_al_sanitize_flags}) + target_link_options(contract_tests PRIVATE -fsanitize=address,undefined) + # Instrument the library too, not just the tests: D7's value ("a future + # leak/UAF in the hot path is caught for free") is about libal's code, so + # the flags must reach the `al` target — otherwise the round trip runs + # through an uninstrumented library and most internal UAF/overruns escape. + # `al` is defined in the root CMakeLists before tests/ is added, so the + # target is visible here. Note: a sanitized libal is meant for the dedicated + # C++-only sanitizer CI leg; don't combine this option with a wheel build, + # whose bindings would then link a sanitized libal without the ASan runtime. + target_compile_options(al PRIVATE ${_al_sanitize_flags}) + target_link_options(al PRIVATE -fsanitize=address,undefined) + endif() +endif() + +######################################################################################################################## +# CTest registration. +# +# gtest_discover_tests runs each GoogleTest case as its own ctest entry (so +# --gtest_list_tests / -R selection works) and applies the "contract" label to +# all of them. The finer unit/integration labelling (decision D6) arrives with +# the broader matrix in later steps; the scaffold keeps one label. + +gtest_discover_tests(contract_tests + PROPERTIES LABELS "contract" +) + +######################################################################################################################## +# Install (mirrors the smoke-test targets). + +install( + TARGETS contract_tests + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} +) diff --git a/tests/contract/al_contract.h b/tests/contract/al_contract.h new file mode 100644 index 00000000..92ce8445 --- /dev/null +++ b/tests/contract/al_contract.h @@ -0,0 +1,183 @@ +// Contract-test harness for the public C ABI (include/al_lowlevel.h). +// +// This header is the fixture/generator layer of the Task-2 contract suite +// (see TEST_STRATEGY.md). It exists to make the *machine* — temp pulse dirs, +// URI construction, data-entry lifecycle, and typed round-trip helpers — +// trivial and reusable, so the tests themselves read as behavior assertions +// against the ABI rather than boilerplate. +// +// Everything here binds only to the `extern "C"` surface (al_lowlevel.h + +// al_const.h), per decision D1: no C++ internals, no Python bindings. + +#ifndef AL_CONTRACT_H +#define AL_CONTRACT_H + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#define AL_CONTRACT_GETPID _getpid +#else +#include +#define AL_CONTRACT_GETPID getpid +#endif + +namespace al_contract { + +// --------------------------------------------------------------------------- +// Status assertion helpers +// --------------------------------------------------------------------------- +// al_status_t carries {code, message}; code == 0 means success. These wrap the +// pair so a failing ABI call reports its own diagnostic message, not just a +// bare code mismatch. + +inline ::testing::AssertionResult IsOk(const al_status_t& s) { + if (s.code == 0) { + return ::testing::AssertionSuccess(); + } + return ::testing::AssertionFailure() + << "al_status_t.code=" << s.code + << " message=" << (s.message ? s.message : "(null)"); +} + +#define AL_ASSERT_OK(expr) ASSERT_TRUE(::al_contract::IsOk(expr)) +#define AL_EXPECT_OK(expr) EXPECT_TRUE(::al_contract::IsOk(expr)) + +// --------------------------------------------------------------------------- +// BackendCase descriptor for value-parametrized tests (TEST_P). +// --------------------------------------------------------------------------- +struct BackendCase { + int id; // BACKEND enum value from al_const.h + const char* name; // human-readable label for the ctest name suffix + bool on_disk; // needs the legacy //// tree +}; + +// GoogleTest prints this for the parametrized test-name suffix. +inline void PrintTo(const BackendCase& b, std::ostream* os) { *os << b.name; } + +// --------------------------------------------------------------------------- +// The legacy address of a pulse: (database, version, pulse, run). These four +// always travel together — into the URI and into the on-disk tree path — so +// they are one value, not four loose args. +// --------------------------------------------------------------------------- +struct PulseId { + std::string database; + std::string version; + int pulse; + int run; +}; + +// --------------------------------------------------------------------------- +// Unique temp base directory, RAII-cleaned. +// --------------------------------------------------------------------------- +// The legacy pulse path is ////; the core +// derives it when the base dir is supplied as the "user" field of the legacy +// URI (exactly how tests/CMakeLists.txt drives the existing smoke tests). Each +// fixture instance gets its own base dir so parallel ctest runs never collide: +// name = /al_contract__. +class TempBase { +public: + TempBase() { + static std::atomic counter{0}; + namespace fs = std::filesystem; + const unsigned n = counter.fetch_add(1); + path_ = fs::temp_directory_path() / + ("al_contract_" + std::to_string(AL_CONTRACT_GETPID()) + + "_" + std::to_string(n)); + std::error_code ec; + fs::remove_all(path_, ec); + fs::create_directories(path_, ec); + } + + ~TempBase() { + std::error_code ec; + std::filesystem::remove_all(path_, ec); + } + + TempBase(const TempBase&) = delete; + TempBase& operator=(const TempBase&) = delete; + + std::string str() const { return path_.string(); } + + // Pre-create the legacy pulse subtree an on-disk backend expects for + // FORCE_CREATE_PULSE. + void make_legacy_tree(const PulseId& id) const { + std::error_code ec; + std::filesystem::create_directories( + path_ / id.database / id.version / std::to_string(id.pulse) / + std::to_string(id.run), + ec); + } + +private: + std::filesystem::path path_; +}; + +// --------------------------------------------------------------------------- +// URI construction through the legacy-parameter ABI. +// --------------------------------------------------------------------------- +// Uses al_build_uri_from_legacy_parameters so the URI is whatever the core +// itself produces for a given backend id — we don't hand-format schemes. +inline std::string build_uri(int backend_id, const std::string& base, + const PulseId& id) { + char* uri = nullptr; + al_status_t s = al_build_uri_from_legacy_parameters( + backend_id, id.pulse, id.run, base.c_str(), id.database.c_str(), + id.version.c_str(), "", &uri); + EXPECT_TRUE(IsOk(s)) << "al_build_uri_from_legacy_parameters failed"; + std::string out = (uri ? uri : ""); + free(uri); + return out; +} + +// --------------------------------------------------------------------------- +// Typed round-trip helpers over al_write_data / al_read_data. +// --------------------------------------------------------------------------- +inline al_status_t write_int_scalar(int ctx, const char* field, int value) { + // Scalar: dim=0, size=NULL (mirrors testlowlevel.cpp). + return al_write_data(ctx, field, "", &value, INTEGER_DATA, 0, nullptr); +} + +// Reads an INTEGER scalar into `out`. For dim=0 the core fills the caller's +// buffer, so we pass a pointer to our own int (no free needed). +inline al_status_t read_int_scalar(int ctx, const char* field, int* out) { + int size[MAXDIM] = {0}; + void* buf = out; + return al_read_data(ctx, field, "", &buf, INTEGER_DATA, 0, size); +} + +inline al_status_t write_char_array(int ctx, const char* field, + const std::string& value) { + int size[1] = {static_cast(value.size())}; + return al_write_data(ctx, field, "", + const_cast(value.c_str()), CHAR_DATA, 1, size); +} + +// Reads a CHAR array. For dim>=1 the core allocates the buffer and returns it +// via *data; the caller owns it, so we copy into std::string and free. +inline al_status_t read_char_array(int ctx, const char* field, + std::string* out) { + int size[MAXDIM] = {0}; + char* buf = nullptr; + al_status_t s = al_read_data(ctx, field, "", reinterpret_cast(&buf), + CHAR_DATA, 1, size); + if (s.code == 0 && buf != nullptr) { + out->assign(buf, static_cast(size[0])); + } + free(buf); + return s; +} + +} // namespace al_contract + +#endif // AL_CONTRACT_H diff --git a/tests/contract/test_known_defects.cpp b/tests/contract/test_known_defects.cpp new file mode 100644 index 00000000..58c69a64 --- /dev/null +++ b/tests/contract/test_known_defects.cpp @@ -0,0 +1,74 @@ +// Known-defect contract tests — the expected-fail mechanism proof. +// +// Decision D2 (TEST_STRATEGY.md): for a genuine defect we write the test for +// the *correct* behavior and mark it expected-fail, never freezing the bug as +// if it were the spec. When someone fixes the defect the expected-fail flips to +// pass — a signal, not a regression. +// +// Scaffold defect: al_setvalue_*_parameter_plugin called with an *unregistered* +// plugin name crashes instead of returning an error. +// FUNCTIONALITY_INVENTORY.md:410-417 +// src/al_lowlevel.cpp:453-457 (llpluginsStore[name] default-constructs an +// LLplugin with al_plugin == NULL, then dereferences it — an uncatchable +// null-pointer dereference the surrounding try/catch cannot convert to an +// al_status_t failure). +// +// GoogleTest was chosen over Catch2 precisely for this class of defect +// (decision D3): the crash would take down an in-process runner, so we isolate +// it in a death test. + +#include +#include + +#include + +#include + +namespace { + +constexpr const char* kUnregistered = "no_such_plugin_ever_registered"; + +// CORRECT-CONTRACT test, expected-fail. +// +// Marked expected-fail via the DISABLED_ prefix: it does not run in the default +// `ctest` pass, so it can't crash the runner, but it stays compiled and is +// listed by `--gtest_list_tests`. The day the null-deref is fixed to return an +// error, drop the DISABLED_ prefix and this becomes a live green assertion — +// the suite then documents the fixed contract. +// +// (GoogleTest has no first-class xfail; DISABLED_ + this comment + the paired +// death test below is the framework's idiom for "known defect, correct +// behavior asserted, tracked".) +TEST(KnownDefects, DISABLED_SetValueIntScalarUnregisteredPluginReturnsError) { + al_status_t s = + al_setvalue_int_scalar_parameter_plugin("param", 1, kUnregistered); + // The correct contract: report the unknown plugin as an error, like the + // registration checks the other Cluster-3 calls perform. + EXPECT_NE(s.code, 0) + << "setting a parameter on an unregistered plugin must return an " + "error, not crash"; +} + +// CURRENT-BEHAVIOR guard (death test). +// +// Actively pins today's crash so that a fix — which turns the crash into an +// error return — makes THIS test fail loudly, forcing whoever fixed it to also +// enable the correct-contract test above. This is the tripwire that keeps the +// quirk catalog honest; it asserts "it currently dies", not "it should die". +TEST(KnownDefectsDeath, SetValueIntScalarUnregisteredPluginCurrentlyCrashes) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + // Pinned to SIGSEGV specifically (the null-pointer dereference), not "any + // death" — an unrelated abort must not be mistaken for the defect being + // present. `.*` on the stderr matcher because a bare SIGSEGV prints nothing. + ASSERT_EXIT( + { + al_setvalue_int_scalar_parameter_plugin("param", 1, kUnregistered); + }, + ::testing::KilledBySignal(SIGSEGV), ".*") + << "expected the unregistered-plugin null-deref (see " + "FUNCTIONALITY_INVENTORY.md:410). If this no longer dies by SIGSEGV, " + "the defect was likely fixed — enable " + "KnownDefects.SetValueIntScalarUnregisteredPluginReturnsError."; +} + +} // namespace diff --git a/tests/contract/test_roundtrip.cpp b/tests/contract/test_roundtrip.cpp new file mode 100644 index 00000000..57b0fb56 --- /dev/null +++ b/tests/contract/test_roundtrip.cpp @@ -0,0 +1,100 @@ +// Round-trip contract tests — the scaffold-slice behavior proof. +// +// TEST_STRATEGY.md §4 step 1: one write->read round trip on an on-disk backend +// (HDF5) *and* an in-RAM backend (Memory), proving the machine end-to-end +// before the matrix is broadened. Oracle = round-trip self-consistency +// (read-back equals write), which needs zero DD artifacts (decision D5). +// +// This is intended-contract territory (decision D2): the ABI genuinely stores +// and returns what it was given, so we assert it directly. The suffix on each +// ctest name is the backend label (HDF5 / Memory). + +#include "al_contract.h" + +#include + +#include + +using al_contract::BackendCase; +using al_contract::PulseId; + +namespace { + +// A magnetics IDS is used purely as an opaque container of two leaf paths; the +// core attaches no DD semantics to it (Key architecture fact: DD paths are +// opaque strings through the whole C ABI). +constexpr const char* kIds = "magnetics"; +constexpr const char* kIntField = "ids_properties/homogeneous_time"; +constexpr const char* kStrField = "ids_properties/comment"; + +class RoundTrip : public ::testing::TestWithParam { +protected: + al_contract::TempBase base_; + // A single fixed pulse address; every case gets its own base_ dir, so the + // on-disk trees can never alias even though the address is shared. + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, + /*run=*/0}; +}; + +// Write an INTEGER scalar and a CHAR array, then read both back through a fresh +// READ operation on the same data entry, and assert exact equality. +TEST_P(RoundTrip, IntScalarAndCharArraySurviveWriteThenRead) { + const BackendCase backend = GetParam(); + if (backend.on_disk) { + base_.make_legacy_tree(pulse_); + } + + const std::string uri = + al_contract::build_uri(backend.id, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + + const int kIntValue = 1; + const std::string kStrValue = "round-trip"; + + // --- write --- + { + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, + &op_ctx)); + AL_EXPECT_OK(al_contract::write_int_scalar(op_ctx, kIntField, + kIntValue)); + AL_EXPECT_OK(al_contract::write_char_array(op_ctx, kStrField, + kStrValue)); + AL_ASSERT_OK(al_end_action(op_ctx)); + } + + // --- read back --- + { + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", READ_OP, + &op_ctx)); + + int read_int = -999; + AL_EXPECT_OK(al_contract::read_int_scalar(op_ctx, kIntField, + &read_int)); + EXPECT_EQ(read_int, kIntValue); + + std::string read_str; + AL_EXPECT_OK(al_contract::read_char_array(op_ctx, kStrField, + &read_str)); + EXPECT_EQ(read_str, kStrValue); + + AL_ASSERT_OK(al_end_action(op_ctx)); + } + + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); +} + +INSTANTIATE_TEST_SUITE_P( + Backends, RoundTrip, + ::testing::Values(BackendCase{HDF5_BACKEND, "HDF5", /*on_disk=*/true}, + BackendCase{MEMORY_BACKEND, "Memory", /*on_disk=*/false}), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.name); + }); + +} // namespace From 7fa83c3540446f9859dda62e9c4d75ec5b7c3482 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:10:09 +0200 Subject: [PATCH 04/91] Add Task-2 introspection + version-sentinel contract tests (issue #5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of TEST_STRATEGY.md §4 (the thin unit tier), building on the #2 scaffold. Binds only to the public C ABI per decision D1. What lands: - test_introspection.cpp — pure-lookup unit tests for Cluster 4 (FUNCTIONALITY_INVENTORY.md:434-459), all intended-contract (D2): const2str/err2str on mapped ids; the silent "" for unmapped ids asserted as the deliberate contract (incl. the documented TIMERANGE_OP / FLEXBUFFERS_BACKEND map-coverage gaps, :75-79); the two maps proven distinct; getALVersion == the compiled AL_VERSION macro and dotted-numeric; getDDVersion == "!!DEPRECATED!!". - test_version_sentinel.cpp — decision D5 tripwire: a fake DD version written to ids_properties/version_put/data_dictionary round-trips verbatim AND leaves getDDVersion() unchanged, proving the core interprets no DD version. Parametrized over Memory (hermetic) and HDF5 (on-disk). - TRACEABILITY.md — decision D8 matrix; Cluster 4 + version-sentinel rows filled, scaffold rows seeded, remaining clusters listed as gaps for later build-order steps. - CMakeLists.txt — wires both files into contract_tests. The finer CTest unit/integration LABEL split (D6) stays deferred as the scaffold recorded; the pure-lookup subset is addressable via `ctest -R Introspection`. Per the issue's adapted TDD loop, each assertion was mutation-verified to fail for the right reason (real value surfaced) before setting the true expectation green. No new expected-fails; the crash-class defects remain in test_known_defects.cpp. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/contract/CMakeLists.txt | 15 +- tests/contract/TRACEABILITY.md | 86 +++++++++++ tests/contract/test_introspection.cpp | 176 +++++++++++++++++++++++ tests/contract/test_version_sentinel.cpp | 109 ++++++++++++++ 4 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 tests/contract/TRACEABILITY.md create mode 100644 tests/contract/test_introspection.cpp create mode 100644 tests/contract/test_version_sentinel.cpp diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index a17ea558..85c1ea04 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -48,9 +48,22 @@ option(AL_CONTRACT_SANITIZE ######################################################################################################################## # The contract test executable. +# +# Step 3 (issue #5) adds two files to the single suite: the pure introspection +# lookups (test_introspection.cpp — the thin unit tier: const2str/err2str/ +# versions, no backend) and the version sentinel (test_version_sentinel.cpp). +# They join the existing round-trip and known-defect files from the scaffold. +# +# The finer CTest unit/integration LABEL split (decision D6) stays deferred, as +# the scaffold commit recorded — it arrives with the broader matrix in a later +# step. Until then the pure-lookup subset is addressable directly via +# `ctest -R Introspection` / `--gtest_filter=Introspection.*` (a fast, disk-free +# inner loop) even though every case shares the single "contract" label. add_executable(contract_tests test_roundtrip.cpp + test_introspection.cpp + test_version_sentinel.cpp test_known_defects.cpp ) @@ -89,7 +102,7 @@ endif() # gtest_discover_tests runs each GoogleTest case as its own ctest entry (so # --gtest_list_tests / -R selection works) and applies the "contract" label to # all of them. The finer unit/integration labelling (decision D6) arrives with -# the broader matrix in later steps; the scaffold keeps one label. +# the broader matrix in later steps; the suite keeps one label. gtest_discover_tests(contract_tests PROPERTIES LABELS "contract" diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md new file mode 100644 index 00000000..0d0376e3 --- /dev/null +++ b/tests/contract/TRACEABILITY.md @@ -0,0 +1,86 @@ +# Contract-suite traceability matrix (Task 2) + +Per **TEST_STRATEGY.md decision D8**, this matrix — not line coverage — is the +**primary definition of "comprehensive."** One row per +`FUNCTIONALITY_INVENTORY.md` capability reachable through the public C ABI, +with the test(s) that cover it and a status: + +- **covered** — an intended-contract test asserts the behavior and passes. +- **xfail** — a genuine defect; the test asserts the *correct* behavior and is + expected-fail (decision D2). Flipping to pass means someone fixed it. +- **gap** — not yet tested; a later build-order step (TEST_STRATEGY §4) fills it. + +Line coverage (`gcov`/`lcov`) is a secondary signal only (D8): a rewrite may +restructure lines but must still satisfy every row here. + +Test IDs are GoogleTest `Suite.Case` names as registered in CTest; run one with +`ctest -R ` or `contract_tests --gtest_filter=`. + +> **Note on the Unit / Integ. columns.** They classify each test by *substrate* +> per decision D6 (pure lookups and Memory round trips are unit; on-disk +> backends are integration) — the intended tier taxonomy. The CTest `unit` / +> `integration` **labels** that would let `ctest -L unit` select them are +> deferred (see `CMakeLists.txt`); today every case carries the single +> `contract` label. Until the split lands, address the unit subset with +> `ctest -R Introspection`. + +--- + +## Cluster 4 — Introspection / diagnostics (issue #5 — the thin unit tier) + +`FUNCTIONALITY_INVENTORY.md:434-459`. Pure lookups: no context, no backend, +zero DD artifacts (decisions D5/D6). All **intended contract** (D2) — nothing +here is a defect, including the silent `""`, which the inventory documents as +deliberate. + +| Capability | Inventory ref | Unit | Integ. | Test(s) | Status | +|---|---|:--:|:--:|---|---| +| `const2str(id)` maps a known constant to its symbolic name | :434-444 | ✓ | — | `Introspection.Const2StrMapsKnownConstantsToTheirNames` | covered | +| `const2str` silently returns `""` for an unmapped id (intended, not an error) | :451-452 | ✓ | — | `Introspection.Const2StrReturnsEmptyForUnmappedId` | covered | +| `const2str` returns `""` for defined-but-unmapped constants (`TIMERANGE_OP`, `FLEXBUFFERS_BACKEND`) | :75-79, :453-454 | ✓ | — | `Introspection.Const2StrReturnsEmptyForDefinedButUnmappedConstants` | covered | +| `err2str(id)` maps a known error code to its name | :445-446 | ✓ | — | `Introspection.Err2StrMapsKnownErrorCodesToTheirNames` | covered | +| `err2str` silently returns `""` for an unmapped id | :451-452 | ✓ | — | `Introspection.Err2StrReturnsEmptyForUnmappedId` | covered | +| `const2str`/`err2str` are backed by separate maps (two namespaces) | :443-446 | ✓ | — | `Introspection.Const2StrAndErr2StrUseSeparateMaps` | covered | +| `getALVersion()` returns the compiled AL version string | :447 | ✓ | — | `Introspection.GetALVersionReturnsCompiledVersionMacro`, `Introspection.GetALVersionIsDottedNumeric` | covered | +| `getDDVersion()` returns the deprecated sentinel `"!!DEPRECATED!!"` (intended) | :455-459 | ✓ | — | `Introspection.GetDDVersionReturnsDeprecatedSentinel` | covered | + +## Version sentinel (issue #5 — decision D5) + +The tripwire: the core stores `ids_properties/version_put/data_dictionary` as +opaque data and interprets no DD version. A current-behavior test that Task 3's +version-negotiation work will watch. + +| Capability | Inventory ref | Unit | Integ. | Test(s) | Status | +|---|---|:--:|:--:|---|---| +| `version_put/data_dictionary` round-trips verbatim; writing it does **not** change `getDDVersion()` | CLAUDE.md; :455-459 | ✓ (Memory) | ✓ (HDF5) | `Backends/VersionSentinel.VersionPutRoundTripsOpaquelyAndIsNotInterpreted` | covered | + +--- + +## Already covered by the scaffold slice (#2 — TEST_STRATEGY §4 step 1) + +| Capability | Inventory ref | Unit | Integ. | Test(s) | Status | +|---|---|:--:|:--:|---|---| +| `al_write_data`→`al_read_data` round trip (INTEGER scalar + CHAR array) | Clusters 1-2 | ✓ (Memory) | ✓ (HDF5) | `Backends/RoundTrip.IntScalarAndCharArraySurviveWriteThenRead` | covered | +| `al_setvalue_int_scalar_parameter_plugin` on an unregistered name must return an error, not crash | :410-417 | — | ✓ | `KnownDefects.SetValueIntScalarUnregisteredPluginReturnsError` (DISABLED_) | **xfail** | +| ↳ current-behavior guard: that call crashes by SIGSEGV today | :410-417 | — | ✓ | `KnownDefectsDeath.SetValueIntScalarUnregisteredPluginCurrentlyCrashes` | covered (tripwire) | + +## In progress by a sibling issue (#2a — TEST_STRATEGY §4 step 2) + +| Capability | Test(s) | Status | +|---|---|---| +| Synthetic round-trip matrix: {HDF5, Memory, ASCII, Flexbuffers} × {CHAR, INTEGER, DOUBLE, COMPLEX} × scalar→7-D | `Backends/RoundTripMatrix.ReadEqualsWrite/*` | owned by #2a (Flexbuffers cases currently red) | + +--- + +## Remaining clusters — gaps to be filled by later build-order steps + +Per TEST_STRATEGY.md §4, still open (not covered by this issue): + +- **Cluster 1 — Pulse lifecycle**: open/info/close/erase, occurrences (step 2). +- **Cluster 2 — Data access**: `al_delete_data`, AOS write/iterate/read (step 2). +- **Cluster E — capability-gated ops**: slice / timerange / `list_filled_paths` + — positive on HDF5, paired-negative "must-refuse" on the others (step 4). +- **Cluster 3 — Plugins**: register/bind/unbind/unregister, parameter-setting, + state-machine quirks and the remaining defect death-tests (step 5). +- Backend-implementer / plugin-author capabilities reachable through the C ABI, + swept until no row is blank (step 6). diff --git a/tests/contract/test_introspection.cpp b/tests/contract/test_introspection.cpp new file mode 100644 index 00000000..a4e22290 --- /dev/null +++ b/tests/contract/test_introspection.cpp @@ -0,0 +1,176 @@ +// Introspection / diagnostics contract tests — the thin unit tier. +// +// TEST_STRATEGY.md §4 step 3 + issue #5: the pure-lookup surface of the C ABI +// (FUNCTIONALITY_INVENTORY.md "Cluster 4 — Introspection / diagnostics", +// lines 434-459). These four functions take no context and touch no backend, +// so they are the genuinely-unit part of a C-ABI-only layer (decision D6) and +// need zero DD artifacts (decision D5). +// +// Every assertion here is *intended contract* (decision D2), asserted +// directly — including the silent "" for unmapped ids, which the inventory +// documents as deliberate (al_const.cpp:4-11), not a defect. Nothing in this +// file is expected-fail; the crash-class defects live in test_known_defects.cpp. + +#include +#include // AL_VERSION, DD_VERSION — the compiled-in macros we + // check getALVersion()/getDDVersion() actually return. + +#include + +#include + +namespace { + +// --------------------------------------------------------------------------- +// const2str — maps a vocabulary constant to its symbolic name. +// --------------------------------------------------------------------------- + +// Mapped ids: a representative spread across the constmap categories (backend, +// operation, access mode, pulse action, datatype). Table-driven so a missing +// or renamed entry names itself in the failure. +TEST(Introspection, Const2StrMapsKnownConstantsToTheirNames) { + struct Case { int id; const char* name; }; + const Case cases[] = { + {NO_BACKEND, "NO_BACKEND"}, + {ASCII_BACKEND, "ASCII_BACKEND"}, + {MDSPLUS_BACKEND, "MDSPLUS_BACKEND"}, + {HDF5_BACKEND, "HDF5_BACKEND"}, + {MEMORY_BACKEND, "MEMORY_BACKEND"}, + {UDA_BACKEND, "UDA_BACKEND"}, + {GLOBAL_OP, "GLOBAL_OP"}, + {SLICE_OP, "SLICE_OP"}, + {READ_OP, "READ_OP"}, + {WRITE_OP, "WRITE_OP"}, + {OPEN_PULSE, "OPEN_PULSE"}, + {FORCE_CREATE_PULSE, "FORCE_CREATE_PULSE"}, + {CLOSE_PULSE, "CLOSE_PULSE"}, + {ERASE_PULSE, "ERASE_PULSE"}, + {CHAR_DATA, "CHAR_DATA"}, + {INTEGER_DATA, "INTEGER_DATA"}, + {DOUBLE_DATA, "DOUBLE_DATA"}, + {COMPLEX_DATA, "COMPLEX_DATA"}, + }; + for (const Case& c : cases) { + const char* got = const2str(c.id); + ASSERT_NE(got, nullptr) << "const2str must never return NULL (id=" << c.id << ")"; + EXPECT_EQ(std::string(got), c.name) << "const2str(" << c.id << ")"; + } +} + +// Unmapped id: an out-of-range value is not in constmap, so the documented +// contract is a silent empty string (NOT NULL, NOT an error). al_const.cpp:7-8. +TEST(Introspection, Const2StrReturnsEmptyForUnmappedId) { + const char* got = const2str(999999); + ASSERT_NE(got, nullptr) << "unmapped id must yield \"\", never NULL"; + EXPECT_STREQ(got, "") << "unmapped id must silently return an empty string"; +} + +// Known map-coverage gaps, asserted as the *intended* silent-"" contract: +// FUNCTIONALITY_INVENTORY.md:75-79 & 451-454 record that TIMERANGE_OP and +// FLEXBUFFERS_BACKEND are defined constants deliberately absent from +// alconst::constmap, so const2str returns "" for them rather than erroring. +// This pins that documented quirk so a future map change is a visible event. +TEST(Introspection, Const2StrReturnsEmptyForDefinedButUnmappedConstants) { + EXPECT_STREQ(const2str(TIMERANGE_OP), "") + << "TIMERANGE_OP is a defined constant absent from constmap " + "(FUNCTIONALITY_INVENTORY.md:451-454)"; + EXPECT_STREQ(const2str(FLEXBUFFERS_BACKEND), "") + << "FLEXBUFFERS_BACKEND is a defined constant absent from constmap " + "(FUNCTIONALITY_INVENTORY.md:75-79)"; +} + +// --------------------------------------------------------------------------- +// err2str — maps an error code to its name (a separate map from const2str). +// --------------------------------------------------------------------------- + +TEST(Introspection, Err2StrMapsKnownErrorCodesToTheirNames) { + struct Case { int id; const char* name; }; + const Case cases[] = { + {UNKNOWN_ERR, "UNKNOWN_ERR"}, + {CONTEXT_ERR, "CONTEXT_ERR"}, + {BACKEND_ERR, "BACKEND_ERR"}, + {LOWLEVEL_ERR, "LOWLEVEL_ERR"}, + }; + for (const Case& c : cases) { + const char* got = err2str(c.id); + ASSERT_NE(got, nullptr) << "err2str must never return NULL (id=" << c.id << ")"; + EXPECT_EQ(std::string(got), c.name) << "err2str(" << c.id << ")"; + } +} + +TEST(Introspection, Err2StrReturnsEmptyForUnmappedId) { + const char* got = err2str(999999); + ASSERT_NE(got, nullptr) << "unmapped error id must yield \"\", never NULL"; + EXPECT_STREQ(got, "") << "unmapped error id must silently return an empty string"; +} + +// const2str and err2str are backed by *distinct* maps: an error code resolves +// only through err2str, a vocabulary constant only through const2str. The two +// key spaces don't even overlap — error codes are negative (al_defs.h ERR_0=-1) +// and constmap keys positive — so this documents the two-namespace design +// deterministically, in both directions. +TEST(Introspection, Const2StrAndErr2StrUseSeparateMaps) { + // An error code is not a vocabulary constant: const2str must not resolve it. + EXPECT_STREQ(const2str(BACKEND_ERR), "") + << "an error code must not resolve through const2str"; + EXPECT_EQ(std::string(err2str(BACKEND_ERR)), "BACKEND_ERR"); + + // Symmetrically, a backend constant is not an error code. + EXPECT_STREQ(err2str(HDF5_BACKEND), "") + << "a vocabulary constant must not resolve through err2str"; + EXPECT_EQ(std::string(const2str(HDF5_BACKEND)), "HDF5_BACKEND"); +} + +// --------------------------------------------------------------------------- +// getALVersion — returns the AL library version string. +// --------------------------------------------------------------------------- + +// The tightest self-consistent oracle: the function must return exactly the +// compiled-in AL_VERSION macro (al_defs.h, from CMake PROJECT_VERSION). This +// verifies the linkage returns the constant, not merely that it is non-empty. +TEST(Introspection, GetALVersionReturnsCompiledVersionMacro) { + const char* got = getALVersion(); + ASSERT_NE(got, nullptr); + EXPECT_STREQ(got, AL_VERSION); + EXPECT_FALSE(std::string(got).empty()) << "AL version must not be empty"; +} + +// And it must be a dotted-numeric version whose first three components parse as +// integers — the shape python/tests/test_imasdef.py::test_AL_version relies on. +TEST(Introspection, GetALVersionIsDottedNumeric) { + const std::string v = getALVersion(); + int components = 0; + size_t start = 0; + while (start <= v.size() && components < 3) { + size_t dot = v.find('.', start); + const std::string tok = v.substr( + start, dot == std::string::npos ? std::string::npos : dot - start); + ASSERT_FALSE(tok.empty()) << "empty version component in '" << v << "'"; + for (char ch : tok) { + ASSERT_TRUE(ch >= '0' && ch <= '9') + << "non-numeric version component '" << tok << "' in '" << v << "'"; + } + ++components; + if (dot == std::string::npos) break; + start = dot + 1; + } + EXPECT_GE(components, 3) + << "expected at least MAJOR.MINOR.PATCH in '" << v << "'"; +} + +// --------------------------------------------------------------------------- +// getDDVersion — the deprecated sentinel (intended contract). +// --------------------------------------------------------------------------- + +// CLAUDE.md + include/al_defs.h.in:57 deliberately set DD_VERSION to +// "!!DEPRECATED!!"; python/tests/test_imasdef.py::test_DD_version asserts the +// same at the binding layer. The core carries no compile-time DD coupling, so +// the C ABI must surface this sentinel verbatim — not real version info. +TEST(Introspection, GetDDVersionReturnsDeprecatedSentinel) { + const char* got = getDDVersion(); + ASSERT_NE(got, nullptr); + EXPECT_STREQ(got, "!!DEPRECATED!!"); + EXPECT_STREQ(got, DD_VERSION) << "must match the compiled-in DD_VERSION macro"; +} + +} // namespace diff --git a/tests/contract/test_version_sentinel.cpp b/tests/contract/test_version_sentinel.cpp new file mode 100644 index 00000000..f348c20d --- /dev/null +++ b/tests/contract/test_version_sentinel.cpp @@ -0,0 +1,109 @@ +// Version sentinel contract test — the tripwire for accidental DD-awareness. +// +// TEST_STRATEGY.md decision D5 + issue #5: the core must treat +// `ids_properties/version_put/data_dictionary` as ordinary opaque string data. +// It is the field the HLI uses to record which DD version wrote the data, but +// per CLAUDE.md the core interprets no DD version anywhere — it stores what it +// is given and returns it verbatim. This test pins that: a write→read of an +// obviously-fake version string comes back byte-for-byte, AND getDDVersion() +// stays the deprecated sentinel, so any future move to version-awareness shows +// up here as a deliberate, visible change rather than an accident (this is the +// current-behavior test that Task 3's version-negotiation work will watch). +// +// Runs on Memory (hermetic, unit-ish) and HDF5 (the real on-disk path): the +// non-interpretation property is backend-independent, so proving it on both +// rules out a backend quietly parsing the field. + +#include "al_contract.h" // pulls in al_lowlevel.h + al_const.h transitively + +#include + +#include + +using al_contract::BackendCase; +using al_contract::PulseId; + +namespace { + +// The DD path the HLI writes the writer's DD version into. Opaque to the core. +constexpr const char* kIds = "magnetics"; +constexpr const char* kVersionPutPath = + "ids_properties/version_put/data_dictionary"; + +// Deliberately NOT a real DD version and NOT the deprecated sentinel: if the +// core ever interpreted this field, storing this value would either be rejected +// or would perturb getDDVersion(). Neither may happen. +constexpr const char* kFakeVersion = "9.9.9-sentinel-not-a-real-dd"; + +// The compile-time value getDDVersion() must keep returning regardless of what +// version string is stored (include/al_defs.h.in:57; test_introspection.cpp +// cross-checks it against the DD_VERSION macro). +constexpr const char* kDeprecatedSentinel = "!!DEPRECATED!!"; + +class VersionSentinel : public ::testing::TestWithParam { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/13, /*run=*/0}; +}; + +TEST_P(VersionSentinel, VersionPutRoundTripsOpaquelyAndIsNotInterpreted) { + const BackendCase backend = GetParam(); + if (backend.on_disk) { + base_.make_legacy_tree(pulse_); + } + + // Baseline: the sentinel before any data is written. + const std::string dd_before = getDDVersion(); + EXPECT_EQ(dd_before, kDeprecatedSentinel); + + const std::string uri = + al_contract::build_uri(backend.id, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + + // --- write the fake version into version_put/data_dictionary --- + { + int op_ctx = -1; + AL_ASSERT_OK( + al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, &op_ctx)); + AL_EXPECT_OK( + al_contract::write_char_array(op_ctx, kVersionPutPath, kFakeVersion)); + AL_ASSERT_OK(al_end_action(op_ctx)); + } + + // Writing a DD-version string must not have taught the core a DD version: + // getDDVersion() is a compile-time sentinel, independent of stored data. + EXPECT_EQ(std::string(getDDVersion()), kDeprecatedSentinel) + << "the core must not adopt a stored version_put value as its DD version"; + + // --- read it back; it must be the exact bytes we wrote (opaque) --- + { + int op_ctx = -1; + AL_ASSERT_OK( + al_begin_global_action(pulse_ctx, kIds, "", READ_OP, &op_ctx)); + std::string read_back; + AL_EXPECT_OK( + al_contract::read_char_array(op_ctx, kVersionPutPath, &read_back)); + EXPECT_EQ(read_back, kFakeVersion) + << "version_put/data_dictionary must round-trip verbatim, unparsed"; + AL_ASSERT_OK(al_end_action(op_ctx)); + } + + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + + // Still the sentinel after a full write→read cycle. + EXPECT_EQ(std::string(getDDVersion()), kDeprecatedSentinel); +} + +INSTANTIATE_TEST_SUITE_P( + Backends, VersionSentinel, + ::testing::Values(BackendCase{HDF5_BACKEND, "HDF5", /*on_disk=*/true}, + BackendCase{MEMORY_BACKEND, "Memory", /*on_disk=*/false}), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.name); + }); + +} // namespace From edbdee02dd7e07849624ca7ed20314df36346c1b Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:50:31 +0200 Subject: [PATCH 05/91] =?UTF-8?q?Add=20synthetic=20write=E2=86=92read=20ro?= =?UTF-8?q?und-trip=20matrix=20(issue=20#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Step 2 of TEST_STRATEGY.md — the bulk of the storage contract — building on the scaffold (issue #2) and binding only to the public C ABI (D1). Fixture layer (al_contract.h): - Deterministic synthetic-data generator over the datatype × shape space: synth_value/synth_buffer for CHAR/INTEGER/DOUBLE/COMPLEX and shape_for_rank scalar→7-D (MAXDIM), with values kept clear of the EMPTY sentinels so a scalar write is never silently dropped by data_has_non_zero_shape. - Generic typed write_data/read_data honoring the two ABI ownership regimes (scalar caller-buffer; array core-allocated + freed). - build_uri gains a FLEXBUFFERS branch: getURIBackend (al_context.cpp:280) has no flexbuffers case though the parse side accepts the scheme, so the URI is hand-built (documented, and tracked as an expected-fail). Matrix (test_roundtrip_matrix.cpp): - One TEST_P over Combine({HDF5,Memory,ASCII,Flexbuffers} × {CHAR,INTEGER, DOUBLE,COMPLEX} × ranks 0..7); each cell its own ctest entry. Oracle = round-trip self-consistency, zero DD artifacts (D5). - Each cell classified once (D2/D4): RoundTrip (assert equality), Refused (paired-negative — CHAR>2D on HDF5/ASCII; every Flexbuffers cell, which accepts the write but refuses the read-back as a serializer), or KnownDefect. - Known defects asserted as their correct contract and marked expected-fail: HDF5 crash on a CHAR scalar (DISABLED_ + paired death test), ASCII numeric corruption/abort at the MAXDIM boundary, and the flexbuffers URI-builder gap. TRACEABILITY.md: al_read_data/al_write_data rows filled (D8). Full contract suite: 139 passed, 4 known-defect cells skipped, 6 expected-fails disabled; each expected-fail verified to produce a real red when enabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/contract/CMakeLists.txt | 1 + tests/contract/TRACEABILITY.md | 33 ++- tests/contract/al_contract.h | 166 +++++++++++ tests/contract/test_roundtrip_matrix.cpp | 363 +++++++++++++++++++++++ 4 files changed, 559 insertions(+), 4 deletions(-) create mode 100644 tests/contract/test_roundtrip_matrix.cpp diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 85c1ea04..170cc699 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -62,6 +62,7 @@ option(AL_CONTRACT_SANITIZE add_executable(contract_tests test_roundtrip.cpp + test_roundtrip_matrix.cpp test_introspection.cpp test_version_sentinel.cpp test_known_defects.cpp diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 0d0376e3..77f110e7 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -64,11 +64,36 @@ version-negotiation work will watch. | `al_setvalue_int_scalar_parameter_plugin` on an unregistered name must return an error, not crash | :410-417 | — | ✓ | `KnownDefects.SetValueIntScalarUnregisteredPluginReturnsError` (DISABLED_) | **xfail** | | ↳ current-behavior guard: that call crashes by SIGSEGV today | :410-417 | — | ✓ | `KnownDefectsDeath.SetValueIntScalarUnregisteredPluginCurrentlyCrashes` | covered (tripwire) | -## In progress by a sibling issue (#2a — TEST_STRATEGY §4 step 2) +## Cluster 2 — Core data access: `al_write_data` / `al_read_data` (issue #3 — TEST_STRATEGY §4 step 2) + +The synthetic write→read round-trip matrix — the bulk of the storage contract. +Oracle = round-trip self-consistency (decision D5); synthetic opaque data, zero +DD artifacts. Parametrized over the always-on tier (decision D4) +{HDF5, Memory, ASCII, Flexbuffers} × {CHAR, INTEGER, DOUBLE, COMPLEX} × +{scalar → 7-D}. Each cell is classified once (D2/D4): a plain round trip, a +documented refusal (paired-negative), or a genuine defect (expected-fail). +Flexbuffers is a serializer, not a pulse store, so every one of its cells is a +must-refuse column (it accepts the write but refuses the read-back — D4's +prescription for an unsupported operation). -| Capability | Test(s) | Status | -|---|---|---| -| Synthetic round-trip matrix: {HDF5, Memory, ASCII, Flexbuffers} × {CHAR, INTEGER, DOUBLE, COMPLEX} × scalar→7-D | `Backends/RoundTripMatrix.ReadEqualsWrite/*` | owned by #2a (Flexbuffers cases currently red) | +| Capability | Inventory ref | Unit | Integ. | Test(s) | Status | +|---|---|:--:|:--:|---|---| +| `al_write_data`→`al_read_data` round trip preserves value + shape, INTEGER/DOUBLE/COMPLEX, scalar→7-D, on HDF5·Memory·ASCII (ASCII: scalar→6-D) | Cluster 2 | ✓ (Memory) | ✓ (HDF5, ASCII) | `Backends/RoundTripMatrix.ReadEqualsWrite/{HDF5,Memory,ASCII}_{INTEGER,DOUBLE,COMPLEX}_r{0..7}` | covered | +| Round trip for CHAR — scalar & 1-D (string) & 2-D (array-of-strings) on HDF5·ASCII; all ranks on Memory (raw bytes) | Cluster 2 | ✓ (Memory) | ✓ (HDF5, ASCII) | `…/{Memory}_CHAR_r{0..7}`, `…/{HDF5,ASCII}_CHAR_r{1,2}` | covered | +| Deterministic synthetic-data generator: datatype × shape (scalar→7-D), values clear of the EMPTY sentinels | D5 | ✓ | ✓ | `al_contract.h` `synth_value`/`synth_buffer`/`shape_for_rank`; exercised by every `RoundTripMatrix` cell | covered | +| Flexbuffers must-refuse column: accepts the write but refuses the read-back (serializer, not a pulse store), every datatype × shape | Cluster 2 / D4 | — | ✓ | `Backends/RoundTripMatrix.ReadEqualsWrite/Flexbuffers_*_r{0..7}` (paired-negative) | covered | +| CHAR data > 2-D is refused (documented "not implemented"): HDF5 & ASCII | Cluster 2 / D4 | — | ✓ | `Backends/RoundTripMatrix.ReadEqualsWrite/{HDF5,ASCII}_CHAR_r{3..7}` (paired-negative) | covered | +| A CHAR scalar (dim 0) must round-trip, not crash — **HDF5 crashes** | Cluster 2 | — | ✓ | `RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips` + `RoundTripKnownDefectsDeath.Hdf5CharScalarCurrentlyCrashes` | **xfail** | +| Numeric data at the MAXDIM boundary (rank 7) must round-trip — **ASCII corrupts (DOUBLE/COMPLEX) / aborts (INTEGER)** | Cluster 2 | — | ✓ | `RoundTripKnownDefects.DISABLED_Ascii{Integer,Double,Complex}MaxdimRoundTrips` | **xfail** | +| `al_build_uri_from_legacy_parameters` must address the always-on FLEXBUFFERS backend — **it throws (getURIBackend has no case)** | src/al_context.cpp:280 | ✓ | — | `RoundTripKnownDefects.DISABLED_BuildUriSupportsFlexbuffers` | **xfail** | + +> **Deferred (recorded):** a full Flexbuffers **serialize→deserialize** round +> trip over the datatype × shape space needs the HLI `` protocol and +> belongs to the serialize seam; the matrix only pins its must-refuse pulse- +> lifecycle behavior. Occurrences, `al_delete_data`, AOS, and the pulse +> lifecycle (Cluster 1) remain with issue #4 (structured data). CHAR>2-D on +> Memory round-trips incidentally (raw bytes) and is asserted as such; it is not +> part of the CHAR contract. --- diff --git a/tests/contract/al_contract.h b/tests/contract/al_contract.h index 92ce8445..98ddd4b7 100644 --- a/tests/contract/al_contract.h +++ b/tests/contract/al_contract.h @@ -18,11 +18,14 @@ #include #include +#include +#include #include #include #include #include #include +#include #if defined(_WIN32) #include @@ -128,8 +131,24 @@ class TempBase { // --------------------------------------------------------------------------- // Uses al_build_uri_from_legacy_parameters so the URI is whatever the core // itself produces for a given backend id — we don't hand-format schemes. +// +// Exception: the always-on FLEXBUFFERS backend. al_build_uri_from_legacy_parameters +// -> getURIBackend (src/al_context.cpp:280) has no FLEXBUFFERS_BACKEND case and +// throws "not yet implemented", even though the *parse* side accepts the +// "flexbuffers" scheme (al_context.cpp:214). So for that one backend we build +// the same legacy query URI directly — mirroring the exact format +// build_uri_from_legacy_parameters emits (al_context.cpp:252), including its +// version[0] single-char quirk — so the storage round trip is still exercised. +// The builder gap itself is a genuine defect, tracked separately as a +// known-defect test (see test_known_defects.cpp); it is NOT papered over here. inline std::string build_uri(int backend_id, const std::string& base, const PulseId& id) { + if (backend_id == FLEXBUFFERS_BACKEND) { + return "imas:flexbuffers?user=" + base + ";pulse=" + + std::to_string(id.pulse) + ";run=" + std::to_string(id.run) + + ";database=" + id.database + ";version=" + + (id.version.empty() ? std::string() : id.version.substr(0, 1)); + } char* uri = nullptr; al_status_t s = al_build_uri_from_legacy_parameters( backend_id, id.pulse, id.run, base.c_str(), id.database.c_str(), @@ -178,6 +197,153 @@ inline al_status_t read_char_array(int ctx, const char* field, return s; } +// =========================================================================== +// Synthetic-data generator + generic typed round-trip (issue #3). +// =========================================================================== +// The bulk of the storage contract is proven by *round-trip self-consistency* +// over synthetic opaque data (decision D5): write a deterministic buffer, read +// it back, assert byte-for-byte equality. It needs zero DD artifacts, which is +// itself the proof of the version-free property. This block generalizes the +// two hand-written scalar/char helpers above to the whole +// datatype × shape space. + +// --- datatype descriptor: element C++ type -> ABI datatype id + label ------- +template +struct DType; +template <> +struct DType { + static constexpr int value = CHAR_DATA; + static constexpr const char* name = "CHAR"; +}; +template <> +struct DType { + static constexpr int value = INTEGER_DATA; + static constexpr const char* name = "INTEGER"; +}; +template <> +struct DType { + static constexpr int value = DOUBLE_DATA; + static constexpr const char* name = "DOUBLE"; +}; +template <> +struct DType> { + static constexpr int value = COMPLEX_DATA; + static constexpr const char* name = "COMPLEX"; +}; + +// The core's "this value means absent" sentinels (src/al_lowlevel.cpp:41-44). +// A *scalar* write whose value equals its sentinel is silently dropped by +// Lowlevel::data_has_non_zero_shape (al_lowlevel.cpp:759) — the generator must +// never emit one, or the round trip would compare against a default-filled +// read and give a false failure. (Arrays are gated on shape, not value, so +// element values there are unrestricted; we stay clear for all ranks anyway.) +inline constexpr int kEmptyInt = -999999999; +inline constexpr double kEmptyDouble = -9.0E40; + +// --- deterministic synthetic value for flat element index i ----------------- +// Distinct per index so a transposed / off-by-one round trip is caught, and +// guaranteed clear of every sentinel above. +template +T synth_value(std::size_t i); + +template <> +inline char synth_value(std::size_t i) { + // Printable ASCII 33..122, never '\0' (EMPTY_CHAR). + return static_cast('!' + static_cast(i % 90)); +} +template <> +inline int synth_value(std::size_t i) { + int v = static_cast(i) * 7 - 11; // spans negatives and positives + return v == kEmptyInt ? 1 : v; +} +template <> +inline double synth_value(std::size_t i) { + double v = static_cast(i) * 1.5 - 3.25; // exactly representable + return v == kEmptyDouble ? 1.0 : v; +} +template <> +inline std::complex synth_value>(std::size_t i) { + double re = static_cast(i) * 1.5 - 3.25; + double im = static_cast(i) * -0.75 + 2.0; + if (re == kEmptyDouble) re = 1.0; + if (im == kEmptyDouble) im = 1.0; + return {re, im}; // clear of EMPTY_COMPLEX == (-9e40,-9e40) +} + +// --- shape for a given rank ------------------------------------------------- +// rank 0 -> scalar (empty shape, one element). rank R -> R dims sized 2,3,2,3… +// so every dimension is >= 1 (a zero dim would itself read as "absent") while +// the 7-D total stays small (2*3*2*3*2*3*2 = 432 elements). +inline std::vector shape_for_rank(int rank) { + std::vector s; + s.reserve(static_cast(rank)); + for (int i = 0; i < rank; ++i) s.push_back((i % 2 == 0) ? 2 : 3); + return s; +} + +inline std::size_t element_count(const std::vector& shape) { + std::size_t n = 1; + for (int d : shape) n *= static_cast(d); + return n; +} + +template +std::vector synth_buffer(std::size_t n) { + std::vector v; + v.reserve(n); + for (std::size_t i = 0; i < n; ++i) v.push_back(synth_value(i)); + return v; +} + +// --- generic write ---------------------------------------------------------- +// al_write_data takes a non-const void* and int* size but does not mutate +// them for a write; the const_casts keep the caller's data/shape const. +template +al_status_t write_data(int ctx, const char* field, const std::vector& shape, + const std::vector& data) { + const int dim = static_cast(shape.size()); + int* size = dim ? const_cast(shape.data()) : nullptr; + return al_write_data(ctx, field, "", const_cast(data.data()), + DType::value, dim, size); +} + +// --- generic read ----------------------------------------------------------- +// Honors the two ownership regimes the ABI uses (mirrors testlowlevel.cpp): +// dim == 0 : the core writes into the caller's buffer; nothing to free. +// dim >= 1 : the core malloc's the buffer and returns it via *data; the +// caller owns it, so we copy out and free. +// On success out_shape is the shape the core reported (scalar -> empty) and +// out_data holds element_count(out_shape) elements. +template +al_status_t read_data(int ctx, const char* field, int expected_rank, + std::vector* out_shape, std::vector* out_data) { + int size[MAXDIM] = {0}; + + if (expected_rank == 0) { + T scalar{}; + void* buf = &scalar; + al_status_t s = + al_read_data(ctx, field, "", &buf, DType::value, 0, size); + if (s.code == 0) { + out_shape->clear(); + out_data->assign(1, scalar); + } + return s; + } + + void* buf = nullptr; + al_status_t s = al_read_data(ctx, field, "", &buf, DType::value, + expected_rank, size); + if (s.code == 0 && buf != nullptr) { + out_shape->assign(size, size + expected_rank); + const std::size_t n = element_count(*out_shape); + T* p = static_cast(buf); + out_data->assign(p, p + n); + } + free(buf); + return s; +} + } // namespace al_contract #endif // AL_CONTRACT_H diff --git a/tests/contract/test_roundtrip_matrix.cpp b/tests/contract/test_roundtrip_matrix.cpp new file mode 100644 index 00000000..7d8ce5cc --- /dev/null +++ b/tests/contract/test_roundtrip_matrix.cpp @@ -0,0 +1,363 @@ +// Synthetic write->read round-trip matrix (issue #3). +// +// TEST_STRATEGY.md §4 step 2: the bulk of the storage contract. One round-trip +// test is value-parametrized over the always-on backend tier (decision D4) +// {HDF5, Memory, ASCII, Flexbuffers} x {CHAR, INTEGER, DOUBLE, COMPLEX} +// x {scalar, 1-D, ... 7-D} +// (D4's columns x D5's synthetic data). Each cell is its own ctest entry, so +// one failing datatype/shape/backend is pinpointed by name. +// +// Oracle = round-trip self-consistency (decision D5): the ABI is handed a +// deterministic synthetic buffer and must return exactly those bytes with the +// shape it was given. No Data Dictionary artifacts are required — itself the +// standing proof of the version-free storage property the migration protects. +// +// Not every (backend, datatype, shape) cell is a plain round trip. Following +// decision D2 (assert the intended contract; never freeze a bug) and D4 +// (capability-gating with paired negatives), each cell is classified once: +// * RoundTrip - the backend stores and returns the data; assert equality. +// * Refused - the backend documents the operation as unsupported and must +// refuse it; assert the refusal (D4's paired-negative). Two +// cases: CHAR > 2D on the tensorizing/text backends (IMAS +// models CHAR as string / array-of-strings), and every cell +// of Flexbuffers, which is a serializer, not a pulse store — +// it refuses a read while writing (src/flexbuffers_backend.cpp +// :358) and never persists (:148). Its full serialize -> +// deserialize round trip needs the HLI "" protocol and +// belongs to the serialize seam; here it is the must-refuse +// column D4 prescribes. +// * KnownDefect - the backend crashes or corrupts on a shape it ought to +// handle; the cell is skipped here and the *correct* contract +// is asserted (expected-fail) in the RoundTripKnownDefects.* +// tests below, so a fix flips them to pass. + +#include "al_contract.h" + +#include + +#include +#include +#include +#include +#include + +using al_contract::BackendCase; +using al_contract::PulseId; + +namespace { + +// A magnetics IDS is used purely as an opaque container; the core attaches no +// DD semantics to it (DD paths are opaque strings through the whole C ABI). +constexpr const char* kIds = "magnetics"; + +// The always-on backend tier (decision D4). MDSplus/UDA are compile-guarded and +// covered elsewhere; they are deliberately absent here, not failing. +const BackendCase kBackends[] = { + {HDF5_BACKEND, "HDF5", /*on_disk=*/true}, + {MEMORY_BACKEND, "Memory", /*on_disk=*/false}, + {ASCII_BACKEND, "ASCII", /*on_disk=*/true}, + {FLEXBUFFERS_BACKEND, "Flexbuffers", /*on_disk=*/true}, +}; + +// Datatype axis. The enum indexes a switch that recovers the C++ element type, +// so the matrix stays a single flat Combine() rather than four near-copies. +enum class DType { Char, Int, Double, Complex }; +struct DataTypeCase { + DType dt; + const char* name; +}; +const DataTypeCase kDataTypes[] = { + {DType::Char, "CHAR"}, + {DType::Int, "INTEGER"}, + {DType::Double, "DOUBLE"}, + {DType::Complex, "COMPLEX"}, +}; + +// Per-cell classification (see file header). Everything not named here is a +// plain round trip; the exceptions are the backends' real capability edges, +// each observed empirically and cross-checked against the source. +enum class Expect { RoundTrip, Refused, KnownDefect }; + +Expect classify(int backend_id, DType dt, int rank) { + // Flexbuffers is a serializer, not a pulse store: it accepts the write but + // refuses the read-back within the session, for every datatype and shape. + if (backend_id == FLEXBUFFERS_BACKEND) { + return Expect::Refused; + } + + const bool is_char = (dt == DType::Char); + + // ASCII corrupts / crashes numeric data at the MAXDIM boundary (rank 7): + // INTEGER aborts, DOUBLE/COMPLEX read back wrong. HDF5/Memory are fine at + // rank 7, so this is an ASCII-specific defect, not a shape limit. + if (!is_char && rank == MAXDIM && backend_id == ASCII_BACKEND) { + return Expect::KnownDefect; + } + + if (is_char) { + // CHAR > 2D is documented-unsupported on the tensorizing/text backends + // ("CHAR data > 2D is not implemented yet" on ASCII; a dimension- + // mismatch error on HDF5). Memory keeps raw bytes and round-trips. + if (rank >= 3 && + (backend_id == HDF5_BACKEND || backend_id == ASCII_BACKEND)) { + return Expect::Refused; + } + // A CHAR scalar (dim 0) crashes the HDF5 backend; Memory/ASCII handle + // it. Tracked as a defect, not frozen as "char-0D unsupported". + if (rank == 0 && backend_id == HDF5_BACKEND) { + return Expect::KnownDefect; + } + } + return Expect::RoundTrip; +} + +// A distinct opaque leaf per (datatype, rank) so cells never collide on a path. +template +std::string field_for(int rank) { + return std::string("ids_properties/synthetic_") + al_contract::DType::name + + "_r" + std::to_string(rank); +} + +// The inputs one cell operates on: the opened-entry URI plus the synthetic +// buffer, its shape, and the leaf path to store it at. Bundled so the identical +// setup preamble isn't copy-pasted between the round-trip and refusal helpers. +template +struct Cell { + std::string uri; + std::vector shape; + std::vector written; + std::string field; +}; + +template +Cell make_cell(const BackendCase& backend, const al_contract::TempBase& base, + const PulseId& pulse, int rank) { + if (backend.on_disk) { + base.make_legacy_tree(pulse); + } + Cell c; + c.uri = al_contract::build_uri(backend.id, base.str(), pulse); + c.shape = al_contract::shape_for_rank(rank); + c.written = al_contract::synth_buffer(al_contract::element_count(c.shape)); + c.field = field_for(rank); + return c; +} + +// Write one field into an already-open pulse, in its own WRITE global action. +template +void write_field(int pulse_ctx, const std::string& field, + const std::vector& shape, const std::vector& data) { + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, &op_ctx)); + AL_EXPECT_OK(al_contract::write_data(op_ctx, field.c_str(), shape, data)); + AL_ASSERT_OK(al_end_action(op_ctx)); +} + +// Read one field back and assert it equals what was written, byte-for-byte +// (the round-trip oracle, decision D5): same shape, same count, same values. +template +void read_field_and_expect(int pulse_ctx, const std::string& field, int rank, + const std::vector& shape, + const std::vector& written) { + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", READ_OP, &op_ctx)); + + std::vector read_shape; + std::vector read_data; + AL_EXPECT_OK(al_contract::read_data(op_ctx, field.c_str(), rank, + &read_shape, &read_data)); + + EXPECT_EQ(read_shape, shape) << "shape changed across the round trip"; + ASSERT_EQ(read_data.size(), written.size()) + << "element count changed across the round trip"; + EXPECT_EQ(read_data, written) << "data changed across the round trip"; + + AL_ASSERT_OK(al_end_action(op_ctx)); +} + +// One typed round trip: generate -> write -> read -> assert byte-exact equal. +// Write and read share one open data entry (mandatory for the transient Memory +// backend; fine for the persistent ones). Fatal ASSERTs return from this +// helper, which is the whole cell body, so nothing runs on a broken setup. +template +void run_round_trip(const BackendCase& backend, const al_contract::TempBase& base, + const PulseId& pulse, int rank) { + const Cell c = make_cell(backend, base, pulse, rank); + ASSERT_FALSE(c.uri.empty()); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(c.uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + write_field(pulse_ctx, c.field, c.shape, c.written); + read_field_and_expect(pulse_ctx, c.field, rank, c.shape, c.written); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); +} + +// Paired-negative (decision D4): the operation is documented-unsupported, so +// the round-trip attempt must be refused. The refusal may surface on write +// (ASCII CHAR>2D) or on read (HDF5 CHAR>2D dimension mismatch; Flexbuffers read- +// while-serializing), so we accept either. +template +void expect_refused(const BackendCase& backend, const al_contract::TempBase& base, + const PulseId& pulse, int rank) { + const Cell c = make_cell(backend, base, pulse, rank); + ASSERT_FALSE(c.uri.empty()); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(c.uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + + al_status_t ws, rs; + ws.code = 0; + rs.code = 0; + { + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, + &op_ctx)); + ws = al_contract::write_data(op_ctx, c.field.c_str(), c.shape, + c.written); + AL_EXPECT_OK(al_end_action(op_ctx)); + } + if (ws.code == 0) { // write was accepted; the refusal must come on read + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", READ_OP, + &op_ctx)); + std::vector sh; + std::vector rd; + rs = al_contract::read_data(op_ctx, c.field.c_str(), rank, &sh, &rd); + AL_EXPECT_OK(al_end_action(op_ctx)); + } + EXPECT_TRUE(ws.code != 0 || rs.code != 0) + << "expected the backend to refuse this datatype/shape (a documented " + "limitation — CHAR data > 2D, or a read while the serializer is " + "writing), but the round trip was accepted"; + + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); +} + +template +void dispatch(const BackendCase& backend, const al_contract::TempBase& base, + const PulseId& pulse, int rank, Expect expect) { + if (expect == Expect::Refused) { + expect_refused(backend, base, pulse, rank); + } else { + run_round_trip(backend, base, pulse, rank); + } +} + +using MatrixParam = std::tuple; + +class RoundTripMatrix : public ::testing::TestWithParam { +protected: + al_contract::TempBase base_; + // One fixed pulse address; each parametrized instance has its own base_ + // dir, so on-disk trees can never alias despite the shared address. + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, + /*run=*/0}; +}; + +TEST_P(RoundTripMatrix, ReadEqualsWrite) { + const BackendCase backend = std::get<0>(GetParam()); + const DataTypeCase dcase = std::get<1>(GetParam()); + const int rank = std::get<2>(GetParam()); + + const Expect expect = classify(backend.id, dcase.dt, rank); + if (expect == Expect::KnownDefect) { + GTEST_SKIP() << "known defect for " << backend.name << "/" << dcase.name + << "/rank " << rank + << " — correct contract asserted (expected-fail) in " + "RoundTripKnownDefects.*"; + } + + switch (dcase.dt) { + case DType::Char: + dispatch(backend, base_, pulse_, rank, expect); + break; + case DType::Int: + dispatch(backend, base_, pulse_, rank, expect); + break; + case DType::Double: + dispatch(backend, base_, pulse_, rank, expect); + break; + case DType::Complex: + dispatch>(backend, base_, pulse_, rank, expect); + break; + } +} + +INSTANTIATE_TEST_SUITE_P( + Backends, RoundTripMatrix, + ::testing::Combine(::testing::ValuesIn(kBackends), + ::testing::ValuesIn(kDataTypes), + ::testing::Range(0, MAXDIM + 1)), + [](const ::testing::TestParamInfo& info) { + // ctest-name suffix: __r, e.g. HDF5_DOUBLE_r3. + return std::string(std::get<0>(info.param).name) + "_" + + std::get<1>(info.param).name + "_r" + + std::to_string(std::get<2>(info.param)); + }); + +// =========================================================================== +// Known defects surfaced by the matrix above (decision D2). +// =========================================================================== +// The correct contract is asserted and marked expected-fail (DISABLED_ + a +// paired death test for the crash, mirroring test_known_defects.cpp). The day a +// backend is fixed, the DISABLED_ test flips to pass — a signal, not a +// regression — and its death-test twin starts failing, forcing whoever fixed it +// to enable the correct-contract test. + +const BackendCase kHdf5{HDF5_BACKEND, "HDF5", /*on_disk=*/true}; +const BackendCase kAscii{ASCII_BACKEND, "ASCII", /*on_disk=*/true}; + +// Run a round trip against a freshly-created, self-cleaning data entry — the +// standalone form used by the (non-parametrized) known-defect tests. +template +void run_round_trip_here(const BackendCase& backend, int rank) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + run_round_trip(backend, base, pulse, rank); +} + +// --- HDF5 crashes writing/reading a CHAR scalar (dim 0) -------------------- +TEST(RoundTripKnownDefects, DISABLED_Hdf5CharScalarRoundTrips) { + run_round_trip_here(kHdf5, /*rank=*/0); +} + +TEST(RoundTripKnownDefectsDeath, Hdf5CharScalarCurrentlyCrashes) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + // ASSERT_DEATH's argument must be a single call: a brace block would expose + // the commas in the initializers as extra macro arguments. + ASSERT_DEATH(run_round_trip_here(kHdf5, /*rank=*/0), ".*") + << "expected the HDF5 CHAR-scalar defect to crash. If it no longer " + "does, enable RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips."; +} + +// --- ASCII corrupts/crashes numeric data at the MAXDIM boundary (rank 7) ---- +TEST(RoundTripKnownDefects, DISABLED_AsciiIntegerMaxdimRoundTrips) { + run_round_trip_here(kAscii, /*rank=*/MAXDIM); +} +TEST(RoundTripKnownDefects, DISABLED_AsciiDoubleMaxdimRoundTrips) { + run_round_trip_here(kAscii, /*rank=*/MAXDIM); +} +TEST(RoundTripKnownDefects, DISABLED_AsciiComplexMaxdimRoundTrips) { + run_round_trip_here>(kAscii, /*rank=*/MAXDIM); +} + +// --- al_build_uri_from_legacy_parameters can't address FLEXBUFFERS ---------- +// getURIBackend (src/al_context.cpp:280) has no FLEXBUFFERS_BACKEND case and +// throws, even though the parse side accepts the "flexbuffers" scheme — so the +// always-on serializer backend has no legacy-URI builder (the matrix works +// around it by hand-building the URI, see al_contract.h::build_uri). Correct +// contract: the builder should support it, like the other always-on backends. +TEST(RoundTripKnownDefects, DISABLED_BuildUriSupportsFlexbuffers) { + char* uri = nullptr; + al_status_t s = al_build_uri_from_legacy_parameters( + FLEXBUFFERS_BACKEND, 12, 0, "/tmp/al_contract_flexbuffers", "test", "3", + "", &uri); + EXPECT_EQ(s.code, 0) + << "al_build_uri_from_legacy_parameters should support the always-on " + "FLEXBUFFERS backend (getURIBackend, src/al_context.cpp:280)"; + free(uri); +} + +} // namespace From fcc1e249a3f840661373e2a6f8087d50d0c45150 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:17:12 +0200 Subject: [PATCH 06/91] Add Task-2 capability-gated op tests (issue #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEST_STRATEGY.md §4 step 4 (Cluster E): slice / timerange / al_list_filled_paths tested positive where the backend supports the capability and paired-negative (documented refusal) where it does not, so the supports* matrix is itself under test at the C ABI boundary (D1 — the C++ Backend flags are asserted through their sole ABI-observable consequence, accept-vs-refuse, since they cannot be linked directly). Support columns were established empirically against the binary and follow D2 (assert the real contract, not the issue's assumptions): - Memory genuinely supports slice (full SLICE_OP handling in memory_backend.cpp; correct closest-slice values), so slice is positive on HDF5+Memory and paired-negative only on ASCII+Flexbuffers — not the issue's assumed Memory-negative. - Refusal codes pinned specifically: timerange -> LOWLEVEL_ERR (al_lowlevel.cpp:1053), slice / list_filled_paths -> BACKEND_ERR, slice-read without an interp mode -> CONTEXT_ERR (al_context.cpp:347). What lands: - test_capability_gated.cpp — CapabilityMatrix TEST_P over the four always-on backends for timerange, slice read, slice-write-begin, and list_filled_paths; HDF5-only detail tests for the three interpolation modes (CLOSEST/PREVIOUS/LINEAR) and timerange with/without a resampling dtime grid, each with an exact value oracle (seeded via a single global write, so the read machinery is exercised independently of append). - KNOWN DEFECT per D2: repeated WRITE+UNDEFINED_TIME slices do not accumulate in this build (only the last persists). Encoded as a DISABLED_ correct-contract test asserting both slices persist plus a current-behavior tripwire pinning today's single-slice result; write lifecycle ownership stays with issue #3. - TRACEABILITY.md — Cluster E rows filled (covered + the append xfail); stale Cluster-E gap bullet removed. - CMake: test_capability_gated.cpp added to the contract_tests target. Full contract suite: 164 tests green (1 xfail disabled). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/contract/CMakeLists.txt | 1 + tests/contract/TRACEABILITY.md | 38 +- tests/contract/test_capability_gated.cpp | 420 +++++++++++++++++++++++ 3 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 tests/contract/test_capability_gated.cpp diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 170cc699..03b081c5 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -65,6 +65,7 @@ add_executable(contract_tests test_roundtrip_matrix.cpp test_introspection.cpp test_version_sentinel.cpp + test_capability_gated.cpp test_known_defects.cpp ) diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 77f110e7..5d93522d 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -97,14 +97,48 @@ prescription for an unsupported operation). --- +## Cluster E — capability-gated ops (issue #6 — TEST_STRATEGY §4 step 4) + +slice / timerange / `list_filled_paths` — positive where the backend advertises +the capability, **paired-negative** (documented refusal) where it does not +(decision D4). The support columns were established empirically and follow D2, +not the issue's stated assumptions: **Memory genuinely supports slice** (full +`SLICE_OP` handling in `src/memory_backend.cpp`), so it is positive for slice and +negative only for time-range/list. Refusal codes are the stable contract: +timerange → `LOWLEVEL_ERR` (`src/al_lowlevel.cpp:1053`); slice / list_filled_paths +→ `BACKEND_ERR` (backend throws `ALBackendException`). Because decision D1 forbids +linking the C++ `Backend` flags, `supportsTimeRangeOperation()` / +`supportsTimeDataInterpolation()` are asserted through their sole ABI-observable +consequence — whether the op is accepted or refused per backend — which is what +puts the Cluster-E matrix itself under test. + +| Capability | Inventory ref | Unit | Integ. | Test(s) | Status | +|---|---|:--:|:--:|---|---| +| `al_begin_timerange_action` (READ) returns the stored slices in `[tmin,tmax]` (no-resample) on a supporting backend — HDF5 | Cluster E | — | ✓ | `Backends/CapabilityMatrix.TimeRangeReadPositiveOrRefused/HDF5`, `Hdf5TimeDependent.TimeRangeReadWithoutResampling` | covered | +| `al_begin_timerange_action` resamples onto an explicit `dtime` grid (LINEAR) — HDF5 | Cluster E | — | ✓ | `Hdf5TimeDependent.TimeRangeReadWithResampling` | covered | +| `al_begin_timerange_action` must refuse where `supportsTimeRangeOperation()==false` (`LOWLEVEL_ERR`) — Memory·ASCII·Flexbuffers | Cluster E / D4 | ✓ (Memory) | ✓ | `Backends/CapabilityMatrix.TimeRangeReadPositiveOrRefused/{Memory,ASCII,Flexbuffers}` (paired-negative) | covered | +| `al_begin_slice_action` (READ) selects a slice by CLOSEST/PREVIOUS/LINEAR interp — HDF5 | Cluster E | — | ✓ | `Hdf5TimeDependent.SliceInterpolationModes`, `Backends/CapabilityMatrix.SliceReadPositiveOrRefused/HDF5` | covered | +| `al_begin_slice_action` (READ, CLOSEST) is supported on Memory (slice storage, no interpolation gate) | Cluster E | ✓ (Memory) | — | `Backends/CapabilityMatrix.SliceReadPositiveOrRefused/Memory` | covered | +| `al_begin_slice_action` (WRITE, append via `UNDEFINED_TIME`) accepted on a slicing backend — HDF5·Memory | Cluster E | ✓ (Memory) | ✓ | `Backends/CapabilityMatrix.SliceWriteBeginPositiveOrRefused/{HDF5,Memory}` | covered | +| `al_begin_slice_action` must refuse on a non-slicing backend (`BACKEND_ERR`) — ASCII·Flexbuffers | Cluster E / D4 | — | ✓ | `Backends/CapabilityMatrix.{SliceReadPositiveOrRefused,SliceWriteBeginPositiveOrRefused}/{ASCII,Flexbuffers}` (paired-negative) | covered | +| `al_begin_slice_action` (READ) with `UNDEFINED_INTERP` is refused (an interp mode is required; `CONTEXT_ERR`) | src/al_context.cpp:347 | — | ✓ | `Hdf5TimeDependent.SliceReadWithoutInterpModeIsRejected` | covered | +| `al_list_filled_paths` returns the filled leaf paths (caller frees list + strings) — HDF5 | :494-505 | — | ✓ | `Backends/CapabilityMatrix.ListFilledPathsPositiveOrRefused/HDF5` | covered | +| `al_list_filled_paths` must refuse where unimplemented (`BACKEND_ERR`, "only tensorizing backends") — Memory·ASCII·Flexbuffers | :496-497 / D4 | ✓ (Memory) | ✓ | `Backends/CapabilityMatrix.ListFilledPathsPositiveOrRefused/{Memory,ASCII,Flexbuffers}` (paired-negative) | covered | +| Slice **append** via `UNDEFINED_TIME`: repeated WRITE slices must accumulate — **only the last-written slice persists in this build** | Cluster E; al_lowlevel.h:300-316 | — | ✓ | `Hdf5SliceAppend.AppendedSlicesAllPersist` (DISABLED_) | **xfail** | +| ↳ current-behavior tripwire: two appends leave exactly one stored slice today | Cluster E | — | ✓ | `Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior` | covered (tripwire) | + +> **Note on append vs read.** Because append does not accumulate (the xfail +> above), the interpolation/resampling oracles seed their multi-point signal via +> a single global write and exercise the slice/timerange **read** machinery over +> it — the read path is independent of how the samples reached the store. The +> write-lifecycle fix itself is owned by issue #3. + ## Remaining clusters — gaps to be filled by later build-order steps Per TEST_STRATEGY.md §4, still open (not covered by this issue): - **Cluster 1 — Pulse lifecycle**: open/info/close/erase, occurrences (step 2). - **Cluster 2 — Data access**: `al_delete_data`, AOS write/iterate/read (step 2). -- **Cluster E — capability-gated ops**: slice / timerange / `list_filled_paths` - — positive on HDF5, paired-negative "must-refuse" on the others (step 4). - **Cluster 3 — Plugins**: register/bind/unbind/unregister, parameter-setting, state-machine quirks and the remaining defect death-tests (step 5). - Backend-implementer / plugin-author capabilities reachable through the C ABI, diff --git a/tests/contract/test_capability_gated.cpp b/tests/contract/test_capability_gated.cpp new file mode 100644 index 00000000..4629ae14 --- /dev/null +++ b/tests/contract/test_capability_gated.cpp @@ -0,0 +1,420 @@ +// Capability-gated operation contract tests (issue #6 / TEST_STRATEGY.md §4 step 4). +// +// Some operations are only meaningful on backends that advertise the matching +// Cluster-E capability (FUNCTIONALITY_INVENTORY.md — supportsTimeDataInterpolation +// / supportsTimeRangeOperation). Per decision D4 each such op is tested two ways: +// * POSITIVE where the backend supports it — the op runs and, wherever there is +// an oracle, its returned data is asserted exactly (not merely code==0). +// * PAIRED-NEGATIVE where it does not — the op must produce the *documented +// refusal* (a specific al_status_t error code), never a crash or a silent +// success. This puts the capability matrix itself under test at the C ABI +// boundary (decision D1: we cannot link the C++ Backend flags directly, so we +// assert their only ABI-observable consequence). +// +// The support columns below were established empirically against the current +// library (each value has a verified round trip), because two of them contradict +// the issue's stated assumptions and must follow D2 ("assert the real contract, +// tag defects as xfail — never freeze folklore"): +// +// op / backend HDF5 Memory ASCII Flexbuffers +// slice (R/W) y y* n n +// timerange (R) y n n n +// list_filled_paths y n n n +// +// * Memory genuinely supports slice storage + closest/previous/linear slice +// reads (src/memory_backend.cpp has full SLICE_OP handling); it is NOT a +// paired-negative for slice, even though supportsTimeRangeOperation()==false. +// What Memory lacks is time-RANGE operations, gated separately at +// src/al_lowlevel.cpp:1053. +// +// Refusal codes are the documented, stable part of the contract (D4 "specific +// error code"): +// * timerange on an unsupporting backend -> LOWLEVEL_ERR +// (src/al_lowlevel.cpp:1053-1055, ALLowlevelException) +// * slice / list_filled_paths unsupported -> BACKEND_ERR +// (backend beginAction / list_filled_paths throw ALBackendException) + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include +#include + +using al_contract::PulseId; + +namespace { + +// --- opaque paths (the core attaches no DD semantics; strings are opaque) ----- +constexpr const char* kIds = "magnetics"; +constexpr const char* kHomogTime = "ids_properties/homogeneous_time"; +constexpr const char* kTimebase = "time"; // homogeneous shared timebase +constexpr const char* kSignal = "ip"; // an opaque dynamic leaf + +// The seeded dynamic signal: ip = f(time), a 2-point time axis. Chosen so slice +// interpolation has a non-trivial, exactly-representable oracle +// (LINEAR at the midpoint = 15.0, not a round-off). +constexpr double kT0 = 1.0, kT1 = 2.0; +constexpr double kV0 = 10.0, kV1 = 20.0; + +// --------------------------------------------------------------------------- +// Backend capability descriptor for the always-on tier (decision D4). +// --------------------------------------------------------------------------- +struct CapBackend { + int id; + const char* name; + bool on_disk; // needs the legacy //// tree + bool slice; // al_begin_slice_action accepted + bool timerange; // supportsTimeRangeOperation() (time-range ops accepted) + bool listpaths; // al_list_filled_paths implemented +}; + +// GoogleTest prints this wherever a CapBackend is shown (failure diagnostics and +// the discovered test-name suffix), so both read "HDF5"/"Memory"/… not a byte dump. +inline void PrintTo(const CapBackend& b, std::ostream* os) { *os << b.name; } + +const CapBackend kBackends[] = { + {HDF5_BACKEND, "HDF5", /*on_disk=*/true, /*slice=*/true, + /*timerange=*/true, /*listpaths=*/true}, + {MEMORY_BACKEND, "Memory", /*on_disk=*/false, /*slice=*/true, + /*timerange=*/false, /*listpaths=*/false}, + {ASCII_BACKEND, "ASCII", /*on_disk=*/true, /*slice=*/false, + /*timerange=*/false, /*listpaths=*/false}, + {FLEXBUFFERS_BACKEND, "Flexbuffers", /*on_disk=*/true, + /*slice=*/false, /*timerange=*/false, /*listpaths=*/false}, +}; + +// Write the 2-point dynamic signal through an ordinary GLOBAL write. Slice/ +// timerange are READ-side capabilities here, so the fixture is seeded with a +// plain global write (already proven by the round-trip matrix on every backend), +// and multi-point time selection is exercised on read. Returns true iff every +// write succeeded, so a downstream refusal can never be blamed on a missing seed. +bool seed_signal(int pctx) { + int op = -1; + if (al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code != 0) { + return false; + } + int ht = 1; + double t[2] = {kT0, kT1}; + double v[2] = {kV0, kV1}; + int shape[1] = {2}; + bool ok = true; + ok &= al_write_data(op, kHomogTime, "", &ht, INTEGER_DATA, 0, nullptr).code == 0; + ok &= al_write_data(op, kTimebase, kTimebase, t, DOUBLE_DATA, 1, shape).code == 0; + ok &= al_write_data(op, kSignal, kTimebase, v, DOUBLE_DATA, 1, shape).code == 0; + ok &= al_end_action(op).code == 0; + return ok; +} + +// Read the single scalar a slice read collapses the signal to. +al_status_t read_slice_value(int op, double* out) { + void* buf = nullptr; + int size[MAXDIM] = {0}; + al_status_t s = al_read_data(op, kSignal, kTimebase, &buf, DOUBLE_DATA, 1, size); + if (s.code == 0 && buf != nullptr && size[0] >= 1) { + *out = static_cast(buf)[0]; + } + free(buf); + return s; +} + +// =========================================================================== +// Parametrized capability matrix: one instance per always-on backend. +// =========================================================================== +class CapabilityMatrix : public ::testing::TestWithParam { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; + + // Open a fresh, seeded data entry. Fails the test fatally on any setup error + // (so a later refusal assertion cannot be a false positive from bad setup). + int open_seeded() { + const CapBackend& b = GetParam(); + if (b.on_disk) base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(b.id, base_.str(), pulse_); + EXPECT_FALSE(uri.empty()); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + EXPECT_TRUE(seed_signal(pctx)) << "seed write must succeed on every backend"; + return pctx; + } +}; + +// --- timerange: positive on HDF5, paired-negative (LOWLEVEL_ERR) elsewhere --- +// Directly asserts supportsTimeRangeOperation() through its sole ABI +// manifestation (the guard at src/al_lowlevel.cpp:1053). +TEST_P(CapabilityMatrix, TimeRangeReadPositiveOrRefused) { + const CapBackend b = GetParam(); + int pctx = open_seeded(); + + int op = -1; + double dtime = 0.0; + int dtime_shape = 0; // 0 => no resampling: return the raw stored slices + al_status_t s = al_begin_timerange_action(pctx, kIds, READ_OP, kT0, kT1, + &dtime, &dtime_shape, + UNDEFINED_INTERP, &op); + if (b.timerange) { + AL_EXPECT_OK(s); + void* buf = nullptr; + int size[MAXDIM] = {0}; + AL_EXPECT_OK(al_read_data(op, kSignal, kTimebase, &buf, DOUBLE_DATA, 1, size)); + ASSERT_EQ(size[0], 2) << "no-resample timerange returns both stored slices"; + EXPECT_EQ(static_cast(buf)[0], kV0); + EXPECT_EQ(static_cast(buf)[1], kV1); + free(buf); + EXPECT_EQ(al_end_action(op).code, 0); + } else { + EXPECT_NE(s.code, 0) << "unsupporting backend must refuse timerange"; + EXPECT_EQ(s.code, LOWLEVEL_ERR) + << "documented refusal is LOWLEVEL_ERR (al_lowlevel.cpp:1053)"; + } + al_close_pulse(pctx, CLOSE_PULSE); +} + +// --- slice: positive on HDF5 + Memory, paired-negative (BACKEND_ERR) elsewhere - +TEST_P(CapabilityMatrix, SliceReadPositiveOrRefused) { + const CapBackend b = GetParam(); + int pctx = open_seeded(); + + int op = -1; + // A time between the two slices; CLOSEST resolves to kT0 -> kV0. + al_status_t s = al_begin_slice_action(pctx, kIds, READ_OP, 1.4, CLOSEST_INTERP, &op); + if (b.slice) { + AL_EXPECT_OK(s); + double val = -1.0; + AL_EXPECT_OK(read_slice_value(op, &val)); + EXPECT_EQ(val, kV0) << "CLOSEST slice to t=1.4 is t=1.0 -> " << kV0; + EXPECT_EQ(al_end_action(op).code, 0); + } else { + EXPECT_NE(s.code, 0) << "unsupporting backend must refuse slice"; + EXPECT_EQ(s.code, BACKEND_ERR) + << "documented refusal is BACKEND_ERR (backend beginAction throws)"; + } + al_close_pulse(pctx, CLOSE_PULSE); +} + +// --- slice WRITE (append via UNDEFINED_TIME): accepted iff the backend slices -- +// The issue calls out append/replace via UNDEFINED_TIME specifically; this pins +// that begin-slice-for-write is accepted where supported and refused where not. +TEST_P(CapabilityMatrix, SliceWriteBeginPositiveOrRefused) { + const CapBackend b = GetParam(); + int pctx = open_seeded(); + + int op = -1; + al_status_t s = al_begin_slice_action(pctx, kIds, WRITE_OP, UNDEFINED_TIME, + UNDEFINED_INTERP, &op); + if (b.slice) { + AL_EXPECT_OK(s); + EXPECT_EQ(al_end_action(op).code, 0); + } else { + EXPECT_EQ(s.code, BACKEND_ERR) << "slice write must be refused (BACKEND_ERR)"; + } + al_close_pulse(pctx, CLOSE_PULSE); +} + +// --- list_filled_paths: positive on HDF5, paired-negative (BACKEND_ERR) else --- +TEST_P(CapabilityMatrix, ListFilledPathsPositiveOrRefused) { + const CapBackend b = GetParam(); + int pctx = open_seeded(); + + char** paths = nullptr; + int n = -1; + al_status_t s = al_list_filled_paths(pctx, kIds, &paths, &n); + if (b.listpaths) { + AL_EXPECT_OK(s); + ASSERT_GE(n, 0); + // The caller owns the list and every string (al_lowlevel.h:499). + std::set got; + for (int i = 0; i < n; ++i) { + got.insert(paths[i]); + free(paths[i]); + } + free(paths); + // Every leaf we seeded must be discoverable (order is unspecified). + EXPECT_TRUE(got.count(kHomogTime)) << "seeded leaf missing from listing"; + EXPECT_TRUE(got.count(kTimebase)) << "seeded timebase missing from listing"; + EXPECT_TRUE(got.count(kSignal)) << "seeded signal missing from listing"; + } else { + EXPECT_NE(s.code, 0) << "unsupporting backend must refuse list_filled_paths"; + EXPECT_EQ(s.code, BACKEND_ERR) + << "documented refusal is BACKEND_ERR (not implemented in this backend)"; + } + al_close_pulse(pctx, CLOSE_PULSE); +} + +INSTANTIATE_TEST_SUITE_P( + Backends, CapabilityMatrix, ::testing::ValuesIn(kBackends), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.name); + }); + +// =========================================================================== +// HDF5-only positive detail: slice interpolation modes + timerange resampling. +// =========================================================================== +// These carry the real interpolation oracle (CLOSEST / PREVIOUS / LINEAR and the +// with/without-dtime timerange forms the issue enumerates). They run only on the +// one always-on backend that supports interpolation; the paired-negative half is +// already covered by CapabilityMatrix above. +class Hdf5TimeDependent : public ::testing::Test { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; + int pctx_ = -1; + + void SetUp() override { + base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(HDF5_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + ASSERT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx_).code, 0); + ASSERT_TRUE(seed_signal(pctx_)); + } + void TearDown() override { + if (pctx_ >= 0) al_close_pulse(pctx_, CLOSE_PULSE); + } + + double slice_read(double time, int interp) { + int op = -1; + EXPECT_EQ(al_begin_slice_action(pctx_, kIds, READ_OP, time, interp, &op).code, 0); + double val = -1.0; + EXPECT_EQ(read_slice_value(op, &val).code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + return val; + } +}; + +TEST_F(Hdf5TimeDependent, SliceInterpolationModes) { + // CLOSEST: nearest sample time. + EXPECT_EQ(slice_read(1.4, CLOSEST_INTERP), kV0); // 1.4 nearer 1.0 + EXPECT_EQ(slice_read(1.6, CLOSEST_INTERP), kV1); // 1.6 nearer 2.0 + // PREVIOUS: last sample at/below the requested time. + EXPECT_EQ(slice_read(1.9, PREVIOUS_INTERP), kV0); + // LINEAR: interpolate between bracketing samples (midpoint -> mean). + EXPECT_EQ(slice_read(1.5, LINEAR_INTERP), (kV0 + kV1) / 2.0); +} + +TEST_F(Hdf5TimeDependent, TimeRangeReadWithoutResampling) { + int op = -1; + double dtime = 0.0; + int dtime_shape = 0; // no resampling + ASSERT_EQ(al_begin_timerange_action(pctx_, kIds, READ_OP, kT0, kT1, &dtime, + &dtime_shape, UNDEFINED_INTERP, &op).code, 0); + void* buf = nullptr; + int size[MAXDIM] = {0}; + ASSERT_EQ(al_read_data(op, kSignal, kTimebase, &buf, DOUBLE_DATA, 1, size).code, 0); + ASSERT_EQ(size[0], 2); + EXPECT_EQ(static_cast(buf)[0], kV0); + EXPECT_EQ(static_cast(buf)[1], kV1); + free(buf); + EXPECT_EQ(al_end_action(op).code, 0); +} + +TEST_F(Hdf5TimeDependent, TimeRangeReadWithResampling) { + // Resample onto an explicit 3-point grid; LINEAR fills the new midpoint. + int op = -1; + double grid[3] = {1.0, 1.5, 2.0}; + int grid_len = 3; + ASSERT_EQ(al_begin_timerange_action(pctx_, kIds, READ_OP, kT0, kT1, grid, + &grid_len, LINEAR_INTERP, &op).code, 0); + void* buf = nullptr; + int size[MAXDIM] = {0}; + ASSERT_EQ(al_read_data(op, kSignal, kTimebase, &buf, DOUBLE_DATA, 1, size).code, 0); + ASSERT_EQ(size[0], 3) << "resampled onto the 3-point target grid"; + EXPECT_EQ(static_cast(buf)[0], kV0); + EXPECT_EQ(static_cast(buf)[1], (kV0 + kV1) / 2.0); + EXPECT_EQ(static_cast(buf)[2], kV1); + free(buf); + EXPECT_EQ(al_end_action(op).code, 0); +} + +// A slice READ with UNDEFINED_INTERP is a documented refusal (an interp mode is +// required for reads). Pins the CONTEXT_ERR contract (src/al_context.cpp:347). +TEST_F(Hdf5TimeDependent, SliceReadWithoutInterpModeIsRejected) { + int op = -1; + al_status_t s = al_begin_slice_action(pctx_, kIds, READ_OP, 1.5, UNDEFINED_INTERP, &op); + EXPECT_NE(s.code, 0) << "a slice read without an interp mode must be refused"; + EXPECT_EQ(s.code, CONTEXT_ERR) << "documented refusal: 'Missing interpmode'"; +} + +// =========================================================================== +// Slice append via UNDEFINED_TIME — KNOWN DEFECT (decision D2). +// =========================================================================== +// The issue calls for slice "append/replace via UNDEFINED_TIME". A correct +// slicing backend must let repeated WRITE+UNDEFINED_TIME slices *accumulate* +// (the header documents UNDEFINED_TIME as "append a slice"). Empirically, in +// this build each append overwrites the previous — only the last-written slice +// survives — regardless of UNDEFINED_TIME vs a concrete time, or where +// homogeneous_time is written. Per D2 the correct contract is asserted (as an +// expected-fail), never frozen as folklore; a current-behavior tripwire pins +// today's overwrite so the xfail can't rot. Write-lifecycle ownership is +// issue #3; the slice *contract* is pinned here. +class Hdf5SliceAppend : public ::testing::Test { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; + int pctx_ = -1; + + void SetUp() override { + base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(HDF5_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + ASSERT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx_).code, 0); + // Append two slices: (t=1.0 -> 10.0) then (t=2.0 -> 20.0). + const double t[2] = {kT0, kT1}; + const double v[2] = {kV0, kV1}; + for (int k = 0; k < 2; ++k) { + int op = -1; + EXPECT_EQ(al_begin_slice_action(pctx_, kIds, WRITE_OP, UNDEFINED_TIME, + UNDEFINED_INTERP, &op).code, 0); + if (k == 0) { + int ht = 1; + al_write_data(op, kHomogTime, "", &ht, INTEGER_DATA, 0, nullptr); + } + int one[1] = {1}; + al_write_data(op, kTimebase, kTimebase, const_cast(&t[k]), + DOUBLE_DATA, 1, one); + al_write_data(op, kSignal, kTimebase, const_cast(&v[k]), + DOUBLE_DATA, 1, one); + EXPECT_EQ(al_end_action(op).code, 0); + } + } + void TearDown() override { + if (pctx_ >= 0) al_close_pulse(pctx_, CLOSE_PULSE); + } + + // Number of stored slices a no-resample timerange read finds. + int stored_slice_count() { + int op = -1; + double dtime = 0.0; + int dtime_shape = 0; + EXPECT_EQ(al_begin_timerange_action(pctx_, kIds, READ_OP, 0.0, 10.0, &dtime, + &dtime_shape, UNDEFINED_INTERP, &op).code, 0); + void* buf = nullptr; + int size[MAXDIM] = {0}; + EXPECT_EQ(al_read_data(op, kSignal, kTimebase, &buf, DOUBLE_DATA, 1, size).code, 0); + free(buf); + EXPECT_EQ(al_end_action(op).code, 0); + return size[0]; + } +}; + +// CORRECT-CONTRACT, expected-fail (DISABLED_): both appended slices must persist. +// Drop the DISABLED_ prefix the day append works — the suite then documents it. +TEST_F(Hdf5SliceAppend, DISABLED_AppendedSlicesAllPersist) { + EXPECT_EQ(stored_slice_count(), 2) + << "two WRITE+UNDEFINED_TIME slices must both be readable"; +} + +// CURRENT-BEHAVIOR tripwire: today only the last-written slice survives. When +// append is fixed this goes red, forcing whoever fixed it to enable the +// correct-contract test above. +TEST_F(Hdf5SliceAppend, OnlyLastAppendedSlicePersists_CurrentBehavior) { + EXPECT_EQ(stored_slice_count(), 1) + << "append currently overwrites; if this is now 2 the defect is fixed — " + "enable Hdf5SliceAppend.AppendedSlicesAllPersist"; +} + +} // namespace From 1e438199b93d2fbacefd7a1309b9bcada86960a8 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:36:41 +0200 Subject: [PATCH 07/91] Update TRACEABILITY.md Correction of the .md to fit correctly in order of the processed issues. --- tests/contract/TRACEABILITY.md | 253 ++++++++++++++++++++------------- 1 file changed, 153 insertions(+), 100 deletions(-) diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 5d93522d..68ee74d7 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -1,53 +1,142 @@ # Contract-suite traceability matrix (Task 2) Per **TEST_STRATEGY.md decision D8**, this matrix — not line coverage — is the -**primary definition of "comprehensive."** One row per -`FUNCTIONALITY_INVENTORY.md` capability reachable through the public C ABI, -with the test(s) that cover it and a status: +**primary definition of "comprehensive."** It has **one row per +`FUNCTIONALITY_INVENTORY.md` capability, across all three Audiences** (User, +Backend implementer, Plugin author), in the inventory's own Part/Cluster order. +A capability with no row here is an omission in *this file*, not an untested +capability that is silently fine — the point of the enumeration is that "done" +is a checkable list. + +Status vocabulary: - **covered** — an intended-contract test asserts the behavior and passes. +- **covered (indirect)** — a Backend-implementer / lower-layer capability with + no C-ABI seam of its own (decision D1 forbids linking the C++ `Backend` + classes), asserted through its sole ABI-observable consequence, or a User + capability exercised as an asserted dependency of another test (e.g. every + round trip asserts its `al_close_pulse` returns 0). The residual, un-asserted + modes/paths are named in the row's Test(s)/notes cell. - **xfail** — a genuine defect; the test asserts the *correct* behavior and is - expected-fail (decision D2). Flipping to pass means someone fixed it. -- **gap** — not yet tested; a later build-order step (TEST_STRATEGY §4) fills it. + expected-fail (decision D2, `DISABLED_` + a paired current-behavior tripwire). + Flipping to pass means someone fixed it. +- **gap** — not yet tested; the owning later build-order step (TEST_STRATEGY §4) + is named so the future issue knows which row to fill. Line coverage (`gcov`/`lcov`) is a secondary signal only (D8): a rewrite may restructure lines but must still satisfy every row here. Test IDs are GoogleTest `Suite.Case` names as registered in CTest; run one with -`ctest -R ` or `contract_tests --gtest_filter=`. +`ctest -R ` or `contract_tests --gtest_filter=`. Parametrized cases +carry a `/` (or `/__r`) suffix per instance. > **Note on the Unit / Integ. columns.** They classify each test by *substrate* > per decision D6 (pure lookups and Memory round trips are unit; on-disk > backends are integration) — the intended tier taxonomy. The CTest `unit` / -> `integration` **labels** that would let `ctest -L unit` select them are -> deferred (see `CMakeLists.txt`); today every case carries the single +> `integration` **labels** that would let `ctest -L unit` select them are still +> **deferred** (see `CMakeLists.txt`): today every case carries the single > `contract` label. Until the split lands, address the unit subset with -> `ctest -R Introspection`. +> `ctest -R Introspection`. *(This is a known un-met acceptance item of the +> scaffold issue #2 — tracked, not silently dropped.)* + +## Coverage status — as of the four implemented issues (#2 scaffold, #5 +## introspection, #3 round-trip matrix, #6 capability-gated) + +The suite covers the **data-path and introspection surface** end to end and +pins the four defects those issues surfaced. It does **not yet** cover the +pulse-lifecycle detail, arrays-of-structures, deletion, occurrences, or any of +the plugin surface — those are the remaining build-order steps and appear below +as explicit **gap** rows, each tagged with the step that owns it: + +- **Structured data** (TEST_STRATEGY §4 step 2, task doc `2b-structured.md`): + AOS, `al_delete_data`, `al_get_occurrences`, full pulse lifecycle. +- **Plugins** (§4 step 5, task doc `5-plugins.md`): all of User Cluster 3 and + all of Part 3. +- **Ownership sweep** (§4 step 6, task doc `6-ownership-sweep.md`): the + caller-frees questions and the remaining audience sweep. + +GitHub issue numbers for those three are to be filled in when the issues are +opened; until then they are referenced by build-order step + task-doc slug. --- -## Cluster 4 — Introspection / diagnostics (issue #5 — the thin unit tier) +# Part 1 — User (HLI implementer) audience + +## Cluster 1 — Pulse lifecycle (`FUNCTIONALITY_INVENTORY.md:86-149`) + +| Capability | Inventory ref | Unit | Integ. | Test(s) — and residual | Status | +|---|---|:--:|:--:|---|---| +| `al_begin_dataentry_action` opens a data entry from a URI | :90-107 | ✓ (Memory) | ✓ (HDF5, ASCII) | `FORCE_CREATE_PULSE` asserted OK in every suite (`Backends/RoundTrip.*`, `RoundTripMatrix.*`, `VersionSentinel.*`, `CapabilityMatrix.*`). Residual: `OPEN_PULSE`/`FORCE_OPEN_PULSE`/`CREATE_PULSE` modes + error paths untested → step 2 | covered (indirect) | +| `al_close_pulse` closes (`CLOSE_PULSE`) / erases (`ERASE_PULSE`) | :109-116 | ✓ (Memory) | ✓ (HDF5, ASCII) | `CLOSE_PULSE` asserted `code==0` in every suite. Residual: `ERASE_PULSE` untested → step 2 | covered (indirect) | +| `al_context_info` describes a context; **caller frees `*info`** | :118-126 | — | — | — → ownership sweep (step 6) | gap | +| `al_get_backendID` returns the active `BACKEND` for a context | :128-135 | — | — | — → step 2 | gap | +| `al_build_uri_from_legacy_parameters` builds a URI from legacy params | :137-149 | ✓ | ✓ | `al_contract::build_uri` (asserts OK) drives HDF5·Memory·ASCII in all on-disk suites. Caller-frees of `*uri` → ownership sweep (step 6) | covered | +| ↳ must also address the always-on FLEXBUFFERS backend — **throws (`getURIBackend` has no case)** | :137-149; src/al_context.cpp:280 | ✓ | — | `RoundTripKnownDefects.DISABLED_BuildUriSupportsFlexbuffers` (no paired tripwire — see risk note §end) | **xfail** | + +## Cluster 2 — Core data access (`FUNCTIONALITY_INVENTORY.md:153-314`) + +The bulk of the storage contract (issue #3) plus the capability-gated ops +(issue #6). Round-trip oracle = self-consistency (decision D5); synthetic opaque +data, zero DD artifacts. The round-trip matrix is parametrized over the +always-on tier (decision D4) {HDF5, Memory, ASCII, Flexbuffers} × {CHAR, +INTEGER, DOUBLE, COMPLEX} × {scalar → 7-D}; each cell is classified once +(D2/D4) as a plain round trip, a documented refusal (paired-negative), or a +genuine defect (expected-fail). + +| Capability | Inventory ref | Unit | Integ. | Test(s) — and residual | Status | +|---|---|:--:|:--:|---|---| +| `al_begin_global_action` starts a GLOBAL read/write op | :159-175 | ✓ (Memory) | ✓ (HDF5, ASCII) | Vehicle for every round trip: `RoundTrip.*`, `RoundTripMatrix.*`, `VersionSentinel.*`, `CapabilityMatrix.*` (WRITE_OP + READ_OP). `datapath` partial-get is UDA-only (out of always-on scope) | 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` | 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` | covered | +| `al_begin_arraystruct_action` starts an AOS op (top-level + nested) | :223-233 | — | — | — → structured data (step 2) | gap | +| `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` | 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` (**no paired tripwire — see risk note §end**) | **xfail** | +| `al_delete_data` at signal / structure / DATAOBJECT-root granularity | :265-272 | — | — | — → structured data (step 2) | gap | +| `al_iterate_over_arraystruct` advances the AOS cursor | :274-282 | — | — | — → structured data (step 2) | gap | +| `al_get_occurrences` lists non-empty occurrences; caller-frees `*occurrences_list` | :284-292 | — | — | — → structured data (step 2); ownership → step 6 | gap | +| `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` | covered | + +## Cluster 3 — Plugin management (`FUNCTIONALITY_INVENTORY.md:318-430`) + +Almost entirely deferred to the plugins issue (step 5). The one exception is the +scaffold's expected-fail proof, which lives on the int-scalar setvalue variant. + +| Capability | Inventory ref | Unit | Integ. | Test(s) — and residual | Status | +|---|---|:--:|:--:|---|---| +| `al_register_plugin` / `al_unregister_plugin` lifecycle (register-twice throws; unregister of never-bound leaves it un-destroyed) | :325-360 | — | — | — → plugins (step 5) | gap | +| `al_bind_plugin` / `al_unbind_plugin` (double-bind throws; unbind-unbound no-ops) | :362-376 | — | — | — → plugins (step 5) | gap | +| `al_bind_readback_plugins` / `al_unbind_readback_plugins` | :378-389 | — | — | — → plugins (step 5) | gap | +| `al_is_plugin_registered` boolean query | :391-397 | — | — | — → plugins (step 5) | gap | +| `al_setvalue_parameter_plugin` (generic typed variant) | :399-420 | — | — | — → plugins (step 5) | gap | +| `al_setvalue_int_scalar_parameter_plugin` on an **unregistered** name must error, not crash | :399-417 | — | ✓ | `KnownDefects.DISABLED_SetValueIntScalarUnregisteredPluginReturnsError` + tripwire `KnownDefectsDeath.SetValueIntScalarUnregisteredPluginCurrentlyCrashes` (SIGSEGV) | **xfail** | +| `al_setvalue_double_scalar_parameter_plugin` (same defect class, double variant) | :399-417 | — | — | — → plugins (step 5) | gap | +| `al_write_plugins_metadata` | :422-430 | — | — | — → plugins (step 5) | gap | + +## Cluster 4 — Introspection / diagnostics (`FUNCTIONALITY_INVENTORY.md:434-459`) (issue #5 — the thin unit tier) -`FUNCTIONALITY_INVENTORY.md:434-459`. Pure lookups: no context, no backend, -zero DD artifacts (decisions D5/D6). All **intended contract** (D2) — nothing -here is a defect, including the silent `""`, which the inventory documents as -deliberate. +Pure lookups: no context, no backend, zero DD artifacts (D5/D6). All **intended +contract** (D2) — nothing here is a defect, including the silent `""`. | Capability | Inventory ref | Unit | Integ. | Test(s) | Status | |---|---|:--:|:--:|---|---| -| `const2str(id)` maps a known constant to its symbolic name | :434-444 | ✓ | — | `Introspection.Const2StrMapsKnownConstantsToTheirNames` | covered | -| `const2str` silently returns `""` for an unmapped id (intended, not an error) | :451-452 | ✓ | — | `Introspection.Const2StrReturnsEmptyForUnmappedId` | covered | -| `const2str` returns `""` for defined-but-unmapped constants (`TIMERANGE_OP`, `FLEXBUFFERS_BACKEND`) | :75-79, :453-454 | ✓ | — | `Introspection.Const2StrReturnsEmptyForDefinedButUnmappedConstants` | covered | +| `const2str(id)` maps a known constant to its symbolic name | :443-444 | ✓ | — | `Introspection.Const2StrMapsKnownConstantsToTheirNames` | covered | +| `const2str` silently returns `""` for an unmapped id (intended) | :451-452 | ✓ | — | `Introspection.Const2StrReturnsEmptyForUnmappedId` | covered | +| `const2str` returns `""` for defined-but-unmapped constants (`TIMERANGE_OP`, `FLEXBUFFERS_BACKEND`) | :75-79, :451-454 | ✓ | — | `Introspection.Const2StrReturnsEmptyForDefinedButUnmappedConstants` | covered | | `err2str(id)` maps a known error code to its name | :445-446 | ✓ | — | `Introspection.Err2StrMapsKnownErrorCodesToTheirNames` | covered | | `err2str` silently returns `""` for an unmapped id | :451-452 | ✓ | — | `Introspection.Err2StrReturnsEmptyForUnmappedId` | covered | -| `const2str`/`err2str` are backed by separate maps (two namespaces) | :443-446 | ✓ | — | `Introspection.Const2StrAndErr2StrUseSeparateMaps` | covered | -| `getALVersion()` returns the compiled AL version string | :447 | ✓ | — | `Introspection.GetALVersionReturnsCompiledVersionMacro`, `Introspection.GetALVersionIsDottedNumeric` | covered | +| `const2str` / `err2str` are backed by separate maps (two namespaces) | :443-446 | ✓ | — | `Introspection.Const2StrAndErr2StrUseSeparateMaps` | covered | +| `getALVersion()` returns the compiled AL version string | :447 | ✓ | — | `Introspection.GetALVersionReturnsCompiledVersionMacro`, `…IsDottedNumeric` | covered | | `getDDVersion()` returns the deprecated sentinel `"!!DEPRECATED!!"` (intended) | :455-459 | ✓ | — | `Introspection.GetDDVersionReturnsDeprecatedSentinel` | covered | -## Version sentinel (issue #5 — decision D5) +### Version sentinel (issue #5 — decision D5) The tripwire: the core stores `ids_properties/version_put/data_dictionary` as -opaque data and interprets no DD version. A current-behavior test that Task 3's +opaque data and interprets no DD version. A current-behavior test Task 3's version-negotiation work will watch. | Capability | Inventory ref | Unit | Integ. | Test(s) | Status | @@ -56,90 +145,54 @@ version-negotiation work will watch. --- -## Already covered by the scaffold slice (#2 — TEST_STRATEGY §4 step 1) +# Part 2 — Backend implementer audience (`FUNCTIONALITY_INVENTORY.md:482-690`) -| Capability | Inventory ref | Unit | Integ. | Test(s) | Status | -|---|---|:--:|:--:|---|---| -| `al_write_data`→`al_read_data` round trip (INTEGER scalar + CHAR array) | Clusters 1-2 | ✓ (Memory) | ✓ (HDF5) | `Backends/RoundTrip.IntScalarAndCharArraySurviveWriteThenRead` | covered | -| `al_setvalue_int_scalar_parameter_plugin` on an unregistered name must return an error, not crash | :410-417 | — | ✓ | `KnownDefects.SetValueIntScalarUnregisteredPluginReturnsError` (DISABLED_) | **xfail** | -| ↳ current-behavior guard: that call crashes by SIGSEGV today | :410-417 | — | ✓ | `KnownDefectsDeath.SetValueIntScalarUnregisteredPluginCurrentlyCrashes` | covered (tripwire) | - -## Cluster 2 — Core data access: `al_write_data` / `al_read_data` (issue #3 — TEST_STRATEGY §4 step 2) - -The synthetic write→read round-trip matrix — the bulk of the storage contract. -Oracle = round-trip self-consistency (decision D5); synthetic opaque data, zero -DD artifacts. Parametrized over the always-on tier (decision D4) -{HDF5, Memory, ASCII, Flexbuffers} × {CHAR, INTEGER, DOUBLE, COMPLEX} × -{scalar → 7-D}. Each cell is classified once (D2/D4): a plain round trip, a -documented refusal (paired-negative), or a genuine defect (expected-fail). -Flexbuffers is a serializer, not a pulse store, so every one of its cells is a -must-refuse column (it accepts the write but refuses the read-back — D4's -prescription for an unsupported operation). +Exercised **through the C ABI** (decision D1 forbids linking the C++ `Backend` +classes), so these mirror Part 1 rows from the storage layer's side. Where a +Part-1 op above already drives the backend method, this is "covered (indirect)" +and points at the same test. -| Capability | Inventory ref | Unit | Integ. | Test(s) | Status | +| Cluster / Capability | Inventory ref | Unit | Integ. | Test(s) — and residual | Status | |---|---|:--:|:--:|---|---| -| `al_write_data`→`al_read_data` round trip preserves value + shape, INTEGER/DOUBLE/COMPLEX, scalar→7-D, on HDF5·Memory·ASCII (ASCII: scalar→6-D) | Cluster 2 | ✓ (Memory) | ✓ (HDF5, ASCII) | `Backends/RoundTripMatrix.ReadEqualsWrite/{HDF5,Memory,ASCII}_{INTEGER,DOUBLE,COMPLEX}_r{0..7}` | covered | -| Round trip for CHAR — scalar & 1-D (string) & 2-D (array-of-strings) on HDF5·ASCII; all ranks on Memory (raw bytes) | Cluster 2 | ✓ (Memory) | ✓ (HDF5, ASCII) | `…/{Memory}_CHAR_r{0..7}`, `…/{HDF5,ASCII}_CHAR_r{1,2}` | covered | -| Deterministic synthetic-data generator: datatype × shape (scalar→7-D), values clear of the EMPTY sentinels | D5 | ✓ | ✓ | `al_contract.h` `synth_value`/`synth_buffer`/`shape_for_rank`; exercised by every `RoundTripMatrix` cell | covered | -| Flexbuffers must-refuse column: accepts the write but refuses the read-back (serializer, not a pulse store), every datatype × shape | Cluster 2 / D4 | — | ✓ | `Backends/RoundTripMatrix.ReadEqualsWrite/Flexbuffers_*_r{0..7}` (paired-negative) | covered | -| CHAR data > 2-D is refused (documented "not implemented"): HDF5 & ASCII | Cluster 2 / D4 | — | ✓ | `Backends/RoundTripMatrix.ReadEqualsWrite/{HDF5,ASCII}_CHAR_r{3..7}` (paired-negative) | covered | -| A CHAR scalar (dim 0) must round-trip, not crash — **HDF5 crashes** | Cluster 2 | — | ✓ | `RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips` + `RoundTripKnownDefectsDeath.Hdf5CharScalarCurrentlyCrashes` | **xfail** | -| Numeric data at the MAXDIM boundary (rank 7) must round-trip — **ASCII corrupts (DOUBLE/COMPLEX) / aborts (INTEGER)** | Cluster 2 | — | ✓ | `RoundTripKnownDefects.DISABLED_Ascii{Integer,Double,Complex}MaxdimRoundTrips` | **xfail** | -| `al_build_uri_from_legacy_parameters` must address the always-on FLEXBUFFERS backend — **it throws (getURIBackend has no case)** | src/al_context.cpp:280 | ✓ | — | `RoundTripKnownDefects.DISABLED_BuildUriSupportsFlexbuffers` | **xfail** | - -> **Deferred (recorded):** a full Flexbuffers **serialize→deserialize** round -> trip over the datatype × shape space needs the HLI `` protocol and -> belongs to the serialize seam; the matrix only pins its must-refuse pulse- -> lifecycle behavior. Occurrences, `al_delete_data`, AOS, and the pulse -> lifecycle (Cluster 1) remain with issue #4 (structured data). CHAR>2-D on -> Memory round-trips incidentally (raw bytes) and is asserted as such; it is not -> part of the CHAR contract. +| **A** `initBackend` factory selects the backend from the URI | :496-514 | ✓ (Memory) | ✓ (HDF5, ASCII) | every always-on backend instantiated via `build_uri` in `RoundTripMatrix`. Unrecognized-ID throw untested → step 6 | covered (indirect) | +| **B** `getVersion` (installed vs stored, drift check) | :520-540 | — | — | version-drift guard untested → step 6 | 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) | covered | +| **C** `deleteData` | :591-599 | — | — | — → structured data (step 2) | gap | +| **C** `beginArraystructAction` | :601-606 | — | — | — → structured data (step 2) | gap | +| **D** `get_occurrences` | :612-616 | — | — | — → structured data (step 2) | gap | +| **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 | 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 | covered (indirect) | +| **E** `initDataInterpolationComponent` (framework-driven) | :644-690 | — | — | no-op where the factory calls it; not separately ABI-observable → step 6 | gap | --- -## Cluster E — capability-gated ops (issue #6 — TEST_STRATEGY §4 step 4) - -slice / timerange / `list_filled_paths` — positive where the backend advertises -the capability, **paired-negative** (documented refusal) where it does not -(decision D4). The support columns were established empirically and follow D2, -not the issue's stated assumptions: **Memory genuinely supports slice** (full -`SLICE_OP` handling in `src/memory_backend.cpp`), so it is positive for slice and -negative only for time-range/list. Refusal codes are the stable contract: -timerange → `LOWLEVEL_ERR` (`src/al_lowlevel.cpp:1053`); slice / list_filled_paths -→ `BACKEND_ERR` (backend throws `ALBackendException`). Because decision D1 forbids -linking the C++ `Backend` flags, `supportsTimeRangeOperation()` / -`supportsTimeDataInterpolation()` are asserted through their sole ABI-observable -consequence — whether the op is accepted or refused per backend — which is what -puts the Cluster-E matrix itself under test. +# Part 3 — Plugin author audience (`FUNCTIONALITY_INVENTORY.md:708-895`) -| Capability | Inventory ref | Unit | Integ. | Test(s) | Status | +Entirely deferred to the plugins issue (step 5). Listed as explicit gap rows so +the plugins issue has a concrete checklist rather than a blank slate. + +| Cluster / Capability | Inventory ref | Unit | Integ. | Test(s) | Status | |---|---|:--:|:--:|---|---| -| `al_begin_timerange_action` (READ) returns the stored slices in `[tmin,tmax]` (no-resample) on a supporting backend — HDF5 | Cluster E | — | ✓ | `Backends/CapabilityMatrix.TimeRangeReadPositiveOrRefused/HDF5`, `Hdf5TimeDependent.TimeRangeReadWithoutResampling` | covered | -| `al_begin_timerange_action` resamples onto an explicit `dtime` grid (LINEAR) — HDF5 | Cluster E | — | ✓ | `Hdf5TimeDependent.TimeRangeReadWithResampling` | covered | -| `al_begin_timerange_action` must refuse where `supportsTimeRangeOperation()==false` (`LOWLEVEL_ERR`) — Memory·ASCII·Flexbuffers | Cluster E / D4 | ✓ (Memory) | ✓ | `Backends/CapabilityMatrix.TimeRangeReadPositiveOrRefused/{Memory,ASCII,Flexbuffers}` (paired-negative) | covered | -| `al_begin_slice_action` (READ) selects a slice by CLOSEST/PREVIOUS/LINEAR interp — HDF5 | Cluster E | — | ✓ | `Hdf5TimeDependent.SliceInterpolationModes`, `Backends/CapabilityMatrix.SliceReadPositiveOrRefused/HDF5` | covered | -| `al_begin_slice_action` (READ, CLOSEST) is supported on Memory (slice storage, no interpolation gate) | Cluster E | ✓ (Memory) | — | `Backends/CapabilityMatrix.SliceReadPositiveOrRefused/Memory` | covered | -| `al_begin_slice_action` (WRITE, append via `UNDEFINED_TIME`) accepted on a slicing backend — HDF5·Memory | Cluster E | ✓ (Memory) | ✓ | `Backends/CapabilityMatrix.SliceWriteBeginPositiveOrRefused/{HDF5,Memory}` | covered | -| `al_begin_slice_action` must refuse on a non-slicing backend (`BACKEND_ERR`) — ASCII·Flexbuffers | Cluster E / D4 | — | ✓ | `Backends/CapabilityMatrix.{SliceReadPositiveOrRefused,SliceWriteBeginPositiveOrRefused}/{ASCII,Flexbuffers}` (paired-negative) | covered | -| `al_begin_slice_action` (READ) with `UNDEFINED_INTERP` is refused (an interp mode is required; `CONTEXT_ERR`) | src/al_context.cpp:347 | — | ✓ | `Hdf5TimeDependent.SliceReadWithoutInterpModeIsRejected` | covered | -| `al_list_filled_paths` returns the filled leaf paths (caller frees list + strings) — HDF5 | :494-505 | — | ✓ | `Backends/CapabilityMatrix.ListFilledPathsPositiveOrRefused/HDF5` | covered | -| `al_list_filled_paths` must refuse where unimplemented (`BACKEND_ERR`, "only tensorizing backends") — Memory·ASCII·Flexbuffers | :496-497 / D4 | ✓ (Memory) | ✓ | `Backends/CapabilityMatrix.ListFilledPathsPositiveOrRefused/{Memory,ASCII,Flexbuffers}` (paired-negative) | covered | -| Slice **append** via `UNDEFINED_TIME`: repeated WRITE slices must accumulate — **only the last-written slice persists in this build** | Cluster E; al_lowlevel.h:300-316 | — | ✓ | `Hdf5SliceAppend.AppendedSlicesAllPersist` (DISABLED_) | **xfail** | -| ↳ current-behavior tripwire: two appends leave exactly one stored slice today | Cluster E | — | ✓ | `Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior` | covered (tripwire) | - -> **Note on append vs read.** Because append does not accumulate (the xfail -> above), the interpolation/resampling oracles seed their multi-point signal via -> a single global write and exercise the slice/timerange **read** machinery over -> it — the read path is independent of how the samples reached the store. The -> write-lifecycle fix itself is owned by issue #3. - -## Remaining clusters — gaps to be filled by later build-order steps - -Per TEST_STRATEGY.md §4, still open (not covered by this issue): - -- **Cluster 1 — Pulse lifecycle**: open/info/close/erase, occurrences (step 2). -- **Cluster 2 — Data access**: `al_delete_data`, AOS write/iterate/read (step 2). -- **Cluster 3 — Plugins**: register/bind/unbind/unregister, parameter-setting, - state-machine quirks and the remaining defect death-tests (step 5). -- Backend-implementer / plugin-author capabilities reachable through the C ABI, - swept until no row is blank (step 6). +| **1** Provenance metadata (`getName`/`getVersion`/`getCommit`/…) | :739-759 | — | — | — → plugins (step 5) | gap | +| **2** Parameter configuration (`setParameter` / `setParameters`) | :763-780 | — | — | — → plugins (step 5); the User-side unregistered-name crash is xfail'd in Part 1 Cluster 3 | gap | +| **3** Action lifecycle & data interception (`begin_*_action`, `read_data`/`write_data`, `node_operation`) | :784-824 | — | — | — → plugins (step 5) | gap | +| **4** Readback metadata (`getReadback*`) | :827-850 | — | — | — → plugins (step 5) | gap | +| **5** Low-level reentry (`al_plugin_*`); `al_plugin_begin_timerange_action` is broken (declaration/definition mismatch) | :854-895 | — | — | — → plugins (step 5); the reentry bug is tracked separately as an upstream GitHub issue | gap | + +--- + +## Open risks in the *current* xfail bookkeeping (for the ownership-sweep / next issue) + +The D2 discipline is "correct-contract `DISABLED_` test **plus** a paired +current-behavior tripwire, so the xfail can't rot." Two xfail rows above do not +yet have that tripwire and will rot silently if the underlying defect is fixed: + +- **ASCII rank-7 numeric** (`DISABLED_Ascii{Integer,Double,Complex}MaxdimRoundTrips`) + — no current-behavior twin. Add one asserting today's corruption/abort. +- **`DISABLED_BuildUriSupportsFlexbuffers`** — no twin asserting the current + throw. Lower stakes (not a crash), but the same rot risk. + +The HDF5 CHAR-scalar and the plugin unregistered-name defects **do** have their +tripwires (`…Death` cases) and are safe. From e48e222f858d05b005cd977fb421f822f9615bef Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:44:30 +0200 Subject: [PATCH 08/91] Update test_capability_gated.cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What changed: tests/contract/test_capability_gated.cpp — open_seeded() refactored from a int-returning helper using non-fatal EXPECT_* into a void open_seeded(int* pctx) using ASSERT_*, with every call site wrapped in ASSERT_NO_FATAL_FAILURE(open_seeded(&pctx)). Why this fixes the defect: previously, if the URI build / data-entry open / seed write failed, the test continued and the paired-negative branch (EXPECT_NE(s.code,0) / EXPECT_EQ(s.code, BACKEND_ERR)) could pass for the wrong reason — a broken seed looking identical to a genuine documented refusal. ASSERT_* inside a subroutine only aborts the subroutine, so ASSERT_NO_FATAL_FAILURE at the call site is what actually aborts the test on setup failure. Now a refusal assertion can only be reached with a genuinely-seeded entry. The comment, which previously claimed fatal handling it didn't provide, now matches the code. --- tests/contract/test_capability_gated.cpp | 31 +++++++++++++++--------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/contract/test_capability_gated.cpp b/tests/contract/test_capability_gated.cpp index 4629ae14..93416e0b 100644 --- a/tests/contract/test_capability_gated.cpp +++ b/tests/contract/test_capability_gated.cpp @@ -130,17 +130,20 @@ class CapabilityMatrix : public ::testing::TestWithParam { al_contract::TempBase base_; PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; - // Open a fresh, seeded data entry. Fails the test fatally on any setup error - // (so a later refusal assertion cannot be a false positive from bad setup). - int open_seeded() { + // Open a fresh, seeded data entry into *pctx. Fails the test FATALLY on any + // setup error — via ASSERT_* here plus ASSERT_NO_FATAL_FAILURE at the call + // site — so a paired-negative refusal assertion downstream can never be a + // false positive produced by a broken seed rather than the genuine + // documented refusal. (ASSERT_* is only usable in a void-returning function, + // hence the out-param instead of a returned pctx.) + void open_seeded(int* pctx) { const CapBackend& b = GetParam(); if (b.on_disk) base_.make_legacy_tree(pulse_); const std::string uri = al_contract::build_uri(b.id, base_.str(), pulse_); - EXPECT_FALSE(uri.empty()); - int pctx = -1; - EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); - EXPECT_TRUE(seed_signal(pctx)) << "seed write must succeed on every backend"; - return pctx; + ASSERT_FALSE(uri.empty()); + *pctx = -1; + ASSERT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, pctx).code, 0); + ASSERT_TRUE(seed_signal(*pctx)) << "seed write must succeed on every backend"; } }; @@ -149,7 +152,8 @@ class CapabilityMatrix : public ::testing::TestWithParam { // manifestation (the guard at src/al_lowlevel.cpp:1053). TEST_P(CapabilityMatrix, TimeRangeReadPositiveOrRefused) { const CapBackend b = GetParam(); - int pctx = open_seeded(); + int pctx = -1; + ASSERT_NO_FATAL_FAILURE(open_seeded(&pctx)); int op = -1; double dtime = 0.0; @@ -178,7 +182,8 @@ TEST_P(CapabilityMatrix, TimeRangeReadPositiveOrRefused) { // --- slice: positive on HDF5 + Memory, paired-negative (BACKEND_ERR) elsewhere - TEST_P(CapabilityMatrix, SliceReadPositiveOrRefused) { const CapBackend b = GetParam(); - int pctx = open_seeded(); + int pctx = -1; + ASSERT_NO_FATAL_FAILURE(open_seeded(&pctx)); int op = -1; // A time between the two slices; CLOSEST resolves to kT0 -> kV0. @@ -202,7 +207,8 @@ TEST_P(CapabilityMatrix, SliceReadPositiveOrRefused) { // that begin-slice-for-write is accepted where supported and refused where not. TEST_P(CapabilityMatrix, SliceWriteBeginPositiveOrRefused) { const CapBackend b = GetParam(); - int pctx = open_seeded(); + int pctx = -1; + ASSERT_NO_FATAL_FAILURE(open_seeded(&pctx)); int op = -1; al_status_t s = al_begin_slice_action(pctx, kIds, WRITE_OP, UNDEFINED_TIME, @@ -219,7 +225,8 @@ TEST_P(CapabilityMatrix, SliceWriteBeginPositiveOrRefused) { // --- list_filled_paths: positive on HDF5, paired-negative (BACKEND_ERR) else --- TEST_P(CapabilityMatrix, ListFilledPathsPositiveOrRefused) { const CapBackend b = GetParam(); - int pctx = open_seeded(); + int pctx = -1; + ASSERT_NO_FATAL_FAILURE(open_seeded(&pctx)); char** paths = nullptr; int n = -1; From 1e80dd6400a8631535dd7913349a1d5af0bd6a80 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:55:45 +0200 Subject: [PATCH 09/91] fix : add current-behavior tripwires for all rot-prone xfails --- tests/contract/TRACEABILITY.md | 29 ++++---- tests/contract/test_roundtrip_matrix.cpp | 90 ++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 68ee74d7..f6f6866b 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -71,7 +71,7 @@ opened; until then they are referenced by build-order step + task-doc slug. | `al_context_info` describes a context; **caller frees `*info`** | :118-126 | — | — | — → ownership sweep (step 6) | gap | | `al_get_backendID` returns the active `BACKEND` for a context | :128-135 | — | — | — → step 2 | gap | | `al_build_uri_from_legacy_parameters` builds a URI from legacy params | :137-149 | ✓ | ✓ | `al_contract::build_uri` (asserts OK) drives HDF5·Memory·ASCII in all on-disk suites. Caller-frees of `*uri` → ownership sweep (step 6) | covered | -| ↳ must also address the always-on FLEXBUFFERS backend — **throws (`getURIBackend` has no case)** | :137-149; src/al_context.cpp:280 | ✓ | — | `RoundTripKnownDefects.DISABLED_BuildUriSupportsFlexbuffers` (no paired tripwire — see risk note §end) | **xfail** | +| ↳ must also address the always-on FLEXBUFFERS backend — **throws (`getURIBackend` has no case)** | :137-149; src/al_context.cpp:280 | ✓ | — | `RoundTripKnownDefects.DISABLED_BuildUriSupportsFlexbuffers` + tripwire `RoundTripKnownDefects.BuildUriFlexbuffersCurrentlyFails` | **xfail** | ## Cluster 2 — Core data access (`FUNCTIONALITY_INVENTORY.md:153-314`) @@ -95,7 +95,7 @@ genuine defect (expected-fail). | ↳ 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` (**no paired tripwire — see risk note §end**) | **xfail** | +| ↳ numeric at MAXDIM (rank 7) must round-trip — **ASCII corrupts (DOUBLE/COMPLEX) / aborts (INTEGER)** | :244-263 | — | ✓ | `RoundTripKnownDefects.DISABLED_Ascii{Integer,Double,Complex}MaxdimRoundTrips` + tripwires `RoundTripKnownDefectsDeath.AsciiIntegerMaxdimCurrentlyAborts`, `RoundTripKnownDefects.Ascii{Double,Complex}MaxdimCurrentlyCorrupts` | **xfail** | | `al_delete_data` at signal / structure / DATAOBJECT-root granularity | :265-272 | — | — | — → structured data (step 2) | gap | | `al_iterate_over_arraystruct` advances the AOS cursor | :274-282 | — | — | — → structured data (step 2) | gap | | `al_get_occurrences` lists non-empty occurrences; caller-frees `*occurrences_list` | :284-292 | — | — | — → structured data (step 2); ownership → step 6 | gap | @@ -183,16 +183,19 @@ the plugins issue has a concrete checklist rather than a blank slate. --- -## Open risks in the *current* xfail bookkeeping (for the ownership-sweep / next issue) +## xfail bookkeeping — every xfail now has a paired tripwire The D2 discipline is "correct-contract `DISABLED_` test **plus** a paired -current-behavior tripwire, so the xfail can't rot." Two xfail rows above do not -yet have that tripwire and will rot silently if the underlying defect is fixed: - -- **ASCII rank-7 numeric** (`DISABLED_Ascii{Integer,Double,Complex}MaxdimRoundTrips`) - — no current-behavior twin. Add one asserting today's corruption/abort. -- **`DISABLED_BuildUriSupportsFlexbuffers`** — no twin asserting the current - throw. Lower stakes (not a crash), but the same rot risk. - -The HDF5 CHAR-scalar and the plugin unregistered-name defects **do** have their -tripwires (`…Death` cases) and are safe. +current-behavior tripwire, so the xfail can't rot." As of the S4 fix, all five +xfail rows satisfy it — a fix to any underlying defect turns its tripwire red, +forcing whoever fixed it to enable the paired `DISABLED_` correct-contract test: + +| Defect | Correct-contract (`DISABLED_`) | Current-behavior tripwire | +|---|---|---| +| HDF5 CHAR scalar crashes | `RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips` | `RoundTripKnownDefectsDeath.Hdf5CharScalarCurrentlyCrashes` | +| ASCII rank-7 INTEGER aborts | `…DISABLED_AsciiIntegerMaxdimRoundTrips` | `RoundTripKnownDefectsDeath.AsciiIntegerMaxdimCurrentlyAborts` | +| ASCII rank-7 DOUBLE corrupts | `…DISABLED_AsciiDoubleMaxdimRoundTrips` | `RoundTripKnownDefects.AsciiDoubleMaxdimCurrentlyCorrupts` | +| ASCII rank-7 COMPLEX corrupts | `…DISABLED_AsciiComplexMaxdimRoundTrips` | `RoundTripKnownDefects.AsciiComplexMaxdimCurrentlyCorrupts` | +| `build_uri` can't address FLEXBUFFERS | `…DISABLED_BuildUriSupportsFlexbuffers` | `RoundTripKnownDefects.BuildUriFlexbuffersCurrentlyFails` | +| slice append doesn't accumulate | `Hdf5SliceAppend.DISABLED_AppendedSlicesAllPersist` | `Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior` | +| plugin setvalue unregistered-name crash | `KnownDefects.DISABLED_SetValueIntScalarUnregisteredPluginReturnsError` | `KnownDefectsDeath.SetValueIntScalarUnregisteredPluginCurrentlyCrashes` | diff --git a/tests/contract/test_roundtrip_matrix.cpp b/tests/contract/test_roundtrip_matrix.cpp index 7d8ce5cc..07a71b11 100644 --- a/tests/contract/test_roundtrip_matrix.cpp +++ b/tests/contract/test_roundtrip_matrix.cpp @@ -318,6 +318,53 @@ void run_round_trip_here(const BackendCase& backend, int rank) { run_round_trip(backend, base, pulse, rank); } +// Observe a write->read round trip WITHOUT asserting equality: returns true iff +// the data survived intact (shape + values), after asserting each op itself +// succeeded (so a "did not survive" result means genuine corruption, not a +// refusal or a broken setup). The paired DISABLED_ tests above assert survival +// as the correct contract; the current-behavior tripwires below use this to pin +// today's *silent corruption* (the op returns success but the bytes come back +// wrong), so a fix that restores the data flips the tripwire red. +template +bool maxdim_round_trip_survives(const BackendCase& backend, int rank) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + const Cell c = make_cell(backend, base, pulse, rank); + EXPECT_FALSE(c.uri.empty()); + + int pulse_ctx = -1; + EXPECT_EQ( + al_begin_dataentry_action(c.uri.c_str(), FORCE_CREATE_PULSE, &pulse_ctx) + .code, + 0); + { + int op_ctx = -1; + EXPECT_EQ( + al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, &op_ctx).code, + 0); + EXPECT_EQ(al_contract::write_data(op_ctx, c.field.c_str(), c.shape, + c.written) + .code, + 0); + EXPECT_EQ(al_end_action(op_ctx).code, 0); + } + std::vector read_shape; + std::vector read_data; + { + int op_ctx = -1; + EXPECT_EQ( + al_begin_global_action(pulse_ctx, kIds, "", READ_OP, &op_ctx).code, + 0); + EXPECT_EQ(al_contract::read_data(op_ctx, c.field.c_str(), rank, + &read_shape, &read_data) + .code, + 0); + EXPECT_EQ(al_end_action(op_ctx).code, 0); + } + al_close_pulse(pulse_ctx, CLOSE_PULSE); + return read_shape == c.shape && read_data == c.written; +} + // --- HDF5 crashes writing/reading a CHAR scalar (dim 0) -------------------- TEST(RoundTripKnownDefects, DISABLED_Hdf5CharScalarRoundTrips) { run_round_trip_here(kHdf5, /*rank=*/0); @@ -343,6 +390,33 @@ TEST(RoundTripKnownDefects, DISABLED_AsciiComplexMaxdimRoundTrips) { run_round_trip_here>(kAscii, /*rank=*/MAXDIM); } +// Current-behavior tripwires for the ASCII rank-7 defects, so the DISABLED_ +// correct-contract tests above can't rot. The three datatypes fail differently: +// INTEGER aborts the process (a death test), while DOUBLE and COMPLEX complete +// the op but return corrupted bytes (assert non-survival). When ASCII is fixed +// each tripwire goes red, forcing whoever fixed it to enable the paired +// DISABLED_ test. +TEST(RoundTripKnownDefectsDeath, AsciiIntegerMaxdimCurrentlyAborts) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + ASSERT_DEATH(run_round_trip_here(kAscii, /*rank=*/MAXDIM), ".*") + << "expected ASCII rank-7 INTEGER to abort. If it no longer does, the " + "defect was likely fixed — enable " + "RoundTripKnownDefects.DISABLED_AsciiIntegerMaxdimRoundTrips."; +} + +TEST(RoundTripKnownDefects, AsciiDoubleMaxdimCurrentlyCorrupts) { + EXPECT_FALSE(maxdim_round_trip_survives(kAscii, /*rank=*/MAXDIM)) + << "ASCII rank-7 DOUBLE now round-trips intact — the defect is fixed; " + "enable RoundTripKnownDefects.DISABLED_AsciiDoubleMaxdimRoundTrips."; +} + +TEST(RoundTripKnownDefects, AsciiComplexMaxdimCurrentlyCorrupts) { + EXPECT_FALSE( + maxdim_round_trip_survives>(kAscii, /*rank=*/MAXDIM)) + << "ASCII rank-7 COMPLEX now round-trips intact — the defect is fixed; " + "enable RoundTripKnownDefects.DISABLED_AsciiComplexMaxdimRoundTrips."; +} + // --- al_build_uri_from_legacy_parameters can't address FLEXBUFFERS ---------- // getURIBackend (src/al_context.cpp:280) has no FLEXBUFFERS_BACKEND case and // throws, even though the parse side accepts the "flexbuffers" scheme — so the @@ -360,4 +434,20 @@ TEST(RoundTripKnownDefects, DISABLED_BuildUriSupportsFlexbuffers) { free(uri); } +// Current-behavior tripwire: today the builder errors for FLEXBUFFERS (code -4), +// so al_contract.h::build_uri hand-builds the URI to keep the matrix running. +// When the builder is fixed this goes red, forcing whoever fixed it to enable +// DISABLED_BuildUriSupportsFlexbuffers and drop the hand-built workaround. +TEST(RoundTripKnownDefects, BuildUriFlexbuffersCurrentlyFails) { + char* uri = nullptr; + al_status_t s = al_build_uri_from_legacy_parameters( + FLEXBUFFERS_BACKEND, 12, 0, "/tmp/al_contract_flexbuffers", "test", "3", + "", &uri); + EXPECT_NE(s.code, 0) + << "al_build_uri_from_legacy_parameters now supports FLEXBUFFERS — " + "enable RoundTripKnownDefects.DISABLED_BuildUriSupportsFlexbuffers " + "and drop the hand-built URI workaround in al_contract.h::build_uri."; + free(uri); +} + } // namespace From e5ce77cdcaa36357b23ee75e7997a72cf754f826 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:11:51 +0200 Subject: [PATCH 10/91] split unit/integration CTest labels + add sanitizer CI leg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two scaffold (issue #2) acceptance items were never delivered; this closes both. 1. ctest -L unit label split. The suite carried only the contract label, so ctest -L unit selected nothing (PRD user story 29). Now two disjoint gtest_discover_tests passes label each case by substrate (decision D6): unit = pure lookups + in-RAM Memory + plugin ABI tests (Introspection.*:*Memory*:KnownDefects*), integration = everything on-disk. Both also keep contract. Two gotchas handled: the LABELS list needs an escaped ; to survive re-emission, and gtest's negative-filter syntax is one - then colon-separated (*:-a:b:c), not -a:-b:-c. Guarded by CMake ≥ 3.22 (TEST_FILTER), falling back to the single contract label on the 3.21 floor. Verified: ctest -L unit = 49 hermetic tests (0 on-disk), runs in 0.20 s; integration = 126; contract = 175 (49 + 126, disjoint). 2. Non-blocking sanitizer CI leg + suppressions file. Added .github/workflows/sanitizers.yml: a Linux continue-on-error job that configures -DAL_CONTRACT_SANITIZE=ON, builds, and runs the contract suite under ASan/UBSan/LSan, with tests/contract/sanitizer_suppressions.txt wired via LSAN_OPTIONS. Validated the (previously never-exercised) AL_CONTRACT_SANITIZE build path locally: it compiles, links ASan into the binary, and passes 100% (163 tests) under ASan/UBSan. The run excludes Death tests (deliberate crashes) and the CurrentlyCorrupts tripwires — the latter because the ASCII rank-7 DOUBLE/COMPLEX corruption is UB that ASan's memory layout masks (documented in-code and in the workflow). --- .github/workflows/sanitizers.yml | 65 +++++++++++++++++++++++ tests/contract/CMakeLists.txt | 33 +++++++++--- tests/contract/TRACEABILITY.md | 12 ++--- tests/contract/sanitizer_suppressions.txt | 21 ++++++++ tests/contract/test_roundtrip_matrix.cpp | 6 +++ 5 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/sanitizers.yml create mode 100644 tests/contract/sanitizer_suppressions.txt diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml new file mode 100644 index 00000000..607ceb51 --- /dev/null +++ b/.github/workflows/sanitizers.yml @@ -0,0 +1,65 @@ +name: Contract suite — sanitizers (non-blocking) + +# Decision D7 (TEST_STRATEGY.md): build the contract suite with +# AddressSanitizer + UndefinedBehaviorSanitizer + LeakSanitizer and run it, so a +# leak / use-after-free / overrun in the hot path is caught for free — no +# per-test assertions. This leg is INFORMATIONAL, not a merge gate +# (continue-on-error: true), until the baseline is triaged. LSan leak detection +# is Linux-only, which is why this leg runs on ubuntu. + +on: + push: + pull_request: + +jobs: + asan-ubsan-lsan: + name: ASan/UBSan/LSan (Linux, non-blocking) + runs-on: ubuntu-latest + continue-on-error: true # informational leg (D7), not a gate — for now + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 # git describe needs full history for the version macro + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y ninja-build libhdf5-dev libboost-filesystem-dev + + - name: Configure (sanitized, tests on, no Python bindings) + # AL_CONTRACT_SANITIZE instruments both libal and the test binary. Python + # bindings are off: a sanitized libal must not be linked into a wheel + # whose bindings lack the ASan runtime (see tests/contract/CMakeLists.txt). + run: > + cmake -S . -B build-san -G Ninja + -DAL_BUILD_TESTS=ON + -DAL_CONTRACT_SANITIZE=ON + -DAL_PYTHON_BINDINGS=OFF + -DCMAKE_BUILD_TYPE=RelWithDebInfo + + - name: Build contract suite + run: cmake --build build-san --target contract_tests + + - name: Run contract suite under sanitizers + working-directory: build-san + # Scope to the contract suite; halt/abort_on_error=0 so the whole suite + # runs and reports everything at once (informational leg). Two exclusions: + # * `Death` tests deliberately crash child processes (asserting today's + # crash-class defects) — not a memory-safety target, and ASan would + # report the intentional crash as an error. + # * `CurrentlyCorrupts` tripwires pin the ASCII rank-7 DOUBLE/COMPLEX + # corruption, which is UB whose manifestation ASan's memory layout + # masks (the data round-trips intact under ASan), so they legitimately + # fail here. They still guard against rot in the normal (non-sanitized) + # CI run. Real leaks/UAF in the ordinary paths still surface. + env: + ASAN_OPTIONS: detect_leaks=1:halt_on_error=0:abort_on_error=0 + LSAN_OPTIONS: suppressions=${{ github.workspace }}/tests/contract/sanitizer_suppressions.txt:print_suppressions=1 + UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=0 + run: ctest -L contract -E "Death|CurrentlyCorrupts" --output-on-failure + + - name: Note + if: always() + run: echo "Sanitizer leg is informational (D7). Triage findings before promoting it to a required check." diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 03b081c5..2467b288 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -102,13 +102,34 @@ endif() # CTest registration. # # gtest_discover_tests runs each GoogleTest case as its own ctest entry (so -# --gtest_list_tests / -R selection works) and applies the "contract" label to -# all of them. The finer unit/integration labelling (decision D6) arrives with -# the broader matrix in later steps; the suite keeps one label. +# --gtest_list_tests / -R selection works). Every case carries the "contract" +# label; on top of that we split by *substrate* (decision D6) so `ctest -L unit` +# is a fast, hermetic inner loop (PRD user story 29) and `ctest -L integration` +# is the on-disk tier: +# unit = pure lookups (Introspection.*), the in-RAM Memory backend +# (*Memory* parametrized instances), and the plugin ABI death +# tests (KnownDefects*) — nothing touches an on-disk backend. +# integration = everything else (HDF5 / ASCII / Flexbuffers on disk). +# Two disjoint discovery passes over the one binary; the TEST_FILTERs partition +# the cases so each registers exactly once. TEST_FILTER needs CMake >= 3.22; on +# the declared 3.21 floor we fall back to the single "contract" label. -gtest_discover_tests(contract_tests - PROPERTIES LABELS "contract" -) +# The escaped ";" keeps LABELS a two-element list through gtest_discover_tests' +# re-emission (an unescaped ";" flattens to two malformed args). The integration +# TEST_FILTER uses gtest's one-"-"-then-colon-separated negative syntax +# ("*:-a:b:c", NOT "*:-a:-b:-c") so the two passes stay disjoint — otherwise the +# second pass re-registers the unit cases and CTest sees duplicates. +if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") + gtest_discover_tests(contract_tests + TEST_FILTER "Introspection.*:*Memory*:KnownDefects*" + PROPERTIES LABELS "contract\;unit") + gtest_discover_tests(contract_tests + TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*" + PROPERTIES LABELS "contract\;integration") +else() + gtest_discover_tests(contract_tests + PROPERTIES LABELS "contract") +endif() ######################################################################################################################## # Install (mirrors the smoke-test targets). diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index f6f6866b..2e531420 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -32,12 +32,12 @@ carry a `/` (or `/__r`) suffix per instance. > **Note on the Unit / Integ. columns.** They classify each test by *substrate* > per decision D6 (pure lookups and Memory round trips are unit; on-disk -> backends are integration) — the intended tier taxonomy. The CTest `unit` / -> `integration` **labels** that would let `ctest -L unit` select them are still -> **deferred** (see `CMakeLists.txt`): today every case carries the single -> `contract` label. Until the split lands, address the unit subset with -> `ctest -R Introspection`. *(This is a known un-met acceptance item of the -> scaffold issue #2 — tracked, not silently dropped.)* +> backends are integration). This taxonomy is now enforced as CTest **labels** +> (`CMakeLists.txt`): every case carries `contract`, plus `unit` or `integration` +> by substrate, so `ctest -L unit` runs the fast hermetic subset (no on-disk +> backend) and `ctest -L integration` runs the on-disk tier. The label split +> needs CMake ≥ 3.22 (`gtest_discover_tests(TEST_FILTER …)`); on the declared +> 3.21 floor it falls back to the single `contract` label. ## Coverage status — as of the four implemented issues (#2 scaffold, #5 ## introspection, #3 round-trip matrix, #6 capability-gated) diff --git a/tests/contract/sanitizer_suppressions.txt b/tests/contract/sanitizer_suppressions.txt new file mode 100644 index 00000000..65e3f22a --- /dev/null +++ b/tests/contract/sanitizer_suppressions.txt @@ -0,0 +1,21 @@ +# LeakSanitizer suppressions — IMAS-Core contract suite +# ====================================================== +# Decision D7 (TEST_STRATEGY.md): the contract suite is also built with +# ASan/UBSan + LeakSanitizer in a dedicated, NON-BLOCKING CI leg +# (.github/workflows/sanitizers.yml). This file catalogues known/triaged leaks +# so the leg stays green on already-understood issues and only *new* leaks stand +# out. The leg is promoted to a hard gate only after the baseline is triaged. +# +# Referenced at run time via: +# LSAN_OPTIONS=suppressions=:print_suppressions=1 +# +# Format (one per line): +# leak: +# A frame matching any substring suppresses the whole leak report it appears in. +# Examples (commented — enable only with a written justification + a tracking +# issue, never to silence a leak the suite itself introduced): +# leak:H5FL_* # HDF5 free-list pools not reclaimed at exit +# leak:libhdf5 +# +# Intentionally empty of active entries: no baseline leak has been triaged yet. +# Add an entry ONLY with a comment naming the tracking issue and why it's safe. diff --git a/tests/contract/test_roundtrip_matrix.cpp b/tests/contract/test_roundtrip_matrix.cpp index 07a71b11..298aacd3 100644 --- a/tests/contract/test_roundtrip_matrix.cpp +++ b/tests/contract/test_roundtrip_matrix.cpp @@ -396,6 +396,12 @@ TEST(RoundTripKnownDefects, DISABLED_AsciiComplexMaxdimRoundTrips) { // the op but return corrupted bytes (assert non-survival). When ASCII is fixed // each tripwire goes red, forcing whoever fixed it to enable the paired // DISABLED_ test. +// +// NB: the DOUBLE/COMPLEX corruption is UB — deterministic in ordinary builds but +// *masked under AddressSanitizer* (the data round-trips intact there), so the +// two `…CurrentlyCorrupts` tripwires are excluded from the sanitizer CI leg +// (.github/workflows/sanitizers.yml, `-E CurrentlyCorrupts`). They remain live +// in the normal test run, which is where their rot-guard job matters. TEST(RoundTripKnownDefectsDeath, AsciiIntegerMaxdimCurrentlyAborts) { GTEST_FLAG_SET(death_test_style, "threadsafe"); ASSERT_DEATH(run_round_trip_here(kAscii, /*rank=*/MAXDIM), ".*") From 306df1cf41796e345983f14615b996f63151a2c3 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:14:28 +0200 Subject: [PATCH 11/91] Assert getDDVersion() invariance (not the literal) in the version sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sentinel test's real property is non-interpretation: writing a DD version to version_put/data_dictionary must not change what getDDVersion() returns. Compare the post-write value to the captured baseline (dd_before) rather than to the "!!DEPRECATED!!" literal — the literal value is already pinned by test_introspection.cpp::GetDDVersionReturnsDeprecatedSentinel, so this removes the cross-test coupling and keeps the invariance check correct even if the compiled sentinel ever changes. The one baseline assertion stays as the anchor. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/contract/test_version_sentinel.cpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/contract/test_version_sentinel.cpp b/tests/contract/test_version_sentinel.cpp index f348c20d..b13c3dda 100644 --- a/tests/contract/test_version_sentinel.cpp +++ b/tests/contract/test_version_sentinel.cpp @@ -75,8 +75,11 @@ TEST_P(VersionSentinel, VersionPutRoundTripsOpaquelyAndIsNotInterpreted) { } // Writing a DD-version string must not have taught the core a DD version: - // getDDVersion() is a compile-time sentinel, independent of stored data. - EXPECT_EQ(std::string(getDDVersion()), kDeprecatedSentinel) + // getDDVersion() is invariant under stored data. Compared to the captured + // baseline (not the literal sentinel) so this asserts *non-interpretation* + // itself — the specific "!!DEPRECATED!!" value is pinned by + // test_introspection.cpp::GetDDVersionReturnsDeprecatedSentinel. + EXPECT_EQ(std::string(getDDVersion()), dd_before) << "the core must not adopt a stored version_put value as its DD version"; // --- read it back; it must be the exact bytes we wrote (opaque) --- @@ -94,8 +97,8 @@ TEST_P(VersionSentinel, VersionPutRoundTripsOpaquelyAndIsNotInterpreted) { EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); - // Still the sentinel after a full write→read cycle. - EXPECT_EQ(std::string(getDDVersion()), kDeprecatedSentinel); + // Still unchanged after a full write→read cycle. + EXPECT_EQ(std::string(getDDVersion()), dd_before); } INSTANTIATE_TEST_SUITE_P( From 1cbc10371d55d55336086722940c45ec436d2861 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:49:38 +0200 Subject: [PATCH 12/91] Add plugin-lifecycle + crash-class defect tests (issue #7) Cover User Cluster 3 (plugin management) through the public C ABI, per TEST_STRATEGY.md build-order step 5 and decision D2. - test_plugin_fixture.cpp: a real, self-contained access_layer_plugin built as a loadable alcontract_plugin.so (the classic red-green fixture), so the register/bind/unbind/unregister state machine and the al_setvalue_* happy paths run end to end. Its setParameter logs the received value so tests can prove a value crossed the ABI, not just that the call returned success. - test_plugins.cpp: the register/bind/unbind/unregister state machine + the documented quirks (register-twice throws, bind-unregistered throws, double-bind throws, unbind-unbound silent no-op), al_is_plugin_registered, all three al_setvalue_* variants reaching the plugin, and framework gating. Two defects pinned as xfail + paired current-behavior tripwire: the registered-but-never-bound plugin left un-destroyed on unregister, and the dlopen-failure swallowed by the NDEBUG-stripped assert. - test_known_defects.cpp: add the double- and generic-variant setvalue null-deref death tests (SIGSEGV) + DISABLED_ correct-contract partners, joining the scaffold's int variant; share the unregistered-name constant. - al_contract.h: hoist the shared kUnregisteredPluginName constant. - CMakeLists.txt: build the plugin MODULE (POSIX-guarded), wire its dir/name via compile defs, add test_plugins.cpp, extend the unit label filter. - TRACEABILITY.md: fill the Cluster 3 rows, extend the xfail bookkeeping table (now 11 pairs), mark Part 3 setParameter covered (indirect). Empirical finding vs the inventory: the dlopen defect does not crash on this NDEBUG build (the null handle reaches dlsym, whose null-check throws), so it is a plain xfail, not a death test; the setvalue null-derefs genuinely SIGSEGV and remain death tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/contract/CMakeLists.txt | 42 ++- tests/contract/TRACEABILITY.md | 71 +++-- tests/contract/al_contract.h | 5 + tests/contract/test_known_defects.cpp | 63 +++- tests/contract/test_plugin_fixture.cpp | 105 +++++++ tests/contract/test_plugins.cpp | 381 +++++++++++++++++++++++++ 6 files changed, 638 insertions(+), 29 deletions(-) create mode 100644 tests/contract/test_plugin_fixture.cpp create mode 100644 tests/contract/test_plugins.cpp diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 2467b288..cc13839f 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -67,12 +67,45 @@ add_executable(contract_tests test_version_sentinel.cpp test_capability_gated.cpp test_known_defects.cpp + test_plugins.cpp ) target_link_libraries(contract_tests PRIVATE al GTest::gtest GTest::gtest_main) add_dependencies(contract_tests al) target_compile_features(contract_tests PRIVATE cxx_std_17) +######################################################################################################################## +# Test plugin fixture (issue #7, TEST_STRATEGY.md step 5). +# +# A real, loadable plugin the C ABI can dlopen, so the register/bind/unbind/ +# unregister state machine and the al_setvalue_*_parameter_plugin happy paths +# run end to end (not just their failure modes). This is the classic red-green +# fixture/harness the issue calls out. Built as a MODULE named exactly +# "_plugin.so" (PREFIX ""/SUFFIX ".so") because registerPlugin resolves +# "$IMAS_AL_PLUGINS/_plugin.so" (src/al_lowlevel.cpp:335). The +# plugin base classes are header-only, so the module links no libal symbols — +# it only needs al's include dirs (and al's generated al_defs.h). The plugins +# framework is dlopen/dlfcn-based, so this is POSIX-only. +if(NOT WIN32) + add_library(al_contract_plugin MODULE test_plugin_fixture.cpp) + # Pull in al's (source + generated) include dirs without linking its symbols. + target_include_directories(al_contract_plugin + PRIVATE $) + add_dependencies(al_contract_plugin al) # ensure al_defs.h is generated + add_dependencies(contract_tests al_contract_plugin) + target_compile_features(al_contract_plugin PRIVATE cxx_std_17) + set_target_properties(al_contract_plugin PROPERTIES + PREFIX "" + OUTPUT_NAME "alcontract_plugin" + SUFFIX ".so" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/plugins") + + # Tell test_plugins.cpp where the built .so lives and what name to register. + target_compile_definitions(contract_tests PRIVATE + AL_CONTRACT_PLUGIN_DIR="${CMAKE_CURRENT_BINARY_DIR}/plugins" + AL_CONTRACT_PLUGIN_NAME="alcontract") +endif() + if(NOT WIN32 AND NOT APPLE) # stdc++fs is only needed on Linux with older GCC (std::filesystem in the harness). target_link_libraries(contract_tests PRIVATE stdc++fs) @@ -107,8 +140,9 @@ endif() # is a fast, hermetic inner loop (PRD user story 29) and `ctest -L integration` # is the on-disk tier: # unit = pure lookups (Introspection.*), the in-RAM Memory backend -# (*Memory* parametrized instances), and the plugin ABI death -# tests (KnownDefects*) — nothing touches an on-disk backend. +# (*Memory* parametrized instances), the plugin ABI death +# tests (KnownDefects*), and the plugin lifecycle suite +# (Plugin*, an in-process registry — no on-disk backend). # integration = everything else (HDF5 / ASCII / Flexbuffers on disk). # Two disjoint discovery passes over the one binary; the TEST_FILTERs partition # the cases so each registers exactly once. TEST_FILTER needs CMake >= 3.22; on @@ -121,10 +155,10 @@ endif() # second pass re-registers the unit cases and CTest sees duplicates. if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") gtest_discover_tests(contract_tests - TEST_FILTER "Introspection.*:*Memory*:KnownDefects*" + TEST_FILTER "Introspection.*:*Memory*:KnownDefects*:Plugin*" PROPERTIES LABELS "contract\;unit") gtest_discover_tests(contract_tests - TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*" + TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*" PROPERTIES LABELS "contract\;integration") else() gtest_discover_tests(contract_tests diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 2e531420..ef755958 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -39,19 +39,23 @@ carry a `/` (or `/__r`) suffix per instance. > needs CMake ≥ 3.22 (`gtest_discover_tests(TEST_FILTER …)`); on the declared > 3.21 floor it falls back to the single `contract` label. -## Coverage status — as of the four implemented issues (#2 scaffold, #5 -## introspection, #3 round-trip matrix, #6 capability-gated) +## Coverage status — as of the five implemented issues (#2 scaffold, #5 +## introspection, #3 round-trip matrix, #6 capability-gated, #7 plugins) -The suite covers the **data-path and introspection surface** end to end and -pins the four defects those issues surfaced. It does **not yet** cover the -pulse-lifecycle detail, arrays-of-structures, deletion, occurrences, or any of -the plugin surface — those are the remaining build-order steps and appear below -as explicit **gap** rows, each tagged with the step that owns it: +The suite covers the **data-path, introspection, and plugin-management surface** +end to end and pins the defects those issues surfaced (nine xfails, each with a +paired current-behavior tripwire). It does **not yet** cover the pulse-lifecycle +detail, arrays-of-structures, deletion, occurrences, or the Plugin-author +audience — those are the remaining build-order steps and appear below as explicit +**gap** rows, each tagged with the step that owns it: - **Structured data** (TEST_STRATEGY §4 step 2, task doc `2b-structured.md`): AOS, `al_delete_data`, `al_get_occurrences`, full pulse lifecycle. -- **Plugins** (§4 step 5, task doc `5-plugins.md`): all of User Cluster 3 and - all of Part 3. +- **Plugins** (§4 step 5, task doc `5-plugins.md`): User Cluster 3 is now + covered (register/bind/unbind/unregister state machine + quirks, + `al_is_plugin_registered`, all three `al_setvalue_*` variants, four pinned + defects). Still open: readback binding, `al_write_plugins_metadata`, and all + of Part 3 (the Plugin-author audience). - **Ownership sweep** (§4 step 6, task doc `6-ownership-sweep.md`): the caller-frees questions and the remaining audience sweep. @@ -103,19 +107,34 @@ genuine defect (expected-fail). ## Cluster 3 — Plugin management (`FUNCTIONALITY_INVENTORY.md:318-430`) -Almost entirely deferred to the plugins issue (step 5). The one exception is the -scaffold's expected-fail proof, which lives on the int-scalar setvalue variant. +The plugins issue (#7, step 5) — the defect-heavy corner. The register / bind / +unbind / unregister state machine and the `al_setvalue_*` calls are driven +through the C ABI against a real, in-repo loadable plugin +(`test_plugin_fixture.cpp` → `alcontract_plugin.so`, the classic red-green +fixture); every behavior was characterized against the built library, then +classified once (D2). Four defects are pinned as expected-fail with paired +current-behavior tripwires: the two setvalue null-derefs (double + generic, +joining the scaffold's int one), the registered-but-never-bound plugin left +un-destroyed on unregister, and the `dlopen`-failure swallowed assert. The whole +suite is `unit` (in-process registry, no on-disk backend). Still deferred: +readback binding and `al_write_plugins_metadata` (need a get/put through a +readback-implementing plugin — Part 3 / ownership sweep). | Capability | Inventory ref | Unit | Integ. | Test(s) — and residual | Status | |---|---|:--:|:--:|---|---| -| `al_register_plugin` / `al_unregister_plugin` lifecycle (register-twice throws; unregister of never-bound leaves it un-destroyed) | :325-360 | — | — | — → plugins (step 5) | gap | -| `al_bind_plugin` / `al_unbind_plugin` (double-bind throws; unbind-unbound no-ops) | :362-376 | — | — | — → plugins (step 5) | gap | -| `al_bind_readback_plugins` / `al_unbind_readback_plugins` | :378-389 | — | — | — → plugins (step 5) | gap | -| `al_is_plugin_registered` boolean query | :391-397 | — | — | — → plugins (step 5) | gap | -| `al_setvalue_parameter_plugin` (generic typed variant) | :399-420 | — | — | — → plugins (step 5) | gap | -| `al_setvalue_int_scalar_parameter_plugin` on an **unregistered** name must error, not crash | :399-417 | — | ✓ | `KnownDefects.DISABLED_SetValueIntScalarUnregisteredPluginReturnsError` + tripwire `KnownDefectsDeath.SetValueIntScalarUnregisteredPluginCurrentlyCrashes` (SIGSEGV) | **xfail** | -| `al_setvalue_double_scalar_parameter_plugin` (same defect class, double variant) | :399-417 | — | — | — → plugins (step 5) | gap | -| `al_write_plugins_metadata` | :422-430 | — | — | — → plugins (step 5) | gap | +| `al_register_plugin` / `al_unregister_plugin` lifecycle (register happy path; register-twice throws; unregister-unknown throws; **framework-gated**) | :325-360 | ✓ | — | `PluginTest.{RegisterMakesPluginRegistered, RegisterTwiceReturnsError, UnregisterNeverRegisteredNameReturnsError, UnregisterBoundPluginDestroysIt, RegisterWithFrameworkDisabledReturnsError}` | covered | +| ↳ unregister of a **registered-but-never-bound** plugin must destroy it — **it is left un-destroyed** (`unregisterPlugin` destroy+erase run only inside the `boundPlugins` loop) | :347-354; src/al_lowlevel.cpp:382-431 | ✓ | — | `PluginTest.DISABLED_UnregisterNeverBoundPluginDestroysIt` + tripwire `PluginTest.UnregisterNeverBoundPluginLeavesItRegistered_CurrentBehavior` | **xfail** | +| ↳ `register` on an existing-but-unloadable `.so` must report the **dlopen** failure — the failure is guarded only by an `assert`, stripped under `-DNDEBUG` (misleading downstream "Cannot load symbol create"; would `SIGABRT` in an assert-enabled build) | :355-360; src/al_lowlevel.cpp:350-357 | ✓ | — | `PluginTest.DISABLED_RegisterUnloadableSharedLibReportsDlopenFailure` + tripwire `PluginTest.RegisterUnloadableSharedLibSwallowsAssert_CurrentBehavior` | **xfail** | +| `al_bind_plugin` / `al_unbind_plugin` (bind registered OK; bind-unregistered throws; double-bind throws; unbind-unbound silent no-op) | :362-376 | ✓ | — | `PluginTest.{BindRegisteredPluginSucceeds, BindUnregisteredPluginReturnsError, DoubleBindSamePathReturnsError, UnbindNeverBoundPathIsSilentNoOp}` | covered | +| `al_bind_readback_plugins` / `al_unbind_readback_plugins` | :378-389 | — | — | needs a readback-implementing plugin + a get op → Part 3 / ownership sweep | gap | +| `al_is_plugin_registered` boolean query (true/false; framework-gated) | :391-397 | ✓ | — | `PluginTest.{RegisterMakesPluginRegistered, IsPluginRegisteredIsFalseForNeverRegisteredName, IsPluginRegisteredWithFrameworkDisabledReturnsError}` | covered | +| `al_setvalue_parameter_plugin` (generic typed variant) on a registered plugin | :399-420 | ✓ | — | `PluginTest.SetValueGenericOnRegisteredPluginSucceeds` | covered | +| ↳ generic variant on an **unregistered** name must error, not crash | :399-417 | ✓ | — | `KnownDefects.DISABLED_SetValueGenericUnregisteredPluginReturnsError` + tripwire `KnownDefectsDeath.SetValueGenericUnregisteredPluginCurrentlyCrashes` (SIGSEGV) | **xfail** | +| `al_setvalue_int_scalar_parameter_plugin` on a registered plugin reaches `setParameter` with the right value | :399-417 | ✓ | — | `PluginTest.SetValueIntScalarOnRegisteredPluginReachesPlugin` (asserts the value via the plugin's parameter log) | covered | +| ↳ int-scalar variant on an **unregistered** name must error, not crash | :399-417 | ✓ | — | `KnownDefects.DISABLED_SetValueIntScalarUnregisteredPluginReturnsError` + tripwire `KnownDefectsDeath.SetValueIntScalarUnregisteredPluginCurrentlyCrashes` (SIGSEGV) | **xfail** | +| `al_setvalue_double_scalar_parameter_plugin` on a registered plugin reaches `setParameter` with the right value | :399-417 | ✓ | — | `PluginTest.SetValueDoubleScalarOnRegisteredPluginReachesPlugin` | covered | +| ↳ double-scalar variant on an **unregistered** name must error, not crash | :399-417 | ✓ | — | `KnownDefects.DISABLED_SetValueDoubleScalarUnregisteredPluginReturnsError` + tripwire `KnownDefectsDeath.SetValueDoubleScalarUnregisteredPluginCurrentlyCrashes` (SIGSEGV) | **xfail** | +| `al_write_plugins_metadata` | :422-430 | — | — | needs bound provenance plugin + open ctx → Part 3 / ownership sweep | gap | ## Cluster 4 — Introspection / diagnostics (`FUNCTIONALITY_INVENTORY.md:434-459`) (issue #5 — the thin unit tier) @@ -176,7 +195,7 @@ the plugins issue has a concrete checklist rather than a blank slate. | Cluster / Capability | Inventory ref | Unit | Integ. | Test(s) | Status | |---|---|:--:|:--:|---|---| | **1** Provenance metadata (`getName`/`getVersion`/`getCommit`/…) | :739-759 | — | — | — → plugins (step 5) | gap | -| **2** Parameter configuration (`setParameter` / `setParameters`) | :763-780 | — | — | — → plugins (step 5); the User-side unregistered-name crash is xfail'd in Part 1 Cluster 3 | gap | +| **2** Parameter configuration (`setParameter` / `setParameters`) | :763-780 | ✓ | — | `setParameter` is reached (with the exact value) via `al_setvalue_*` on the registered fixture plugin: `PluginTest.SetValue{IntScalar,DoubleScalar}OnRegisteredPluginReachesPlugin` (asserted through the plugin's parameter log). The User-side unregistered-name crash is xfail'd in Part 1 Cluster 3. Residual: `setParameters` (bulk) untested → step 6 | covered (indirect) | | **3** Action lifecycle & data interception (`begin_*_action`, `read_data`/`write_data`, `node_operation`) | :784-824 | — | — | — → plugins (step 5) | gap | | **4** Readback metadata (`getReadback*`) | :827-850 | — | — | — → plugins (step 5) | gap | | **5** Low-level reentry (`al_plugin_*`); `al_plugin_begin_timerange_action` is broken (declaration/definition mismatch) | :854-895 | — | — | — → plugins (step 5); the reentry bug is tracked separately as an upstream GitHub issue | gap | @@ -186,9 +205,9 @@ the plugins issue has a concrete checklist rather than a blank slate. ## xfail bookkeeping — every xfail now has a paired tripwire The D2 discipline is "correct-contract `DISABLED_` test **plus** a paired -current-behavior tripwire, so the xfail can't rot." As of the S4 fix, all five -xfail rows satisfy it — a fix to any underlying defect turns its tripwire red, -forcing whoever fixed it to enable the paired `DISABLED_` correct-contract test: +current-behavior tripwire, so the xfail can't rot." All nine xfail rows satisfy +it — a fix to any underlying defect turns its tripwire red, forcing whoever fixed +it to enable the paired `DISABLED_` correct-contract test: | Defect | Correct-contract (`DISABLED_`) | Current-behavior tripwire | |---|---|---| @@ -198,4 +217,8 @@ forcing whoever fixed it to enable the paired `DISABLED_` correct-contract test: | ASCII rank-7 COMPLEX corrupts | `…DISABLED_AsciiComplexMaxdimRoundTrips` | `RoundTripKnownDefects.AsciiComplexMaxdimCurrentlyCorrupts` | | `build_uri` can't address FLEXBUFFERS | `…DISABLED_BuildUriSupportsFlexbuffers` | `RoundTripKnownDefects.BuildUriFlexbuffersCurrentlyFails` | | slice append doesn't accumulate | `Hdf5SliceAppend.DISABLED_AppendedSlicesAllPersist` | `Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior` | -| plugin setvalue unregistered-name crash | `KnownDefects.DISABLED_SetValueIntScalarUnregisteredPluginReturnsError` | `KnownDefectsDeath.SetValueIntScalarUnregisteredPluginCurrentlyCrashes` | +| plugin setvalue (int) unregistered-name crash | `KnownDefects.DISABLED_SetValueIntScalarUnregisteredPluginReturnsError` | `KnownDefectsDeath.SetValueIntScalarUnregisteredPluginCurrentlyCrashes` | +| plugin setvalue (double) unregistered-name crash | `KnownDefects.DISABLED_SetValueDoubleScalarUnregisteredPluginReturnsError` | `KnownDefectsDeath.SetValueDoubleScalarUnregisteredPluginCurrentlyCrashes` | +| plugin setvalue (generic) unregistered-name crash | `KnownDefects.DISABLED_SetValueGenericUnregisteredPluginReturnsError` | `KnownDefectsDeath.SetValueGenericUnregisteredPluginCurrentlyCrashes` | +| unregister leaves a never-bound plugin registered | `PluginTest.DISABLED_UnregisterNeverBoundPluginDestroysIt` | `PluginTest.UnregisterNeverBoundPluginLeavesItRegistered_CurrentBehavior` | +| `register` swallows the dlopen failure (NDEBUG-stripped assert) | `PluginTest.DISABLED_RegisterUnloadableSharedLibReportsDlopenFailure` | `PluginTest.RegisterUnloadableSharedLibSwallowsAssert_CurrentBehavior` | diff --git a/tests/contract/al_contract.h b/tests/contract/al_contract.h index 98ddd4b7..e48a5473 100644 --- a/tests/contract/al_contract.h +++ b/tests/contract/al_contract.h @@ -56,6 +56,11 @@ inline ::testing::AssertionResult IsOk(const al_status_t& s) { #define AL_ASSERT_OK(expr) ASSERT_TRUE(::al_contract::IsOk(expr)) #define AL_EXPECT_OK(expr) EXPECT_TRUE(::al_contract::IsOk(expr)) +// A plugin name that is never backed by a .so and never registered — shared by +// the plugin negative/crash tests (test_plugins.cpp, test_known_defects.cpp). +inline constexpr const char* kUnregisteredPluginName = + "no_such_plugin_ever_registered"; + // --------------------------------------------------------------------------- // BackendCase descriptor for value-parametrized tests (TEST_P). // --------------------------------------------------------------------------- diff --git a/tests/contract/test_known_defects.cpp b/tests/contract/test_known_defects.cpp index 58c69a64..0ea4f9c7 100644 --- a/tests/contract/test_known_defects.cpp +++ b/tests/contract/test_known_defects.cpp @@ -17,6 +17,8 @@ // (decision D3): the crash would take down an in-process runner, so we isolate // it in a death test. +#include "al_contract.h" + #include #include @@ -26,7 +28,8 @@ namespace { -constexpr const char* kUnregistered = "no_such_plugin_ever_registered"; +// Shared with test_plugins.cpp via al_contract.h (was a duplicated literal). +constexpr const char* kUnregistered = al_contract::kUnregisteredPluginName; // CORRECT-CONTRACT test, expected-fail. // @@ -71,4 +74,62 @@ TEST(KnownDefectsDeath, SetValueIntScalarUnregisteredPluginCurrentlyCrashes) { "KnownDefects.SetValueIntScalarUnregisteredPluginReturnsError."; } +// --------------------------------------------------------------------------- +// Same null-deref defect, double-scalar variant. +// FUNCTIONALITY_INVENTORY.md:399-417 lists all three setvalue variants as +// sharing the crash: they all funnel into LLplugin::setvalueParameterPlugin, +// whose `llpluginsStore[plugin_name]` default-constructs a null-al_plugin entry +// for an unknown key and dereferences it. (src/al_lowlevel.cpp:453-457.) +// --------------------------------------------------------------------------- + +TEST(KnownDefects, DISABLED_SetValueDoubleScalarUnregisteredPluginReturnsError) { + al_status_t s = + al_setvalue_double_scalar_parameter_plugin("param", 1.0, kUnregistered); + EXPECT_NE(s.code, 0) + << "setting a double parameter on an unregistered plugin must return " + "an error, not crash"; +} + +TEST(KnownDefectsDeath, SetValueDoubleScalarUnregisteredPluginCurrentlyCrashes) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + ASSERT_EXIT( + { + al_setvalue_double_scalar_parameter_plugin("param", 1.0, + kUnregistered); + }, + ::testing::KilledBySignal(SIGSEGV), ".*") + << "expected the unregistered-plugin null-deref (double variant, see " + "FUNCTIONALITY_INVENTORY.md:410). If this no longer dies by SIGSEGV, " + "enable KnownDefects." + "SetValueDoubleScalarUnregisteredPluginReturnsError."; +} + +// --------------------------------------------------------------------------- +// Same null-deref defect, generic-typed variant +// (al_setvalue_parameter_plugin). Same code path, same crash. +// --------------------------------------------------------------------------- + +TEST(KnownDefects, DISABLED_SetValueGenericUnregisteredPluginReturnsError) { + int value = 1; + al_status_t s = al_setvalue_parameter_plugin("param", INTEGER_DATA, 0, + nullptr, &value, kUnregistered); + EXPECT_NE(s.code, 0) + << "setting a parameter (generic variant) on an unregistered plugin " + "must return an error, not crash"; +} + +TEST(KnownDefectsDeath, SetValueGenericUnregisteredPluginCurrentlyCrashes) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + ASSERT_EXIT( + { + int value = 1; + al_setvalue_parameter_plugin("param", INTEGER_DATA, 0, nullptr, + &value, kUnregistered); + }, + ::testing::KilledBySignal(SIGSEGV), ".*") + << "expected the unregistered-plugin null-deref (generic variant, see " + "FUNCTIONALITY_INVENTORY.md:410). If this no longer dies by SIGSEGV, " + "enable KnownDefects.SetValueGenericUnregisteredPluginReturnsError."; +} + } // namespace diff --git a/tests/contract/test_plugin_fixture.cpp b/tests/contract/test_plugin_fixture.cpp new file mode 100644 index 00000000..2b4b18fb --- /dev/null +++ b/tests/contract/test_plugin_fixture.cpp @@ -0,0 +1,105 @@ +// Minimal in-repo plugin, built as a loadable `_plugin.so` for the +// plugin-lifecycle contract tests (issue #7, TEST_STRATEGY.md step 5). +// +// This is the *fixture/harness* half of the suite (the only place classic +// red-green applies): a real, deterministic plugin the C ABI can actually +// `dlopen`, so the register / bind / unbind / unregister state machine and the +// `al_setvalue_*_parameter_plugin` happy paths can be exercised end to end +// rather than only through their failure modes. +// +// It implements the whole `access_layer_plugin` interface (all pure virtuals of +// access_layer_base_plugin + provenance_plugin_feature + readback_plugin_feature) +// so `create()` returns a fully-concrete object, and exports the C +// `create()`/`destroy()` factory symbols the framework resolves via `dlsym` +// (src/al_lowlevel.cpp:360-377). It links no libal symbols — every plugin base +// class is header-only — so the module is self-contained and needs no rpath. +// +// setParameter appends a machine-readable line to the file named by the +// AL_CONTRACT_PLUGIN_LOG env var (when set), so a test can prove a value passed +// through the C ABI actually reached the plugin, not merely that the call +// returned success. + +#include "access_layer_plugin.h" + +#include +#include +#include + +namespace { + +class ContractTestPlugin : public access_layer_plugin { +public: + // --- the one method the setvalue_* ABI calls reach --------------------- + void setParameter(const char* parameter_name, int datatype, int dim, + int* /*size*/, void* data) override { + const char* log_path = std::getenv("AL_CONTRACT_PLUGIN_LOG"); + if (log_path == nullptr) { + return; + } + std::FILE* f = std::fopen(log_path, "a"); + if (f == nullptr) { + return; + } + // Format: PARAM|||| + // The scalar value is decoded for the two convenience variants so the + // test can assert the exact value that crossed the ABI boundary. + if (dim == 0 && data != nullptr && datatype == INTEGER_DATA) { + std::fprintf(f, "PARAM|%s|%d|%d|%d\n", parameter_name, datatype, dim, + *static_cast(data)); + } else if (dim == 0 && data != nullptr && datatype == DOUBLE_DATA) { + std::fprintf(f, "PARAM|%s|%d|%d|%g\n", parameter_name, datatype, dim, + *static_cast(data)); + } else { + std::fprintf(f, "PARAM|%s|%d|%d|-\n", parameter_name, datatype, dim); + } + std::fclose(f); + } + + // --- remaining access_layer_plugin surface: inert, never invoked by the + // lifecycle tests (they never drive a get/put through the plugin) ----- + void begin_global_action(int, const char*, const char*, int, int) override {} + void begin_slice_action(int, const char*, int, double, int, int) override {} + void begin_arraystruct_action(int, int*, const char*, const char*, + int*) override {} + void end_action(int) override {} + int read_data(int, const char*, const char*, void**, int, int, + int*) override { + return 0; // "no data" per the readData 0/1 convention + } + void write_data(int, const char*, const char*, void*, int, int, + int*) override {} + plugin::OPERATION node_operation(const std::string&) override { + return plugin::PUT_AND_GET; + } + + // --- provenance_plugin_feature ----------------------------------------- + std::string getName() override { return "alcontract"; } + std::string getDescription() override { return "contract-test plugin"; } + std::string getCommit() override { return "0000000"; } + std::string getVersion() override { return "0.0.0"; } + std::string getRepository() override { return "in-repo"; } + std::string getParameters() override { return ""; } + + // --- readback_plugin_feature ------------------------------------------- + std::string getReadbackName(const std::string&, int*) override { + return ""; + } + std::string getReadbackDescription(const std::string&) override { + return ""; + } + std::string getReadbackCommit(const std::string&) override { return ""; } + std::string getReadbackVersion(const std::string&) override { return ""; } + std::string getReadbackRepository(const std::string&) override { + return ""; + } + std::string getReadbackParameters(const std::string&) override { + return ""; + } +}; + +} // namespace + +// The C factory symbols the framework resolves after dlopen (create_t/destroy_t +// in access_layer_base_plugin.h). +extern "C" access_layer_base_plugin* create() { return new ContractTestPlugin(); } +extern "C" void destroy(access_layer_base_plugin* p) { delete p; } diff --git a/tests/contract/test_plugins.cpp b/tests/contract/test_plugins.cpp new file mode 100644 index 00000000..50a92bac --- /dev/null +++ b/tests/contract/test_plugins.cpp @@ -0,0 +1,381 @@ +// Plugin management contract tests — User Cluster 3 +// (FUNCTIONALITY_INVENTORY.md:318-430, TEST_STRATEGY.md step 5). +// +// The register / bind / unbind / unregister state machine and the +// al_setvalue_*_parameter_plugin calls, driven through the public C ABI +// (decision D1). Every behavior asserted here was first characterized against +// the built library, then classified once per decision D2: +// * intended contract -> asserted directly (green) +// * genuine defect -> correct behavior asserted, DISABLED_ (xfail), +// paired with a current-behavior tripwire +// +// The crash-class null-deref defects (setvalue on an unregistered name) are +// death tests and live with the other death tests in test_known_defects.cpp. +// Two defects that need a real, registerable plugin live here: the +// registered-but-never-bound plugin left un-destroyed on unregister, and the +// dlopen-failure swallowed by the NDEBUG-stripped assert. +// +// Fixture: a real plugin is dlopen-ed from AL_CONTRACT_PLUGIN_DIR (the +// build-time location of alcontract_plugin.so, from test_plugin_fixture.cpp), +// with IMAS_AL_ENABLE_PLUGINS=TRUE. The plugin registry is process-global and +// static; SetUp/TearDown force a clean slate so the suite is order-independent +// whether run per-case under ctest or as one direct ./contract_tests process. + +#ifndef _WIN32 // plugins framework is dlopen/dlfcn-based (POSIX) + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include + +// Both are always supplied by CMake; a silent fallback could drift from the +// build's real values, so require both explicitly. +#ifndef AL_CONTRACT_PLUGIN_DIR +#error "AL_CONTRACT_PLUGIN_DIR must be defined by CMake (dir holding alcontract_plugin.so)" +#endif +#ifndef AL_CONTRACT_PLUGIN_NAME +#error "AL_CONTRACT_PLUGIN_NAME must be defined by CMake (the registerable plugin name)" +#endif + +namespace { + +using ::al_contract::IsOk; + +constexpr const char* kPlugin = AL_CONTRACT_PLUGIN_NAME; + +// A plugin name that is never backed by a .so and never registered. +constexpr const char* kNeverRegistered = al_contract::kUnregisteredPluginName; + +bool is_registered(const char* name) { + bool r = false; + al_status_t s = al_is_plugin_registered(name, &r); + return s.code == 0 && r; +} + +// Force `name` out of the process-global registry so each test starts clean. +// +// A plain al_unregister_plugin is not enough: the very defect this suite pins is +// that unregister only destroys a plugin that is *bound* to a path. So we bind +// it to a throwaway path first (which makes unregister take its destroy+erase +// path), then unregister. Only touches plugins whose entry has a live instance +// (the contract-test plugin always does); a half-registered entry from a failed +// dlopen is left alone, which is fine — no test reuses such a name. +void force_unregister(const char* name) { + bool r = false; + if (al_is_plugin_registered(name, &r).code != 0 || !r) { + return; + } + al_bind_plugin("alcontract_cleanup/node", name); + al_unregister_plugin(name); +} + +class PluginTest : public ::testing::Test { +protected: + void SetUp() override { + setenv("IMAS_AL_ENABLE_PLUGINS", "TRUE", 1); + setenv("IMAS_AL_PLUGINS", AL_CONTRACT_PLUGIN_DIR, 1); + force_unregister(kPlugin); + } + void TearDown() override { + // Runs while the framework is still enabled so force_unregister works, + // then clears the env so non-plugin cases in the same process are + // unaffected (the read/write hot path checks IMAS_AL_ENABLE_PLUGINS). + setenv("IMAS_AL_ENABLE_PLUGINS", "TRUE", 1); + setenv("IMAS_AL_PLUGINS", AL_CONTRACT_PLUGIN_DIR, 1); + force_unregister(kPlugin); + unsetenv("AL_CONTRACT_PLUGIN_LOG"); + unsetenv("IMAS_AL_ENABLE_PLUGINS"); + unsetenv("IMAS_AL_PLUGINS"); + } +}; + +// =========================================================================== +// register / unregister lifecycle + is_plugin_registered +// =========================================================================== + +TEST_F(PluginTest, RegisterMakesPluginRegistered) { + ASSERT_FALSE(is_registered(kPlugin)) << "SetUp should leave a clean slate"; + AL_ASSERT_OK(al_register_plugin(kPlugin)); + EXPECT_TRUE(is_registered(kPlugin)); +} + +TEST_F(PluginTest, RegisterTwiceReturnsError) { + AL_ASSERT_OK(al_register_plugin(kPlugin)); + al_status_t s = al_register_plugin(kPlugin); + EXPECT_NE(s.code, 0) + << "registering an already-registered name must error, not silently " + "re-register (FUNCTIONALITY_INVENTORY.md:345-346)"; +} + +TEST_F(PluginTest, IsPluginRegisteredIsFalseForNeverRegisteredName) { + bool r = true; + al_status_t s = al_is_plugin_registered(kNeverRegistered, &r); + AL_EXPECT_OK(s); + EXPECT_FALSE(r); +} + +TEST_F(PluginTest, UnregisterNeverRegisteredNameReturnsError) { + al_status_t s = al_unregister_plugin(kNeverRegistered); + EXPECT_NE(s.code, 0) + << "unregistering a name that was never registered must error " + "(FUNCTIONALITY_INVENTORY.md:345-346)"; +} + +// A *bound* plugin IS properly destroyed on unregister — the contrast that +// makes the never-bound quirk (below) a genuine defect rather than the spec. +TEST_F(PluginTest, UnregisterBoundPluginDestroysIt) { + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK(al_bind_plugin("equilibrium/time", kPlugin)); + AL_ASSERT_OK(al_unregister_plugin(kPlugin)); + EXPECT_FALSE(is_registered(kPlugin)) + << "a plugin bound to a path must be destroyed and removed on " + "unregister"; +} + +// =========================================================================== +// bind / unbind +// =========================================================================== + +TEST_F(PluginTest, BindRegisteredPluginSucceeds) { + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_EXPECT_OK(al_bind_plugin("equilibrium/time", kPlugin)); +} + +TEST_F(PluginTest, BindUnregisteredPluginReturnsError) { + // Deliberately not registered. + al_status_t s = al_bind_plugin("equilibrium/time", kNeverRegistered); + EXPECT_NE(s.code, 0) + << "binding a plugin that was never registered must error " + "(src/al_lowlevel.cpp:193-197)"; +} + +TEST_F(PluginTest, DoubleBindSamePathReturnsError) { + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK(al_bind_plugin("equilibrium/time", kPlugin)); + al_status_t s = al_bind_plugin("equilibrium/time", kPlugin); + EXPECT_NE(s.code, 0) + << "binding the same plugin to the same path twice must throw " + "(FUNCTIONALITY_INVENTORY.md:373-374, src/al_lowlevel.cpp:269-273)"; +} + +// Unbinding a path nothing is bound to is a documented silent no-op (returns +// success), not an error (FUNCTIONALITY_INVENTORY.md:374-376, +// src/al_lowlevel.cpp:302-305). Note: al_unbind_plugin does not even require +// the plugin to be registered — it operates on the bound-path map directly. +TEST_F(PluginTest, UnbindNeverBoundPathIsSilentNoOp) { + AL_EXPECT_OK(al_unbind_plugin("equilibrium/time", kPlugin)); +} + +// =========================================================================== +// al_setvalue_*_parameter_plugin — happy paths on a REGISTERED plugin +// (the unregistered-name crashes are death tests in test_known_defects.cpp) +// =========================================================================== + +// Reads the fixture plugin's setParameter log (see test_plugin_fixture.cpp). +std::string read_plugin_log(const std::filesystem::path& p) { + std::ifstream in(p); + std::stringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +// A per-test path for the fixture plugin's setParameter log, wired via the +// AL_CONTRACT_PLUGIN_LOG env var and RAII-cleaned. `tag` keeps concurrent cases +// from colliding on the same file. +class PluginLog { +public: + explicit PluginLog(const char* tag) + : path_(std::filesystem::temp_directory_path() / + ("al_contract_pluginlog_" + std::to_string(::getpid()) + "_" + + tag + ".txt")) { + std::filesystem::remove(path_); + setenv("AL_CONTRACT_PLUGIN_LOG", path_.string().c_str(), 1); + } + ~PluginLog() { std::filesystem::remove(path_); } + std::string contents() const { return read_plugin_log(path_); } + +private: + std::filesystem::path path_; +}; + +// Proves a parameter value crossed the C ABI and reached the plugin's +// setParameter, by matching the log line the fixture emits: +// PARAM|||| +void expect_param_logged(const PluginLog& log, const std::string& name, + int datatype, const std::string& value) { + const std::string logged = log.contents(); + EXPECT_NE(logged.find("PARAM|" + name + "|" + std::to_string(datatype) + + "|0|" + value), + std::string::npos) + << "plugin log did not record parameter '" << name << "'; got: [" + << logged << "]"; +} + +TEST_F(PluginTest, SetValueIntScalarOnRegisteredPluginReachesPlugin) { + PluginLog log("int"); + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK(al_setvalue_int_scalar_parameter_plugin("gain", 42, kPlugin)); + expect_param_logged(log, "gain", INTEGER_DATA, "42"); +} + +TEST_F(PluginTest, SetValueDoubleScalarOnRegisteredPluginReachesPlugin) { + PluginLog log("dbl"); + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK( + al_setvalue_double_scalar_parameter_plugin("scale", 1.5, kPlugin)); + expect_param_logged(log, "scale", DOUBLE_DATA, "1.5"); +} + +// The generic variant carries datatype/dim/size/data explicitly; assert the +// value reaches the plugin (not merely that the call returns OK), matching the +// strength of the int/double variants above. +TEST_F(PluginTest, SetValueGenericOnRegisteredPluginReachesPlugin) { + PluginLog log("generic"); + AL_ASSERT_OK(al_register_plugin(kPlugin)); + int value = 7; + AL_ASSERT_OK(al_setvalue_parameter_plugin("mode", INTEGER_DATA, 0, nullptr, + &value, kPlugin)); + expect_param_logged(log, "mode", INTEGER_DATA, "7"); +} + +// =========================================================================== +// framework-gating: with IMAS_AL_ENABLE_PLUGINS unset the calls must error +// (src/al_lowlevel.cpp:116-119), not proceed. +// =========================================================================== + +TEST_F(PluginTest, RegisterWithFrameworkDisabledReturnsError) { + unsetenv("IMAS_AL_ENABLE_PLUGINS"); + al_status_t s = al_register_plugin(kPlugin); + EXPECT_NE(s.code, 0); +} + +TEST_F(PluginTest, IsPluginRegisteredWithFrameworkDisabledReturnsError) { + unsetenv("IMAS_AL_ENABLE_PLUGINS"); + bool r = false; + al_status_t s = al_is_plugin_registered(kPlugin, &r); + EXPECT_NE(s.code, 0); +} + +// =========================================================================== +// DEFECT (D2): registered-but-never-bound plugin left un-destroyed on +// unregister. FUNCTIONALITY_INVENTORY.md:347-354, src/al_lowlevel.cpp:382-431: +// unregisterPlugin's destroy()+erase only run inside the boundPlugins loop, so +// a plugin that was never bound survives unregister (and re-registering it then +// throws "already registered"). +// =========================================================================== + +// CORRECT-CONTRACT test, expected-fail (DISABLED_). The day unregister is fixed +// to destroy unconditionally, drop the DISABLED_ prefix. +TEST_F(PluginTest, DISABLED_UnregisterNeverBoundPluginDestroysIt) { + AL_ASSERT_OK(al_register_plugin(kPlugin)); + // Intentionally never bound. + AL_ASSERT_OK(al_unregister_plugin(kPlugin)); + EXPECT_FALSE(is_registered(kPlugin)) + << "correct contract: unregister must destroy and remove the plugin " + "even when it was never bound"; +} + +// CURRENT-BEHAVIOR tripwire: pins that the never-bound plugin stays registered +// after unregister. If this flips (is_registered becomes false), the defect was +// fixed — enable DISABLED_UnregisterNeverBoundPluginDestroysIt above. +TEST_F(PluginTest, UnregisterNeverBoundPluginLeavesItRegistered_CurrentBehavior) { + AL_ASSERT_OK(al_register_plugin(kPlugin)); + // Intentionally never bound. + AL_ASSERT_OK(al_unregister_plugin(kPlugin)) + << "unregister currently reports success even though it destroys " + "nothing"; + EXPECT_TRUE(is_registered(kPlugin)) + << "tripwire: a never-bound plugin is currently left in the registry " + "on unregister (FUNCTIONALITY_INVENTORY.md:347-354). If this fails, " + "the defect was fixed — enable " + "PluginTest.UnregisterNeverBoundPluginDestroysIt."; +} + +// =========================================================================== +// DEFECT (D2): dlopen failure in registerPlugin is guarded only by an assert. +// FUNCTIONALITY_INVENTORY.md:355-360, src/al_lowlevel.cpp:350-357: a failed +// dlopen() is printf'd and asserted; under -DNDEBUG (this build: +// RelWithDebInfo/Release) the assert is stripped, so the null handle flows on. +// +// Empirically characterized on this platform: the null handle reaches dlsym, +// whose own null-check throws — so registerPlugin returns LOWLEVEL_ERR with the +// *misleading* "Cannot load symbol create" message rather than reporting the +// dlopen failure, and does NOT crash. (In an assert-enabled build the stripped +// assert would instead fire -> SIGABRT; that death only exists there, which is +// why this pair is a plain xfail, not a death test.) +// =========================================================================== + +// Points IMAS_AL_PLUGINS at a temp dir holding a garbage _plugin.so that +// exists (so registerPlugin gets past its boost::filesystem::exists check) but +// is not a loadable shared library, then registers `name`. Returns the status. +al_status_t register_unloadable(const std::filesystem::path& dir, + const char* name) { + namespace fs = std::filesystem; + fs::create_directories(dir); + const fs::path so = dir / (std::string(name) + "_plugin.so"); + { + std::ofstream out(so, std::ios::binary | std::ios::trunc); + out << "this is deliberately not a valid shared library\n"; + } + setenv("IMAS_AL_PLUGINS", dir.string().c_str(), 1); + return al_register_plugin(name); +} + +// Both tests key on whether the returned status *attributes the dlopen +// failure*. Correct handling would surface the dlerror (message mentions +// "dlopen"); the current swallowed-assert path does not — it reports the +// unrelated downstream symbol miss instead. Keying on "dlopen" presence is +// robust to the exact downstream text (which depends on dlsym's null-handle +// diagnostic, and on no global "create" symbol existing). +bool status_reports_dlopen(const al_status_t& s) { + return s.message != nullptr && + std::string(s.message).find("dlopen") != std::string::npos; +} + +// CORRECT-CONTRACT test, expected-fail (DISABLED_): the error must attribute the +// dlopen failure (surface the dlerror), not a downstream symptom. +TEST_F(PluginTest, DISABLED_RegisterUnloadableSharedLibReportsDlopenFailure) { + namespace fs = std::filesystem; + const fs::path dir = fs::temp_directory_path() / + ("al_contract_badplugin_" + + std::to_string(::getpid()) + "_a"); + al_status_t s = register_unloadable(dir, "unloadable_a"); + EXPECT_NE(s.code, 0); + EXPECT_TRUE(status_reports_dlopen(s)) + << "correct contract: report the dlopen failure (dlerror). Got: " + << (s.message ? s.message : "(null)"); + fs::remove_all(dir); +} + +// CURRENT-BEHAVIOR tripwire: pins that the returned status does NOT mention the +// dlopen failure — the NDEBUG-stripped assert lets the null handle flow past +// dlopen, so the failure is swallowed and only a downstream symptom is reported. +// If the status starts naming dlopen, the handling was fixed — enable the +// DISABLED_ test above. +TEST_F(PluginTest, RegisterUnloadableSharedLibSwallowsAssert_CurrentBehavior) { + namespace fs = std::filesystem; + const fs::path dir = fs::temp_directory_path() / + ("al_contract_badplugin_" + + std::to_string(::getpid()) + "_b"); + al_status_t s = register_unloadable(dir, "unloadable_b"); + EXPECT_NE(s.code, 0) << "on this NDEBUG build it returns an error, not a crash"; + EXPECT_FALSE(status_reports_dlopen(s)) + << "tripwire: the dlopen failure is currently swallowed by the " + "NDEBUG-stripped assert, so the status does not attribute it " + "(FUNCTIONALITY_INVENTORY.md:355-360). If this fails, the dlopen " + "handling was fixed — enable " + "PluginTest.RegisterUnloadableSharedLibReportsDlopenFailure."; + fs::remove_all(dir); +} + +} // namespace + +#endif // !_WIN32 From 9a015bb7e5bf3935624e22b54c1ac37ade212f7d Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:06:27 +0200 Subject: [PATCH 13/91] Add structured-data contract tests: AOS, delete, occurrences, lifecycle (issue #4) Characterizes existing C-ABI behavior for arrays-of-structures (top-level + nested write/iterate/read), al_delete_data at signal/structure/DATAOBJECT-root granularity, al_get_occurrences, and the remaining pulse-lifecycle detail (al_begin_dataentry_action modes, al_context_info, al_get_backendID, al_close_pulse CLOSE/ERASE) across the always-on backend tier. Adds a deterministic equilibrium-seed generator (scalar + timebase-carrying 2-D array + constraints AOS, oracle = content hash, no committed blobs) to exercise realistic AOS/timebase/HDF5-tensorization shapes. Empirically validated against the built library (not just source reading) per TEST_STRATEGY.md's TDD discipline, which surfaced several defects invisible to static analysis: ASCII's AOS read always reports size 0 (its READ_OP setup drains the stream into a lookup map before the AOS-size cursor ever runs), HDF5/Memory/ASCII/Flexbuffers each implement a disjoint, non-overlapping slice of the three delete granularities, ERASE_PULSE is a no-op-equivalent to CLOSE_PULSE on every always-on backend, ASCII's CREATE_PULSE silently overwrites instead of refusing, and al_get_backendID accepts a wrong-type context via an unchecked static_cast. Each genuine defect is pinned per decision D2: a DISABLED_ correct-contract test paired with an always-run current-behavior tripwire, never frozen as if it were the spec. Fills the AOS / delete / occurrences / lifecycle rows in TRACEABILITY.md. Co-Authored-By: Claude Sonnet 5 --- tests/contract/CMakeLists.txt | 2 + tests/contract/TRACEABILITY.md | 75 ++- tests/contract/equilibrium_seed.h | 222 ++++++++ tests/contract/test_pulse_lifecycle.cpp | 394 +++++++++++++ tests/contract/test_structured_data.cpp | 708 ++++++++++++++++++++++++ 5 files changed, 1377 insertions(+), 24 deletions(-) create mode 100644 tests/contract/equilibrium_seed.h create mode 100644 tests/contract/test_pulse_lifecycle.cpp create mode 100644 tests/contract/test_structured_data.cpp diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 2467b288..6e883580 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -67,6 +67,8 @@ add_executable(contract_tests test_version_sentinel.cpp test_capability_gated.cpp test_known_defects.cpp + test_structured_data.cpp + test_pulse_lifecycle.cpp ) target_link_libraries(contract_tests PRIVATE al GTest::gtest GTest::gtest_main) diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 2e531420..22aad8d0 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -39,23 +39,22 @@ carry a `/` (or `/__r`) suffix per instance. > needs CMake ≥ 3.22 (`gtest_discover_tests(TEST_FILTER …)`); on the declared > 3.21 floor it falls back to the single `contract` label. -## Coverage status — as of the four implemented issues (#2 scaffold, #5 -## introspection, #3 round-trip matrix, #6 capability-gated) +## Coverage status — as of the five implemented issues (#2 scaffold, #5 +## introspection, #3 round-trip matrix, #6 capability-gated, #4 structured data) -The suite covers the **data-path and introspection surface** end to end and -pins the four defects those issues surfaced. It does **not yet** cover the -pulse-lifecycle detail, arrays-of-structures, deletion, occurrences, or any of -the plugin surface — those are the remaining build-order steps and appear below -as explicit **gap** rows, each tagged with the step that owns it: +The suite covers the **data-path, introspection, and structured-data surface** +end to end and pins the defects those issues surfaced (sixteen xfails, each +with a paired current-behavior tripwire). It does **not yet** cover any of the +plugin surface or the ownership sweep — those are the remaining build-order +steps and appear below as explicit **gap** rows, each tagged with the step +that owns it: -- **Structured data** (TEST_STRATEGY §4 step 2, task doc `2b-structured.md`): - AOS, `al_delete_data`, `al_get_occurrences`, full pulse lifecycle. - **Plugins** (§4 step 5, task doc `5-plugins.md`): all of User Cluster 3 and all of Part 3. - **Ownership sweep** (§4 step 6, task doc `6-ownership-sweep.md`): the caller-frees questions and the remaining audience sweep. -GitHub issue numbers for those three are to be filled in when the issues are +GitHub issue numbers for those two are to be filled in when the issues are opened; until then they are referenced by build-order step + task-doc slug. --- @@ -66,10 +65,13 @@ opened; until then they are referenced by build-order step + task-doc slug. | 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 (`Backends/RoundTrip.*`, `RoundTripMatrix.*`, `VersionSentinel.*`, `CapabilityMatrix.*`). Residual: `OPEN_PULSE`/`FORCE_OPEN_PULSE`/`CREATE_PULSE` modes + error paths untested → step 2 | covered (indirect) | -| `al_close_pulse` closes (`CLOSE_PULSE`) / erases (`ERASE_PULSE`) | :109-116 | ✓ (Memory) | ✓ (HDF5, ASCII) | `CLOSE_PULSE` asserted `code==0` in every suite. Residual: `ERASE_PULSE` untested → step 2 | covered (indirect) | -| `al_context_info` describes a context; **caller frees `*info`** | :118-126 | — | — | — → ownership sweep (step 6) | gap | -| `al_get_backendID` returns the active `BACKEND` for a context | :128-135 | — | — | — → step 2 | gap | +| `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.*` | covered | +| ↳ ASCII's `CREATE_PULSE` has no existence guard — **silently overwrites/truncates an existing pulse** instead of refusing it | :90-107; src/ascii_backend.cpp:107-157 | ✓ | — | `ModeKnownDefects.DISABLED_AsciiCreatePulseFailsWhenAlreadyExists` + tripwire `ModeKnownDefects.AsciiCreatePulseCurrentlyOverwritesSilently` | **xfail** | +| `al_close_pulse` closes (`CLOSE_PULSE`) / erases (`ERASE_PULSE`) | :109-116 | ✓ (Memory) | ✓ (HDF5, ASCII) | `CLOSE_PULSE` asserted `code==0` in every suite | covered (indirect) | +| ↳ `ERASE_PULSE` is not implemented by any always-on backend — **behaves identically to `CLOSE_PULSE`** (none of HDF5/ASCII/Memory's `closePulse` reference their `mode` parameter); a later plain `OPEN_PULSE` on the "erased" pulse still succeeds | :109-116; src/hdf5/{hdf5_reader,hdf5_writer}.cpp closePulse, src/ascii_backend.cpp:165-169, src/memory_backend.h:505-509 | ✓ (Memory) | ✓ (HDF5, ASCII) | `ErasePulseKnownDefects.DISABLED_{Hdf5,Ascii,Memory}EraseMakesPulseUnopenable` + tripwires `ErasePulseKnownDefects.{Hdf5,Ascii,Memory}EraseCurrentlyLeavesPulseOpenable` | **xfail** | +| `al_context_info` describes a context (pulse/operation/arraystruct); **caller frees `*info`** (verified `malloc`-based) | :118-126 | ✓ | ✓ | `ContextInfo.{NullContextReturnsLiteralString, PulseContextDescribesItsUri, OperationContextDescribesDataobjectAndAccessmode, ArraystructContextDescribesPathAndIndex}` (frees via `free()`) | covered | +| `al_get_backendID` returns the active `BACKEND` for a context | :128-135 | — | ✓ | `GetBackendId.ReturnsTheBackendUsedToOpen` (HDF5 only) | covered | +| ↳ no context-type check — a non-pulse context is `static_cast` (not `dynamic_cast`) to `DataEntryContext*` with no validation, so it **silently "succeeds" with an undefined-behavior value** instead of erroring (confirmed empirically: it does not crash) | :128-135; src/al_lowlevel.cpp al_get_backendID | — | ✓ | `GetBackendIdKnownDefects.DISABLED_WrongContextTypeReturnsError` + tripwire `GetBackendIdKnownDefects.WrongContextTypeCurrentlySucceedsViaUnsafeCast` (HDF5 only) | **xfail** | | `al_build_uri_from_legacy_parameters` builds a URI from legacy params | :137-149 | ✓ | ✓ | `al_contract::build_uri` (asserts OK) drives HDF5·Memory·ASCII in all on-disk suites. Caller-frees of `*uri` → ownership sweep (step 6) | covered | | ↳ must also address the always-on FLEXBUFFERS backend — **throws (`getURIBackend` has no case)** | :137-149; src/al_context.cpp:280 | ✓ | — | `RoundTripKnownDefects.DISABLED_BuildUriSupportsFlexbuffers` + tripwire `RoundTripKnownDefects.BuildUriFlexbuffersCurrentlyFails` | **xfail** | @@ -89,18 +91,34 @@ genuine defect (expected-fail). | `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` | 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` | covered | -| `al_begin_arraystruct_action` starts an AOS op (top-level + nested) | :223-233 | — | — | — → structured data (step 2) | gap | +| `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` | 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` | covered | | ↳ Flexbuffers must-refuse column (serializer, not a pulse store): write accepted, read refused, every datatype × shape | :244-263 / D4 | — | ✓ | `…/Flexbuffers_*_r{0..7}` (paired-negative) | covered | | ↳ CHAR > 2-D refused (documented "not implemented") on HDF5·ASCII | :244-263 / D4 | — | ✓ | `…/{HDF5,ASCII}_CHAR_r{3..7}` (paired-negative) | covered | | ↳ CHAR scalar (dim 0) must round-trip — **HDF5 crashes** | :244-263 | — | ✓ | `RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips` + tripwire `RoundTripKnownDefectsDeath.Hdf5CharScalarCurrentlyCrashes` | **xfail** | | ↳ numeric at MAXDIM (rank 7) must round-trip — **ASCII corrupts (DOUBLE/COMPLEX) / aborts (INTEGER)** | :244-263 | — | ✓ | `RoundTripKnownDefects.DISABLED_Ascii{Integer,Double,Complex}MaxdimRoundTrips` + tripwires `RoundTripKnownDefectsDeath.AsciiIntegerMaxdimCurrentlyAborts`, `RoundTripKnownDefects.Ascii{Double,Complex}MaxdimCurrentlyCorrupts` | **xfail** | -| `al_delete_data` at signal / structure / DATAOBJECT-root granularity | :265-272 | — | — | — → structured data (step 2) | gap | -| `al_iterate_over_arraystruct` advances the AOS cursor | :274-282 | — | — | — → structured data (step 2) | gap | -| `al_get_occurrences` lists non-empty occurrences; caller-frees `*occurrences_list` | :284-292 | — | — | — → structured data (step 2); ownership → step 6 | gap | +| `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) | 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` | 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` | covered | +### Equilibrium seed (issue #4 — decision D5) + +The deterministic, in-repo single-version equilibrium-seed generator (a +scalar, a timebase-carrying 2-D array, and a constraints AOS — no committed +binary blobs; oracle = a content hash recomputed from the generator functions +themselves, `tests/contract/equilibrium_seed.h`), round-tripped on the three +backends where AOS content is actually readable back within a session. + +| Capability | Inventory ref | Unit | Integ. | Test(s) | Status | +|---|---|:--:|:--:|---|---| +| Realistic AOS/timebase/HDF5-tensorization shape round-trips exactly | D5 | ✓ (Memory) | ✓ (HDF5, ASCII) | `Backends/EquilibriumSeedMatrix.RoundTripHashMatches/{HDF5,Memory,ASCII}` | covered | + ## Cluster 3 — Plugin management (`FUNCTIONALITY_INVENTORY.md:318-430`) Almost entirely deferred to the plugins issue (step 5). The one exception is the @@ -159,9 +177,9 @@ and points at the same test. | **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) | covered | -| **C** `deleteData` | :591-599 | — | — | — → structured data (step 2) | gap | -| **C** `beginArraystructAction` | :601-606 | — | — | — → structured data (step 2) | gap | -| **D** `get_occurrences` | :612-616 | — | — | — → structured data (step 2) | gap | +| **C** `deleteData` | :591-599 | ✓ (Memory) | ✓ (HDF5) | via `al_delete_data` — see Cluster 2 row above and its three xfail rows | covered (indirect) | +| **C** `beginArraystructAction` | :601-606 | ✓ (Memory) | ✓ (HDF5) | via `al_begin_arraystruct_action` — see Cluster 2 row above | covered (indirect) | +| **D** `get_occurrences` | :612-616 | — | ✓ | via `al_get_occurrences` — `Occurrences.*` | 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 | 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 | covered (indirect) | | **E** `initDataInterpolationComponent` (framework-driven) | :644-690 | — | — | no-op where the factory calls it; not separately ABI-observable → step 6 | gap | @@ -186,9 +204,9 @@ the plugins issue has a concrete checklist rather than a blank slate. ## xfail bookkeeping — every xfail now has a paired tripwire The D2 discipline is "correct-contract `DISABLED_` test **plus** a paired -current-behavior tripwire, so the xfail can't rot." As of the S4 fix, all five -xfail rows satisfy it — a fix to any underlying defect turns its tripwire red, -forcing whoever fixed it to enable the paired `DISABLED_` correct-contract test: +current-behavior tripwire, so the xfail can't rot." All sixteen xfail rows +satisfy it — a fix to any underlying defect turns its tripwire red, forcing +whoever fixed it to enable the paired `DISABLED_` correct-contract test: | Defect | Correct-contract (`DISABLED_`) | Current-behavior tripwire | |---|---|---| @@ -199,3 +217,12 @@ forcing whoever fixed it to enable the paired `DISABLED_` correct-contract test: | `build_uri` can't address FLEXBUFFERS | `…DISABLED_BuildUriSupportsFlexbuffers` | `RoundTripKnownDefects.BuildUriFlexbuffersCurrentlyFails` | | slice append doesn't accumulate | `Hdf5SliceAppend.DISABLED_AppendedSlicesAllPersist` | `Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior` | | plugin setvalue unregistered-name crash | `KnownDefects.DISABLED_SetValueIntScalarUnregisteredPluginReturnsError` | `KnownDefectsDeath.SetValueIntScalarUnregisteredPluginCurrentlyCrashes` | +| ASCII `CREATE_PULSE` overwrites instead of refusing | `ModeKnownDefects.DISABLED_AsciiCreatePulseFailsWhenAlreadyExists` | `ModeKnownDefects.AsciiCreatePulseCurrentlyOverwritesSilently` | +| `ERASE_PULSE` == `CLOSE_PULSE` on HDF5 | `ErasePulseKnownDefects.DISABLED_Hdf5EraseMakesPulseUnopenable` | `ErasePulseKnownDefects.Hdf5EraseCurrentlyLeavesPulseOpenable` | +| `ERASE_PULSE` == `CLOSE_PULSE` on ASCII | `…DISABLED_AsciiEraseMakesPulseUnopenable` | `ErasePulseKnownDefects.AsciiEraseCurrentlyLeavesPulseOpenable` | +| `ERASE_PULSE` == `CLOSE_PULSE` on Memory | `…DISABLED_MemoryEraseMakesPulseUnopenable` | `ErasePulseKnownDefects.MemoryEraseCurrentlyLeavesPulseOpenable` | +| `al_get_backendID` accepts a non-pulse context via unchecked `static_cast` | `GetBackendIdKnownDefects.DISABLED_WrongContextTypeReturnsError` | `GetBackendIdKnownDefects.WrongContextTypeCurrentlySucceedsViaUnsafeCast` | +| ASCII AOS read always reports size 0 | `AosKnownDefects.DISABLED_AsciiAosReadReportsWrittenSize` | `AosKnownDefects.AsciiAosReadCurrentlyReportsZero` | +| HDF5 leaf/structure delete wipes the whole occurrence | `DeleteKnownDefects.DISABLED_Hdf5LeafDeleteLeavesSiblingIntact` | `DeleteKnownDefects.Hdf5LeafDeleteCurrentlyWipesWholeOccurrence` | +| Memory root delete is a no-op | `DeleteKnownDefects.DISABLED_MemoryRootDeleteClearsWholeIds` | `DeleteKnownDefects.MemoryRootDeleteCurrentlyDoesNothing` | +| ASCII delete is a no-op at every granularity | `DeleteKnownDefects.DISABLED_AsciiDeleteRemovesLeaf` | `DeleteKnownDefects.AsciiDeleteIsCurrentlyANoOp` | diff --git a/tests/contract/equilibrium_seed.h b/tests/contract/equilibrium_seed.h new file mode 100644 index 00000000..8c3a8d35 --- /dev/null +++ b/tests/contract/equilibrium_seed.h @@ -0,0 +1,222 @@ +// Deterministic single-version equilibrium-seed generator (issue #4). +// +// TEST_STRATEGY.md decision D5: "one realistic single-version equilibrium +// seed — a curated subset (scalar, a profiles_1d array with a timebase, a +// constraints AOS) from a deterministic in-repo generator (generator + hash, +// not committed blobs) — to exercise real AOS/timebase/HDF5-tensorization +// shapes." This header IS the generator: no binary fixture is checked in, the +// shape is produced by code, and the round-trip oracle is a content hash +// rather than a byte-for-byte vector compare (the synthetic-matrix machinery +// in al_contract.h already covers exhaustive byte-exact round trips; this +// seed's job is a *realistic composite shape*, not another datatype sweep). +// +// The three pieces mirror a real equilibrium IDS closely enough to exercise +// each shape class the migration cares about (NORTH_STAR.md's equilibrium +// pilot), while staying opaque strings through the C ABI (decision D1: the +// core attaches no DD semantics to any of these paths): +// - a scalar -> "vacuum_toroidal_field/r0" +// - a timebase-carrying 2-D array -> "time" (1-D) + "profiles_1d/psi" +// (2-D: [time][radial], tensorized on HDF5 the same way a real +// equilibrium/profiles_1d/psi(:,:) would be) +// - an AOS -> "constraints", each element holding +// two scalar fields ("measured", "weight") + +#ifndef AL_CONTRACT_EQUILIBRIUM_SEED_H +#define AL_CONTRACT_EQUILIBRIUM_SEED_H + +#include "al_contract.h" + +#include +#include + +#include +#include +#include + +namespace equilibrium_seed { + +constexpr const char* kIds = "equilibrium"; +constexpr const char* kScalar = "vacuum_toroidal_field/r0"; +constexpr const char* kTimebase = "time"; +constexpr const char* kProfile = "profiles_1d/psi"; +constexpr const char* kAos = "constraints"; +constexpr const char* kMeasured = "measured"; +constexpr const char* kWeight = "weight"; + +constexpr int kTimeLen = 3; // time slices +constexpr int kRadialLen = 4; // radial grid points per slice +constexpr int kNConstraints = 3; // AOS elements + +// --- deterministic content ------------------------------------------------- +// Plain arithmetic progressions, distinct per role so a transposed or +// swapped-field bug is caught by the hash. Kept clear of the write-path +// "absent" sentinels documented in al_contract.h (kEmptyDouble). +inline double scalar_r0() { return 6.2; } + +inline std::vector timebase_values() { + std::vector t; + for (int i = 0; i < kTimeLen; ++i) t.push_back(1.0 + 0.5 * i); + return t; +} + +// Row-major [time][radial], matching the (dim=2, size={kTimeLen,kRadialLen}) +// shape handed to al_write_data. +inline std::vector profile_values() { + std::vector v; + v.reserve(kTimeLen * kRadialLen); + for (int t = 0; t < kTimeLen; ++t) + for (int r = 0; r < kRadialLen; ++r) + v.push_back(10.0 * t + r + 0.25); + return v; +} + +inline double constraint_measured(int i) { return 100.0 + 2.0 * i; } +inline double constraint_weight(int i) { return 0.5 + 0.1 * i; } + +// --- FNV-1a over the seed's full content, in a fixed field order ----------- +// A hash, not a byte-exact compare, is the oracle here (decision D5): it lets +// the read-back side recompute over whatever shapes it actually observed +// (see verify_and_hash below) without re-deriving a parallel comparison +// structure for every field. +inline uint64_t fnv1a(uint64_t h, const void* data, size_t n) { + const unsigned char* p = static_cast(data); + for (size_t i = 0; i < n; ++i) { + h ^= p[i]; + h *= 1099511628211ull; + } + return h; +} +inline uint64_t fnv1a(uint64_t h, double v) { return fnv1a(h, &v, sizeof(v)); } +inline uint64_t fnv1a(uint64_t h, const std::vector& v) { + return v.empty() ? h : fnv1a(h, v.data(), v.size() * sizeof(double)); +} + +constexpr uint64_t kFnvOffsetBasis = 14695981039346656037ull; + +// The expected hash, computed directly from the generator functions above — +// this is the "generator", not a committed golden value: change any generator +// function and this recomputes automatically. +inline uint64_t expected_hash() { + uint64_t h = kFnvOffsetBasis; + h = fnv1a(h, scalar_r0()); + h = fnv1a(h, timebase_values()); + h = fnv1a(h, profile_values()); + for (int i = 0; i < kNConstraints; ++i) { + h = fnv1a(h, constraint_measured(i)); + h = fnv1a(h, constraint_weight(i)); + } + return h; +} + +// --- write the seed into an already-open pulse (its own GLOBAL write) ------ +inline al_status_t write(int pulse_ctx) { + int op = -1; + al_status_t s = al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, &op); + if (s.code != 0) return s; + + s = al_contract::write_data(op, kScalar, {}, {scalar_r0()}); + if (s.code != 0) { al_end_action(op); return s; } + + s = al_contract::write_data(op, kTimebase, {kTimeLen}, + timebase_values()); + if (s.code != 0) { al_end_action(op); return s; } + + { + int size[2] = {kTimeLen, kRadialLen}; + std::vector data = profile_values(); + s = al_write_data(op, kProfile, kTimebase, data.data(), DOUBLE_DATA, 2, + size); + if (s.code != 0) { al_end_action(op); return s; } + } + + int size = kNConstraints; + int aos_ctx = -1; + s = al_begin_arraystruct_action(op, kAos, "", &size, &aos_ctx); + if (s.code != 0) { al_end_action(op); return s; } + for (int i = 0; i < kNConstraints; ++i) { + s = al_contract::write_data(aos_ctx, kMeasured, {}, + {constraint_measured(i)}); + if (s.code != 0) break; + s = al_contract::write_data(aos_ctx, kWeight, {}, + {constraint_weight(i)}); + if (s.code != 0) break; + if (i + 1 < kNConstraints) { + s = al_iterate_over_arraystruct(aos_ctx, 1); + if (s.code != 0) break; + } + } + al_status_t end_aos = al_end_action(aos_ctx); + if (s.code == 0) s = end_aos; + + al_status_t end_op = al_end_action(op); + if (s.code == 0) s = end_op; + return s; +} + +// --- read the seed back and recompute the same hash over what was read ----- +// Separate from write() (rather than assuming shapes) so any shape drift on +// read (wrong rank, wrong AOS size) shows up as a hash mismatch instead of +// being silently reconciled away. +inline al_status_t read_and_hash(int pulse_ctx, uint64_t* out_hash) { + int op = -1; + al_status_t s = al_begin_global_action(pulse_ctx, kIds, "", READ_OP, &op); + if (s.code != 0) return s; + + uint64_t h = kFnvOffsetBasis; + + std::vector shape; + std::vector scalar; + s = al_contract::read_data(op, kScalar, 0, &shape, &scalar); + if (s.code != 0) { al_end_action(op); return s; } + h = fnv1a(h, scalar.at(0)); + + std::vector tb; + s = al_contract::read_data(op, kTimebase, 1, &shape, &tb); + if (s.code != 0) { al_end_action(op); return s; } + h = fnv1a(h, tb); + + { + void* buf = nullptr; + int size[MAXDIM] = {0}; + s = al_read_data(op, kProfile, kTimebase, &buf, DOUBLE_DATA, 2, size); + if (s.code != 0) { al_end_action(op); return s; } + const std::size_t n = + static_cast(size[0]) * static_cast(size[1]); + std::vector profile(static_cast(buf), + static_cast(buf) + n); + free(buf); + h = fnv1a(h, profile); + } + + int size = 0; + int aos_ctx = -1; + s = al_begin_arraystruct_action(op, kAos, "", &size, &aos_ctx); + if (s.code != 0) { al_end_action(op); return s; } + for (int i = 0; i < size; ++i) { + std::vector measured, weight; + s = al_contract::read_data(aos_ctx, kMeasured, 0, &shape, + &measured); + if (s.code != 0) break; + s = al_contract::read_data(aos_ctx, kWeight, 0, &shape, + &weight); + if (s.code != 0) break; + h = fnv1a(h, measured.at(0)); + h = fnv1a(h, weight.at(0)); + if (i + 1 < size) { + s = al_iterate_over_arraystruct(aos_ctx, 1); + if (s.code != 0) break; + } + } + al_status_t end_aos = al_end_action(aos_ctx); + if (s.code == 0) s = end_aos; + + al_status_t end_op = al_end_action(op); + if (s.code == 0) s = end_op; + + if (s.code == 0) *out_hash = h; + return s; +} + +} // namespace equilibrium_seed + +#endif // AL_CONTRACT_EQUILIBRIUM_SEED_H diff --git a/tests/contract/test_pulse_lifecycle.cpp b/tests/contract/test_pulse_lifecycle.cpp new file mode 100644 index 00000000..1b95f56d --- /dev/null +++ b/tests/contract/test_pulse_lifecycle.cpp @@ -0,0 +1,394 @@ +// Pulse-lifecycle detail contract tests (issue #4 / TEST_STRATEGY.md §4 step 2). +// +// The scaffold and matrix suites only ever drive FORCE_CREATE_PULSE + CLOSE_PULSE +// (TRACEABILITY.md marks the other modes / al_context_info / al_get_backendID as +// gaps). This file closes those: al_begin_dataentry_action's four modes, +// al_context_info's format and ownership, al_get_backendID's happy path (and a +// real null-deref defect on the wrong context type), and al_close_pulse's +// CLOSE_PULSE vs ERASE_PULSE distinction. +// +// Flexbuffers is excluded from the mode-matrix and erase tests: it is a pure +// in-process serialize/deserialize buffer with no persistent pulse state, so it +// does not distinguish among the four open modes at all (src/flexbuffers_backend.cpp +// openPulse only branches on read-vs-write) — the questions this file asks +// ("does OPEN_PULSE fail when absent", "does erase remove persisted state") +// don't apply to it. + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include +#include + +using al_contract::BackendCase; +using al_contract::PulseId; + +namespace { + +constexpr const char* kIds = "magnetics"; + +const BackendCase kLifecycleBackends[] = { + {HDF5_BACKEND, "HDF5", /*on_disk=*/true}, + {MEMORY_BACKEND, "Memory", /*on_disk=*/false}, + {ASCII_BACKEND, "ASCII", /*on_disk=*/true}, +}; + +// =========================================================================== +// al_begin_dataentry_action mode matrix. +// =========================================================================== +struct ModeBackendCase { + int id; + const char* name; + bool on_disk; + // Whether CREATE_PULSE genuinely refuses to overwrite an existing pulse + // (true for HDF5/Memory, verified by reading HDF5Utils::createPulse and + // MemoryBackend::openPulse; false for ASCII, a real defect — see + // ModeKnownDefects below). + bool create_guards_existing; +}; + +inline void PrintTo(const ModeBackendCase& b, std::ostream* os) { *os << b.name; } + +const ModeBackendCase kModeBackends[] = { + {HDF5_BACKEND, "HDF5", /*on_disk=*/true, /*create_guards_existing=*/true}, + {MEMORY_BACKEND, "Memory", /*on_disk=*/false, /*create_guards_existing=*/true}, + {ASCII_BACKEND, "ASCII", /*on_disk=*/true, /*create_guards_existing=*/false}, +}; + +class DataEntryModes : public ::testing::TestWithParam { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; + + // precreate_tree=false leaves nothing on disk at all — the genuinely + // "absent" case. ASCII's OPEN_PULSE only checks that the *directory* + // exists (ascii_backend.cpp:123-127), never that a pulse was actually + // created there, so pre-creating the legacy tree (as every other test in + // this fixture does, matching al_contract::TempBase's own documented + // purpose "for FORCE_CREATE_PULSE") would make OPEN_PULSE spuriously + // succeed on ASCII against an empty directory. + std::string uri(bool precreate_tree = true) const { + const ModeBackendCase& b = GetParam(); + if (precreate_tree && b.on_disk) base_.make_legacy_tree(pulse_); + return al_contract::build_uri(b.id, base_.str(), pulse_); + } +}; + +TEST_P(DataEntryModes, OpenPulseFailsWhenAbsent) { + const std::string u = uri(/*precreate_tree=*/false); + ASSERT_FALSE(u.empty()); + int pctx = -1; + al_status_t s = al_begin_dataentry_action(u.c_str(), OPEN_PULSE, &pctx); + EXPECT_NE(s.code, 0) << "OPEN_PULSE must fail when no pulse exists yet"; + if (s.code == 0) al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_P(DataEntryModes, ForceOpenPulseCreatesWhenAbsent) { + const std::string u = uri(); + ASSERT_FALSE(u.empty()); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(u.c_str(), FORCE_OPEN_PULSE, &pctx)); + EXPECT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); +} + +TEST_P(DataEntryModes, OpenPulseSucceedsOncePresent) { + const std::string u = uri(); + ASSERT_FALSE(u.empty()); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx)); + EXPECT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); + + int pctx2 = -1; + AL_EXPECT_OK(al_begin_dataentry_action(u.c_str(), OPEN_PULSE, &pctx2)); + EXPECT_EQ(al_close_pulse(pctx2, CLOSE_PULSE).code, 0); +} + +TEST_P(DataEntryModes, CreatePulseFailsWhenAlreadyExists) { + const ModeBackendCase& b = GetParam(); + if (!b.create_guards_existing) { + GTEST_SKIP() << "known defect for " << b.name + << " — see ModeKnownDefects.*"; + } + const std::string u = uri(); + ASSERT_FALSE(u.empty()); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx)); + EXPECT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); + + int pctx2 = -1; + al_status_t s = al_begin_dataentry_action(u.c_str(), CREATE_PULSE, &pctx2); + EXPECT_NE(s.code, 0) << "CREATE_PULSE must refuse to overwrite an existing pulse"; + if (s.code == 0) al_close_pulse(pctx2, CLOSE_PULSE); +} + +INSTANTIATE_TEST_SUITE_P( + Backends, DataEntryModes, ::testing::ValuesIn(kModeBackends), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.name); + }); + +// --- ASCII: CREATE_PULSE silently truncates instead of refusing ------------ +TEST(ModeKnownDefects, DISABLED_AsciiCreatePulseFailsWhenAlreadyExists) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string u = al_contract::build_uri(ASCII_BACKEND, base.str(), pulse); + int pctx = -1; + ASSERT_EQ(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + ASSERT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); + + int pctx2 = -1; + al_status_t s = al_begin_dataentry_action(u.c_str(), CREATE_PULSE, &pctx2); + EXPECT_NE(s.code, 0) + << "CREATE_PULSE must refuse an already-existing pulse (documented: " + "\"create a new pulse, do not overwrite if already exist\")"; + if (s.code == 0) al_close_pulse(pctx2, CLOSE_PULSE); +} + +TEST(ModeKnownDefects, AsciiCreatePulseCurrentlyOverwritesSilently) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string u = al_contract::build_uri(ASCII_BACKEND, base.str(), pulse); + int pctx = -1; + ASSERT_EQ(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + int op = -1; + al_begin_global_action(pctx, kIds, "", WRITE_OP, &op); + al_contract::write_data(op, "leaf", {}, {11.0}); + al_end_action(op); + ASSERT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); + + int pctx2 = -1; + al_status_t s = al_begin_dataentry_action(u.c_str(), CREATE_PULSE, &pctx2); + EXPECT_EQ(s.code, 0) + << "AsciiBackend::openPulse now guards CREATE_PULSE against an " + "existing pulse — enable " + "ModeKnownDefects.DISABLED_AsciiCreatePulseFailsWhenAlreadyExists " + "(ascii_backend.cpp:107-157 has no existence check for CREATE_PULSE)"; + if (s.code == 0) al_close_pulse(pctx2, CLOSE_PULSE); +} + +// =========================================================================== +// al_context_info: format per context type, NULL-context special case, +// malloc ownership (src/al_lowlevel.cpp:828-832 -> free() is correct). +// =========================================================================== +class ContextInfo : public ::testing::Test { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; +}; + +TEST_F(ContextInfo, NullContextReturnsLiteralString) { + char* info = nullptr; + AL_ASSERT_OK(al_context_info(0, &info)); + ASSERT_NE(info, nullptr); + EXPECT_STREQ(info, "NULL context"); + free(info); +} + +TEST_F(ContextInfo, PulseContextDescribesItsUri) { + base_.make_legacy_tree(pulse_); + const std::string u = al_contract::build_uri(HDF5_BACKEND, base_.str(), pulse_); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx)); + + char* info = nullptr; + AL_ASSERT_OK(al_context_info(pctx, &info)); + ASSERT_NE(info, nullptr); + EXPECT_NE(std::string(info).find("uri"), std::string::npos) + << "pulse context description must mention its uri: " << info; + free(info); + + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_F(ContextInfo, OperationContextDescribesDataobjectAndAccessmode) { + base_.make_legacy_tree(pulse_); + const std::string u = al_contract::build_uri(HDF5_BACKEND, base_.str(), pulse_); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + + char* info = nullptr; + AL_ASSERT_OK(al_context_info(op, &info)); + ASSERT_NE(info, nullptr); + const std::string s(info); + EXPECT_NE(s.find("dataobjectname"), std::string::npos) << s; + EXPECT_NE(s.find("accessmode"), std::string::npos) << s; + EXPECT_NE(s.find(kIds), std::string::npos) << s; + free(info); + + al_end_action(op); + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_F(ContextInfo, ArraystructContextDescribesPathAndIndex) { + base_.make_legacy_tree(pulse_); + const std::string u = al_contract::build_uri(HDF5_BACKEND, base_.str(), pulse_); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + int size = 1; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, "elements", "", &size, &aos)); + + char* info = nullptr; + AL_ASSERT_OK(al_context_info(aos, &info)); + ASSERT_NE(info, nullptr); + const std::string s(info); + EXPECT_NE(s.find("path"), std::string::npos) << s; + EXPECT_NE(s.find("index"), std::string::npos) << s; + free(info); + + al_end_action(aos); + al_end_action(op); + al_close_pulse(pctx, CLOSE_PULSE); +} + +// =========================================================================== +// al_get_backendID. +// =========================================================================== +TEST(GetBackendId, ReturnsTheBackendUsedToOpen) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string u = al_contract::build_uri(HDF5_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx)); + + int beid = -1; + AL_ASSERT_OK(al_get_backendID(pctx, &beid)); + EXPECT_EQ(beid, HDF5_BACKEND); + + al_close_pulse(pctx, CLOSE_PULSE); +} + +// --- known defect: al_get_backendID silently accepts a non-pulse context --- +// al_lowlevel.cpp's al_get_backendID reinterprets ctxID's context via +// `static_cast(lle.context)` — NOT a dynamic_cast, and with +// no type check at all (unlike al_close_pulse / al_begin_dataentry_action / +// al_delete_data, which all dynamic_cast and explicitly guard against a null +// result). Confirmed empirically (a debug build against this exact call): +// passing an OperationContext does NOT crash — the cast is well-formed +// pointer arithmetic, so it silently reads whatever bytes sit at +// DataEntryContext::backend_id's offset inside the unrelated object and +// returns them as if they were valid, with status.code==0. That value is +// undefined behavior (not a stable "wrong number" to assert), so the only +// portable part of the defect to pin is that the call reports *success* at +// all for a context of the wrong type. +TEST(GetBackendIdKnownDefects, DISABLED_WrongContextTypeReturnsError) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string u = al_contract::build_uri(HDF5_BACKEND, base.str(), pulse); + int pctx = -1; + ASSERT_EQ(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + int op = -1; + ASSERT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); + + int beid = -1; + al_status_t s = al_get_backendID(op, &beid); + EXPECT_NE(s.code, 0) + << "al_get_backendID on a non-pulse context must return an error"; + + al_end_action(op); + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST(GetBackendIdKnownDefects, WrongContextTypeCurrentlySucceedsViaUnsafeCast) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string u = al_contract::build_uri(HDF5_BACKEND, base.str(), pulse); + int pctx = -1; + ASSERT_EQ(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + int op = -1; + ASSERT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); + + int beid = -1; + al_status_t s = al_get_backendID(op, &beid); + EXPECT_EQ(s.code, 0) + << "al_get_backendID now validates context type — enable " + "GetBackendIdKnownDefects.DISABLED_WrongContextTypeReturnsError " + "(al_lowlevel.cpp al_get_backendID uses an unchecked static_cast)"; + + al_end_action(op); + al_close_pulse(pctx, CLOSE_PULSE); +} + +// =========================================================================== +// al_close_pulse: CLOSE_PULSE vs ERASE_PULSE. +// =========================================================================== +// None of the three always-on persistent backends actually branch on `mode` +// in closePulse (verified by reading HDF5Reader/Writer::closePulse, +// AsciiBackend::closePulse, and MemoryBackend::closePulse, none of which +// reference their mode parameter) — ERASE_PULSE behaves exactly like +// CLOSE_PULSE. The oracle here is backend-agnostic and avoids hard-coding any +// on-disk path convention: after ERASE_PULSE, a plain (non-FORCE) OPEN_PULSE +// on the same URI is the documented signal that the pulse is gone. +bool erase_then_plain_open_succeeds(const BackendCase& b, + al_contract::TempBase& base, + const PulseId& pulse) { + if (b.on_disk) base.make_legacy_tree(pulse); + const std::string u = al_contract::build_uri(b.id, base.str(), pulse); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); + EXPECT_EQ(al_contract::write_data(op, "leaf", {}, {11.0}).code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + EXPECT_EQ(al_close_pulse(pctx, ERASE_PULSE).code, 0); + + int pctx2 = -1; + al_status_t s = al_begin_dataentry_action(u.c_str(), OPEN_PULSE, &pctx2); + if (s.code == 0) al_close_pulse(pctx2, CLOSE_PULSE); + return s.code == 0; +} + +class ErasePulseKnownDefects : public ::testing::Test { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; +}; + +TEST_F(ErasePulseKnownDefects, DISABLED_Hdf5EraseMakesPulseUnopenable) { + EXPECT_FALSE(erase_then_plain_open_succeeds(kLifecycleBackends[0], base_, pulse_)) + << "ERASE_PULSE must remove the pulse so a later plain OPEN_PULSE fails"; +} +TEST_F(ErasePulseKnownDefects, Hdf5EraseCurrentlyLeavesPulseOpenable) { + EXPECT_TRUE(erase_then_plain_open_succeeds(kLifecycleBackends[0], base_, pulse_)) + << "HDF5Reader/Writer::closePulse now honors ERASE_PULSE — enable " + "DISABLED_Hdf5EraseMakesPulseUnopenable " + "(closePulse never references its mode parameter today)"; +} + +TEST_F(ErasePulseKnownDefects, DISABLED_AsciiEraseMakesPulseUnopenable) { + EXPECT_FALSE(erase_then_plain_open_succeeds(kLifecycleBackends[2], base_, pulse_)) + << "ERASE_PULSE must remove the pulse so a later plain OPEN_PULSE fails"; +} +TEST_F(ErasePulseKnownDefects, AsciiEraseCurrentlyLeavesPulseOpenable) { + EXPECT_TRUE(erase_then_plain_open_succeeds(kLifecycleBackends[2], base_, pulse_)) + << "AsciiBackend::closePulse now honors ERASE_PULSE — enable " + "DISABLED_AsciiEraseMakesPulseUnopenable " + "(closePulse ignores its mode parameter today)"; +} + +TEST_F(ErasePulseKnownDefects, DISABLED_MemoryEraseMakesPulseUnopenable) { + EXPECT_FALSE(erase_then_plain_open_succeeds(kLifecycleBackends[1], base_, pulse_)) + << "ERASE_PULSE must remove the pulse so a later plain OPEN_PULSE fails"; +} +TEST_F(ErasePulseKnownDefects, MemoryEraseCurrentlyLeavesPulseOpenable) { + EXPECT_TRUE(erase_then_plain_open_succeeds(kLifecycleBackends[1], base_, pulse_)) + << "MemoryBackend::closePulse now honors ERASE_PULSE — enable " + "DISABLED_MemoryEraseMakesPulseUnopenable " + "(closePulse is an empty function body today)"; +} + +} // namespace diff --git a/tests/contract/test_structured_data.cpp b/tests/contract/test_structured_data.cpp new file mode 100644 index 00000000..387310d8 --- /dev/null +++ b/tests/contract/test_structured_data.cpp @@ -0,0 +1,708 @@ +// Structured-data contract tests (issue #4 / TEST_STRATEGY.md §4 step 2). +// +// Covers what the round-trip matrix (issue #3) and capability-gated suite +// (issue #6) deliberately left as gaps: arrays-of-structures (top-level + +// nested), al_delete_data at its three documented granularities, +// al_get_occurrences, and the realistic equilibrium-seed round trip. +// +// Same idioms as the existing suite: al_contract.h for the fixture/generator +// layer, a per-file backend-capability table + TEST_P (test_capability_gated.cpp), +// and local DISABLED_/tripwire pairs for genuine defects, never frozen as spec +// (decision D2). +// +// This is characterization work, not new product code: every "positive" test +// below asserts real, already-shipped C-ABI behavior; every DISABLED_/tripwire +// pair pins a real defect found by reading the backend sources directly +// (src/hdf5/, src/memory_backend.*, src/ascii_backend.*, src/flexbuffers_backend.*) +// and confirmed by running the test against the built library. + +#include "al_contract.h" +#include "equilibrium_seed.h" + +#include +#include + +#include + +#include +#include +#include + +using al_contract::BackendCase; +using al_contract::PulseId; + +namespace { + +constexpr const char* kIds = "magnetics"; + +// =========================================================================== +// Arrays of structures: top-level + nested, write -> iterate -> read. +// =========================================================================== +// Flexbuffers is excluded from the round-trip expectation, consistent with +// RoundTripMatrix's classification (test_roundtrip_matrix.cpp): it is a +// serializer, not a pulse store, and refuses reads within the same session +// for ordinary fields; AOS content is read via the same field read/write path +// so the same refusal applies. +// ASCII is a KnownDefect, not a plain refusal: al_begin_arraystruct_action's +// READ side always reports size 0, for any AOS, regardless of what was +// written — confirmed empirically and traced to AsciiBackend::beginAction +// (READ_OP) consuming the *entire* file into a random-access lookup map +// (`curcontent_map`, ascii_backend.cpp:213-235) before returning, which +// leaves the sequential stream position at EOF. beginReadArraystructAction's +// AOS-size lookup (ascii_backend.cpp:656-682) depends on that same sequential +// position via `getline`, so it always finds nothing. See AosKnownDefects +// below for the paired DISABLED_/tripwire. +enum class AosExpect { RoundTrip, Refused, KnownDefect }; + +struct AosBackendCase { + int id; + const char* name; + bool on_disk; + AosExpect expect; +}; + +inline void PrintTo(const AosBackendCase& b, std::ostream* os) { *os << b.name; } + +const AosBackendCase kAosBackends[] = { + {HDF5_BACKEND, "HDF5", /*on_disk=*/true, AosExpect::RoundTrip}, + {MEMORY_BACKEND, "Memory", /*on_disk=*/false, AosExpect::RoundTrip}, + {ASCII_BACKEND, "ASCII", /*on_disk=*/true, AosExpect::KnownDefect}, + {FLEXBUFFERS_BACKEND, "Flexbuffers", /*on_disk=*/true, AosExpect::Refused}, +}; + +// Shared by AosMatrix and DeleteMatrix below (their backend-descriptor +// structs differ, but each carries the same id/on_disk pair). +int open_fresh_pulse(int backend_id, bool on_disk, const al_contract::TempBase& base, + const PulseId& pulse) { + if (on_disk) base.make_legacy_tree(pulse); + const std::string uri = al_contract::build_uri(backend_id, base.str(), pulse); + EXPECT_FALSE(uri.empty()); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + return pctx; +} + +class AosMatrix : public ::testing::TestWithParam { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; +}; + +TEST_P(AosMatrix, TopLevelWriteIterateRead) { + const AosBackendCase b = GetParam(); + if (b.expect == AosExpect::KnownDefect) { + GTEST_SKIP() << "known defect for " << b.name + << " AOS read — see AosKnownDefects.*"; + } + const int pctx = open_fresh_pulse(b.id, b.on_disk, base_, pulse_); + constexpr int kN = 3; + + // --- write: 3-element top-level AOS, one INTEGER field per element ----- + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + int size = kN; + int aos = -1; + al_status_t begin_status = + al_begin_arraystruct_action(op, "elements", "", &size, &aos); + for (int i = 0; i < kN && begin_status.code == 0; ++i) { + AL_EXPECT_OK(al_contract::write_data(aos, "val", {}, {100 + i})); + if (i + 1 < kN) AL_EXPECT_OK(al_iterate_over_arraystruct(aos, 1)); + } + if (aos != 0) AL_EXPECT_OK(al_end_action(aos)); + AL_ASSERT_OK(al_end_action(op)); + + // --- read: same shape back ---------------------------------------------- + int op2 = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &op2)); + int rsize = 0; + int raos = -1; + al_status_t read_status = + al_begin_arraystruct_action(op2, "elements", "", &rsize, &raos); + + if (b.expect == AosExpect::RoundTrip) { + AL_EXPECT_OK(begin_status); + AL_EXPECT_OK(read_status); + ASSERT_EQ(rsize, kN) << "AOS size must round-trip"; + for (int i = 0; i < rsize; ++i) { + std::vector shape, data; + AL_EXPECT_OK(al_contract::read_data(raos, "val", 0, &shape, &data)); + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], 100 + i) << "element " << i << " mismatch"; + if (i + 1 < rsize) AL_EXPECT_OK(al_iterate_over_arraystruct(raos, 1)); + } + } else { + EXPECT_TRUE(begin_status.code != 0 || read_status.code != 0 || rsize == 0) + << "expected the serializer backend to refuse the AOS read-back " + "within the same session"; + } + if (raos != 0) al_end_action(raos); + al_end_action(op2); + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_P(AosMatrix, NestedWriteIterateRead) { + const AosBackendCase b = GetParam(); + if (b.expect == AosExpect::KnownDefect) { + GTEST_SKIP() << "known defect for " << b.name + << " AOS read — see AosKnownDefects.*"; + } + const int pctx = open_fresh_pulse(b.id, b.on_disk, base_, pulse_); + constexpr int kOuter = 2, kInner = 2; + + // --- write: outer AOS of size 2, each element holds an inner AOS of size 2 + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + int outer_size = kOuter; + int outer = -1; + al_status_t outer_status = + al_begin_arraystruct_action(op, "outer", "", &outer_size, &outer); + for (int oi = 0; oi < kOuter && outer_status.code == 0; ++oi) { + int inner_size = kInner; + int inner = -1; + AL_EXPECT_OK(al_begin_arraystruct_action(outer, "inner", "", &inner_size, &inner)); + for (int ii = 0; ii < kInner; ++ii) { + AL_EXPECT_OK(al_contract::write_data(inner, "val", {}, {oi * 10 + ii})); + if (ii + 1 < kInner) AL_EXPECT_OK(al_iterate_over_arraystruct(inner, 1)); + } + if (inner != 0) AL_EXPECT_OK(al_end_action(inner)); + if (oi + 1 < kOuter) AL_EXPECT_OK(al_iterate_over_arraystruct(outer, 1)); + } + if (outer != 0) AL_EXPECT_OK(al_end_action(outer)); + AL_ASSERT_OK(al_end_action(op)); + + // --- read: same nested shape back -------------------------------------- + int op2 = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &op2)); + int router_size = 0; + int router = -1; + al_status_t router_status = + al_begin_arraystruct_action(op2, "outer", "", &router_size, &router); + + if (b.expect == AosExpect::RoundTrip) { + AL_EXPECT_OK(outer_status); + AL_EXPECT_OK(router_status); + ASSERT_EQ(router_size, kOuter); + for (int oi = 0; oi < router_size; ++oi) { + int rinner_size = 0; + int rinner = -1; + AL_EXPECT_OK( + al_begin_arraystruct_action(router, "inner", "", &rinner_size, &rinner)); + ASSERT_EQ(rinner_size, kInner) << "nested AOS size must round-trip"; + for (int ii = 0; ii < rinner_size; ++ii) { + std::vector shape, data; + AL_EXPECT_OK(al_contract::read_data(rinner, "val", 0, &shape, &data)); + ASSERT_EQ(data.size(), 1u); + EXPECT_EQ(data[0], oi * 10 + ii) + << "outer=" << oi << " inner=" << ii << " mismatch"; + if (ii + 1 < rinner_size) AL_EXPECT_OK(al_iterate_over_arraystruct(rinner, 1)); + } + if (rinner != 0) al_end_action(rinner); + if (oi + 1 < router_size) AL_EXPECT_OK(al_iterate_over_arraystruct(router, 1)); + } + } else { + EXPECT_TRUE(outer_status.code != 0 || router_status.code != 0 || router_size == 0) + << "expected the serializer backend to refuse the nested AOS " + "read-back within the same session"; + } + if (router != 0) al_end_action(router); + al_end_action(op2); + al_close_pulse(pctx, CLOSE_PULSE); +} + +INSTANTIATE_TEST_SUITE_P( + Backends, AosMatrix, ::testing::ValuesIn(kAosBackends), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.name); + }); + +// --------------------------------------------------------------------------- +// ASCII AOS-read known defect (decision D2): correct-contract DISABLED_ test +// paired with a current-behavior tripwire. +// --------------------------------------------------------------------------- +int ascii_aos_read_reported_size() { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string uri = al_contract::build_uri(ASCII_BACKEND, base.str(), pulse); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + + int op = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); + int size = 3; + int aos = -1; + EXPECT_EQ(al_begin_arraystruct_action(op, "elements", "", &size, &aos).code, 0); + for (int i = 0; i < 3; ++i) { + EXPECT_EQ(al_contract::write_data(aos, "val", {}, {100 + i}).code, 0); + if (i + 1 < 3) EXPECT_EQ(al_iterate_over_arraystruct(aos, 1).code, 0); + } + EXPECT_EQ(al_end_action(aos).code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + + int rop = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", READ_OP, &rop).code, 0); + int rsize = -1; + int raos = -1; + al_begin_arraystruct_action(rop, "elements", "", &rsize, &raos); + if (raos != 0) al_end_action(raos); + al_end_action(rop); + al_close_pulse(pctx, CLOSE_PULSE); + return rsize; +} + +TEST(AosKnownDefects, DISABLED_AsciiAosReadReportsWrittenSize) { + EXPECT_EQ(ascii_aos_read_reported_size(), 3) + << "reading an AOS back must report the size that was written"; +} + +TEST(AosKnownDefects, AsciiAosReadCurrentlyReportsZero) { + EXPECT_EQ(ascii_aos_read_reported_size(), 0) + << "AsciiBackend now reports the correct AOS size on read — enable " + "AosKnownDefects.DISABLED_AsciiAosReadReportsWrittenSize " + "(ascii_backend.cpp's READ_OP setup consumes the whole stream " + "into curcontent_map before beginReadArraystructAction's " + "sequential lookup ever runs)"; +} + +// =========================================================================== +// Equilibrium seed: realistic scalar + timebase-carrying 2-D array + AOS, +// oracle = content hash (decision D5). Flexbuffers is excluded for the same +// reason as the AOS matrix above (read refused within the session). +// =========================================================================== +class EquilibriumSeedMatrix : public ::testing::TestWithParam { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; +}; + +const BackendCase kSeedBackends[] = { + {HDF5_BACKEND, "HDF5", /*on_disk=*/true}, + {MEMORY_BACKEND, "Memory", /*on_disk=*/false}, + {ASCII_BACKEND, "ASCII", /*on_disk=*/true}, +}; + +TEST_P(EquilibriumSeedMatrix, RoundTripHashMatches) { + const BackendCase b = GetParam(); + if (b.on_disk) base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(b.id, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + AL_ASSERT_OK(equilibrium_seed::write(pctx)); + + uint64_t hash = 0; + AL_ASSERT_OK(equilibrium_seed::read_and_hash(pctx, &hash)); + EXPECT_EQ(hash, equilibrium_seed::expected_hash()) + << "equilibrium seed (scalar + timebase-carrying 2-D array + " + "constraints AOS) must round-trip exactly"; + + EXPECT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); +} + +INSTANTIATE_TEST_SUITE_P( + Backends, EquilibriumSeedMatrix, ::testing::ValuesIn(kSeedBackends), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.name); + }); + +// =========================================================================== +// al_delete_data at signal / structure / DATAOBJECT-root granularity. +// =========================================================================== +// The three granularities land on wildly different, non-overlapping support +// across the always-on tier (verified by reading each backend's deleteData): +// - HDF5: ignores `path` entirely — every call deletes the whole occurrence. +// So *root* is the only granularity that matches the documented contract; +// leaf/structure are genuine defects (a "leaf" delete takes everything +// else with it). One tripwire (leaf) covers both defective granularities: +// `HDF5Writer::deleteData` (src/hdf5/hdf5_writer.cpp) takes no path +// parameter at all, so there is no leaf-vs-structure branch whose +// behavior could differ — a structure-shaped call hits the exact same +// unconditional occurrence wipe. +// - Memory: the only backend that honors `path` — leaf and structure (an +// AOS field) both work for real; there is no code path that special-cases +// "delete the whole IDS", so root is the defect here. +// - ASCII, Flexbuffers: deleteData is a literal no-op (empty body, doc +// comment says so) — none of the three granularities do anything. One +// tripwire (leaf) suffices: an empty function body has no path-dependent +// branch to differ between granularities either. +// +// A "deleted" read is not a documented refusal: al_plugin_read_data returns +// status.code==0 with the datatype's sentinel value when the backend reports +// "no data" (src/al_lowlevel.cpp:1195-1199, Lowlevel::setDefaultValue) — so +// the oracle for "is it gone" is the sentinel, not an error code. +struct DeleteBackendCase { + int id; + const char* name; + bool on_disk; + bool leaf_ok; + bool structure_ok; + bool root_ok; +}; + +inline void PrintTo(const DeleteBackendCase& b, std::ostream* os) { *os << b.name; } + +const DeleteBackendCase kDeleteBackends[] = { + {HDF5_BACKEND, "HDF5", /*on_disk=*/true, /*leaf_ok=*/false, + /*structure_ok=*/false, /*root_ok=*/true}, + {MEMORY_BACKEND, "Memory", /*on_disk=*/false, /*leaf_ok=*/true, + /*structure_ok=*/true, /*root_ok=*/false}, + {ASCII_BACKEND, "ASCII", /*on_disk=*/true, /*leaf_ok=*/false, + /*structure_ok=*/false, /*root_ok=*/false}, + {FLEXBUFFERS_BACKEND, "Flexbuffers", /*on_disk=*/true, /*leaf_ok=*/false, + /*structure_ok=*/false, /*root_ok=*/false}, +}; + +class DeleteMatrix : public ::testing::TestWithParam { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; +}; + +TEST_P(DeleteMatrix, LeafDeleteRemovesJustTheLeaf) { + const DeleteBackendCase b = GetParam(); + if (!b.leaf_ok) { + GTEST_SKIP() << "known defect for " << b.name + << " leaf delete — see DeleteKnownDefects.*"; + } + const int pctx = open_fresh_pulse(b.id, b.on_disk, base_, pulse_); + + // Write and delete in the SAME WRITE_OP session: ASCII truncates its + // pulsefile (std::ios::out|std::ios::trunc) every time a WRITE_OP begins + // (ascii_backend.cpp:238), so a second, separate write-mode session would + // wipe the data itself and make the delete look effective when it isn't. + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_a", {}, {11.0})); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_b", {}, {22.0})); + AL_EXPECT_OK(al_delete_data(op, "leaf_a")); + AL_ASSERT_OK(al_end_action(op)); + + int rop = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &rop)); + std::vector shape; + std::vector a, bb; + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_a", 0, &shape, &a)); + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_b", 0, &shape, &bb)); + EXPECT_EQ(a.at(0), al_contract::kEmptyDouble) << "deleted leaf must read back absent"; + EXPECT_EQ(bb.at(0), 22.0) << "sibling leaf must survive a leaf-granularity delete"; + AL_ASSERT_OK(al_end_action(rop)); + + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_P(DeleteMatrix, StructureDeleteRemovesWholeSubtree) { + const DeleteBackendCase b = GetParam(); + if (!b.structure_ok) { + GTEST_SKIP() << "known defect for " << b.name + << " structure delete — see DeleteKnownDefects.*"; + } + const int pctx = open_fresh_pulse(b.id, b.on_disk, base_, pulse_); + + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + int size = 2; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, "block", "", &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, "x", {}, {1.0})); + AL_EXPECT_OK(al_iterate_over_arraystruct(aos, 1)); + AL_EXPECT_OK(al_contract::write_data(aos, "x", {}, {2.0})); + AL_ASSERT_OK(al_end_action(aos)); + AL_EXPECT_OK(al_contract::write_data(op, "sibling", {}, {99.0})); + AL_EXPECT_OK(al_delete_data(op, "block")); + AL_ASSERT_OK(al_end_action(op)); + + int rop = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &rop)); + int rsize = -1; + int raos = -1; + al_status_t s = al_begin_arraystruct_action(rop, "block", "", &rsize, &raos); + std::vector shape; + std::vector sibling; + AL_EXPECT_OK(al_contract::read_data(rop, "sibling", 0, &shape, &sibling)); + EXPECT_TRUE(s.code != 0 || rsize == 0) << "deleted structure must not report elements"; + EXPECT_EQ(sibling.at(0), 99.0) << "sibling field must survive a structure delete"; + if (raos != 0) al_end_action(raos); + AL_ASSERT_OK(al_end_action(rop)); + + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_P(DeleteMatrix, RootDeleteRemovesWholeOccurrence) { + const DeleteBackendCase b = GetParam(); + if (!b.root_ok) { + GTEST_SKIP() << "known defect for " << b.name + << " root delete — see DeleteKnownDefects.*"; + } + const int pctx = open_fresh_pulse(b.id, b.on_disk, base_, pulse_); + + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_a", {}, {11.0})); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_b", {}, {22.0})); + AL_EXPECT_OK(al_delete_data(op, "")); + AL_ASSERT_OK(al_end_action(op)); + + // A root/occurrence delete may remove the container so thoroughly that + // even opening a fresh READ action on it fails outright (verified: HDF5's + // deleteData removes the external link and the on-disk IDS file, so + // reopening "magnetics" for READ throws "Unable to open HDF5 group"). + // That failure is itself proof the occurrence is gone; if the backend + // instead tolerates reopening, every leaf must read back absent. + int rop = -1; + al_status_t open_read = al_begin_global_action(pctx, kIds, "", READ_OP, &rop); + if (open_read.code == 0) { + std::vector shape; + std::vector a, bb; + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_a", 0, &shape, &a)); + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_b", 0, &shape, &bb)); + EXPECT_EQ(a.at(0), al_contract::kEmptyDouble) << "root delete must remove every leaf"; + EXPECT_EQ(bb.at(0), al_contract::kEmptyDouble) << "root delete must remove every leaf"; + AL_ASSERT_OK(al_end_action(rop)); + } + + al_close_pulse(pctx, CLOSE_PULSE); +} + +INSTANTIATE_TEST_SUITE_P( + Backends, DeleteMatrix, ::testing::ValuesIn(kDeleteBackends), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.name); + }); + +// --------------------------------------------------------------------------- +// Known defects for al_delete_data (decision D2): correct-contract DISABLED_ +// tests, each paired with a current-behavior tripwire. +// --------------------------------------------------------------------------- +class DeleteKnownDefects : public ::testing::Test { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; + + int open(const BackendCase& b) { + if (b.on_disk) base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(b.id, base_.str(), pulse_); + EXPECT_FALSE(uri.empty()); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + return pctx; + } +}; + +const BackendCase kHdf5{HDF5_BACKEND, "HDF5", /*on_disk=*/true}; +const BackendCase kAscii{ASCII_BACKEND, "ASCII", /*on_disk=*/true}; + +// --- HDF5: leaf delete ignores `path`, wipes the whole occurrence ---------- +double hdf5_leaf_delete_sibling_survives(al_contract::TempBase& base, + const PulseId& pulse) { + const std::string uri = al_contract::build_uri(HDF5_BACKEND, base.str(), pulse); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); + EXPECT_EQ(al_contract::write_data(op, "leaf_a", {}, {11.0}).code, 0); + EXPECT_EQ(al_contract::write_data(op, "leaf_b", {}, {22.0}).code, 0); + EXPECT_EQ(al_delete_data(op, "leaf_a").code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + + // Reopening a READ on the occurrence may itself fail — HDF5's delete + // removed the whole occurrence's group/link/file, so there may be + // nothing left to open at all. That failure is just as much proof the + // sibling is gone as reading back the sentinel would be. + int rop = -1; + al_status_t open_read = al_begin_global_action(pctx, kIds, "", READ_OP, &rop); + double result = al_contract::kEmptyDouble; + if (open_read.code == 0) { + std::vector shape; + std::vector bb; + EXPECT_EQ(al_contract::read_data(rop, "leaf_b", 0, &shape, &bb).code, 0); + EXPECT_EQ(al_end_action(rop).code, 0); + if (!bb.empty()) result = bb[0]; + } + al_close_pulse(pctx, CLOSE_PULSE); + return result; +} + +TEST_F(DeleteKnownDefects, DISABLED_Hdf5LeafDeleteLeavesSiblingIntact) { + EXPECT_EQ(hdf5_leaf_delete_sibling_survives(base_, pulse_), 22.0) + << "a leaf-granularity delete must not remove sibling fields"; +} + +TEST_F(DeleteKnownDefects, Hdf5LeafDeleteCurrentlyWipesWholeOccurrence) { + EXPECT_EQ(hdf5_leaf_delete_sibling_survives(base_, pulse_), + al_contract::kEmptyDouble) + << "HDF5 deleteData now honors `path` for leaf granularity — enable " + "DeleteKnownDefects.DISABLED_Hdf5LeafDeleteLeavesSiblingIntact " + "(src/hdf5/hdf5_writer.cpp deleteData ignores its caller's field)"; +} + +// --- Memory: no code path implements DATAOBJECT-root delete ---------------- +bool memory_root_delete_clears_everything(al_contract::TempBase& base, + const PulseId& pulse) { + const std::string uri = al_contract::build_uri(MEMORY_BACKEND, base.str(), pulse); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); + EXPECT_EQ(al_contract::write_data(op, "leaf_a", {}, {11.0}).code, 0); + EXPECT_EQ(al_contract::write_data(op, "leaf_b", {}, {22.0}).code, 0); + EXPECT_EQ(al_delete_data(op, "").code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + int rop = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", READ_OP, &rop).code, 0); + std::vector shape; + std::vector a, bb; + EXPECT_EQ(al_contract::read_data(rop, "leaf_a", 0, &shape, &a).code, 0); + EXPECT_EQ(al_contract::read_data(rop, "leaf_b", 0, &shape, &bb).code, 0); + EXPECT_EQ(al_end_action(rop).code, 0); + al_close_pulse(pctx, CLOSE_PULSE); + return !a.empty() && !bb.empty() && a[0] == al_contract::kEmptyDouble && + bb[0] == al_contract::kEmptyDouble; +} + +TEST_F(DeleteKnownDefects, DISABLED_MemoryRootDeleteClearsWholeIds) { + EXPECT_TRUE(memory_root_delete_clears_everything(base_, pulse_)) + << "a DATAOBJECT-root delete must clear every field of the IDS"; +} + +TEST_F(DeleteKnownDefects, MemoryRootDeleteCurrentlyDoesNothing) { + EXPECT_FALSE(memory_root_delete_clears_everything(base_, pulse_)) + << "MemoryBackend::deleteData now supports root granularity — enable " + "DeleteKnownDefects.DISABLED_MemoryRootDeleteClearsWholeIds " + "(memory_backend.cpp deleteData has no root/occurrence case)"; +} + +// --- ASCII: deleteData is a literal no-op ---------------------------------- +// Flexbuffers is deliberately NOT covered by this pair: it refuses every +// in-session read regardless of delete (RoundTripMatrix classifies the whole +// backend as a must-refuse column — "write accepted, read refused, every +// datatype x shape"), so a read-based oracle can't distinguish "delete did +// nothing" from "this backend never round-trips reads at all." Its +// deleteData is confirmed a no-op by reading the source directly +// (flexbuffers_backend.cpp: empty NOOP body) — noted in the file-header +// comment above, not re-asserted here since it isn't ABI-observable. +bool delete_is_a_noop(const BackendCase& b, al_contract::TempBase& base, + const PulseId& pulse) { + const std::string uri = al_contract::build_uri(b.id, base.str(), pulse); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); + EXPECT_EQ(al_contract::write_data(op, "leaf_a", {}, {11.0}).code, 0); + EXPECT_EQ(al_delete_data(op, "leaf_a").code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + int rop = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", READ_OP, &rop).code, 0); + std::vector shape; + std::vector a; + EXPECT_EQ(al_contract::read_data(rop, "leaf_a", 0, &shape, &a).code, 0); + EXPECT_EQ(al_end_action(rop).code, 0); + al_close_pulse(pctx, CLOSE_PULSE); + return !a.empty() && a[0] == 11.0; // still there => delete did nothing +} + +TEST_F(DeleteKnownDefects, DISABLED_AsciiDeleteRemovesLeaf) { + EXPECT_FALSE(delete_is_a_noop(kAscii, base_, pulse_)) + << "al_delete_data must actually remove data in the ASCII backend"; +} +TEST_F(DeleteKnownDefects, AsciiDeleteIsCurrentlyANoOp) { + EXPECT_TRUE(delete_is_a_noop(kAscii, base_, pulse_)) + << "AsciiBackend::deleteData now has an effect — enable " + "DeleteKnownDefects.DISABLED_AsciiDeleteRemovesLeaf " + "(ascii_backend.cpp deleteData has an empty body)"; +} + + +// =========================================================================== +// al_get_occurrences. +// =========================================================================== +// HDF5 and ASCII implement it for real, each against its own occurrence-naming +// convention (neither backend transforms `dataobjectname`, so the *caller* +// supplies the backend-native form): HDF5 external-links are named literally, +// so occurrence N is "_" (src/hdf5/hdf5_reader.cpp:1550-1588, split on +// the last '_'); ASCII strips exactly one '/' character from `dataobjectname` +// (src/ascii_backend.cpp:194-197), so occurrence N must be passed as +// "/" to produce the on-disk stem "" its own directory scan +// expects. Memory and Flexbuffers throw unconditionally (not implemented). +TEST(Occurrences, Hdf5ListsWrittenOccurrences) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string uri = al_contract::build_uri(HDF5_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + for (const char* name : {"magnetics", "magnetics_2"}) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, name, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "leaf", {}, {1.0})); + AL_ASSERT_OK(al_end_action(op)); + } + + int* occ = nullptr; + int n = -1; + AL_ASSERT_OK(al_get_occurrences(pctx, "magnetics", &occ, &n)); + ASSERT_EQ(n, 2); + EXPECT_EQ(occ[0], 0); + EXPECT_EQ(occ[1], 2); + free(occ); // malloc'd (src/hdf5/hdf5_reader.cpp:1592) -> free() is correct. + + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST(Occurrences, AsciiListsWrittenOccurrences) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string uri = al_contract::build_uri(ASCII_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + for (const char* name : {"magnetics", "magnetics/2"}) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, name, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "leaf", {}, {1.0})); + AL_ASSERT_OK(al_end_action(op)); + } + + int* occ = nullptr; + int n = -1; + AL_ASSERT_OK(al_get_occurrences(pctx, "magnetics", &occ, &n)); + ASSERT_EQ(n, 2); + EXPECT_EQ(occ[0], 0); + EXPECT_EQ(occ[1], 2); + free(occ); // malloc'd (src/ascii_backend.cpp:706) -> free() is correct. + + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST(Occurrences, MemoryRefuses) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + const std::string uri = al_contract::build_uri(MEMORY_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + int* occ = nullptr; + int n = -1; + EXPECT_NE(al_get_occurrences(pctx, "magnetics", &occ, &n).code, 0) + << "get_occurrences is not implemented in MemoryBackend"; + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST(Occurrences, FlexbuffersRefuses) { + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string uri = al_contract::build_uri(FLEXBUFFERS_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + int* occ = nullptr; + int n = -1; + EXPECT_NE(al_get_occurrences(pctx, "magnetics", &occ, &n).code, 0) + << "get_occurrences is not implemented in the Serialize (Flexbuffers) backend"; + al_close_pulse(pctx, CLOSE_PULSE); +} + +} // namespace From 3ae109de1a687df3b7b962fe129e6c489e53a932 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:34:57 +0200 Subject: [PATCH 14/91] Update TRACEABILITY.md --- tests/contract/TRACEABILITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index ef755958..0a5c8c7a 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -43,7 +43,7 @@ carry a `/` (or `/__r`) suffix per instance. ## introspection, #3 round-trip matrix, #6 capability-gated, #7 plugins) The suite covers the **data-path, introspection, and plugin-management surface** -end to end and pins the defects those issues surfaced (nine xfails, each with a +end to end and pins the defects those issues surfaced (eleven xfails, each with a paired current-behavior tripwire). It does **not yet** cover the pulse-lifecycle detail, arrays-of-structures, deletion, occurrences, or the Plugin-author audience — those are the remaining build-order steps and appear below as explicit @@ -205,7 +205,7 @@ the plugins issue has a concrete checklist rather than a blank slate. ## xfail bookkeeping — every xfail now has a paired tripwire The D2 discipline is "correct-contract `DISABLED_` test **plus** a paired -current-behavior tripwire, so the xfail can't rot." All nine xfail rows satisfy +current-behavior tripwire, so the xfail can't rot." All eleven xfail rows satisfy it — a fix to any underlying defect turns its tripwire red, forcing whoever fixed it to enable the paired `DISABLED_` correct-contract test: From d115cd17f2c2fd3dc96b1338e09284b1bdac5b4b Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:34:19 +0200 Subject: [PATCH 15/91] Update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index bfbb764a..635fac9a 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,5 @@ python/lib/ /.venv/ /vcpkg/ /UDA-*/ -.idea \ No newline at end of file +.idea +/graphify-out From 10cdaf2d464d400754a711e3fb0d622faa1dc273 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:14:44 +0200 Subject: [PATCH 16/91] Close the ownership sweep + comprehensive-coverage exit criterion (issue #8) Pins caller-frees ownership for al_build_uri_from_legacy_parameters's *uri (the three other flagged APIs were already pinned by earlier issues), resolves empirically that plugin-parameter dim/size is not bounded by MAXDIM, and covers the remaining Plugin-author audience reachable through the C ABI: low-level al_plugin_* reentry, write/read data interception via a bound plugin, al_write_plugins_metadata, and al_bind/unbind_readback_plugins (extending the in-repo test plugin fixture with an env-var-gated readback capability). TRACEABILITY.md now has no blank rows; the two remaining gap rows are marked terminal with an explicit rationale. Co-Authored-By: Claude Sonnet 5 --- tests/contract/TRACEABILITY.md | 92 ++++--- tests/contract/test_plugin_fixture.cpp | 35 ++- tests/contract/test_plugins.cpp | 349 ++++++++++++++++++++++++ tests/contract/test_pulse_lifecycle.cpp | 22 ++ 4 files changed, 454 insertions(+), 44 deletions(-) diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 259de07d..639ef445 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -20,8 +20,12 @@ Status vocabulary: - **xfail** — a genuine defect; the test asserts the *correct* behavior and is expected-fail (decision D2, `DISABLED_` + a paired current-behavior tripwire). Flipping to pass means someone fixed it. -- **gap** — not yet tested; the owning later build-order step (TEST_STRATEGY §4) - is named so the future issue knows which row to fill. +- **gap** — not tested. Either *deferred*, naming the later build-order step + (TEST_STRATEGY §4) that owns it, or — now that issue #8 (the last build-order + step) is closing — *terminal*: reachable in principle but out of this + suite's scope (e.g. needs infrastructure this test target doesn't have), or + genuinely not observable through the C ABI at all. Each terminal gap says + which, explicitly, so "gap" here never means "silently fine." Line coverage (`gcov`/`lcov`) is a secondary signal only (D8): a rewrite may restructure lines but must still satisfy every row here. @@ -39,26 +43,39 @@ carry a `/` (or `/__r`) suffix per instance. > needs CMake ≥ 3.22 (`gtest_discover_tests(TEST_FILTER …)`); on the declared > 3.21 floor it falls back to the single `contract` label. -## Coverage status — as of the five implemented issues (#2 scaffold, #5 -## introspection, #3 round-trip matrix, #6 capability-gated, #4 structured data, , #7 plugins) - -The suite covers the **data-path, introspection, structured-data surface, and plugin-management surface** -end to end and pins the defects those issues surfaced (twenty xfails, each -with a paired current-behavior tripwire). It does **not yet** cover any of the -the Plugin-author audience or the ownership sweep — those are the remaining build-order -steps and appear below as explicit **gap** rows, each tagged with the step -that owns it: - -- **Plugins** (§4 step 5, task doc `5-plugins.md`): User Cluster 3 is now - covered (register/bind/unbind/unregister state machine + quirks, - `al_is_plugin_registered`, all three `al_setvalue_*` variants, four pinned - defects). Still open: readback binding, `al_write_plugins_metadata`, and all - of Part 3 (the Plugin-author audience). -- **Ownership sweep** (§4 step 6, task doc `6-ownership-sweep.md`): the - caller-frees questions and the remaining audience sweep. - -GitHub issue numbers for those two are to be filled in when the issues are -opened; until then they are referenced by build-order step + task-doc slug. +## Coverage status — as of all six implemented issues (#2 scaffold, #5 +## introspection, #3 round-trip matrix, #6 capability-gated, #4 structured +## data, #7 plugins, #8 ownership sweep) + +The suite covers the **data-path, introspection, structured-data surface, +plugin-management surface, and Plugin-author audience** end to end and pins +the defects those issues surfaced (twenty xfails, each with a paired +current-behavior tripwire — issue #8 resolved every open ownership/coverage +question by test but found no new genuine defects, so the xfail count is +unchanged). Issue #8 (`6-ownership-sweep.md`) closed every remaining row: + +- **Ownership**: `al_context_info`, `al_get_occurrences`'s + `*occurrences_list`, and `al_list_filled_paths`'s list+strings were already + pinned malloc/free by issues #4/#6 (`ContextInfo.*`, `Occurrences.*`, + `CapabilityMatrix.ListFilledPathsPositiveOrRefused`). + `al_build_uri_from_legacy_parameters`'s `*uri` gets its own explicit pin + here (`UriOwnership.CallerFreesBuiltUri`), rather than only incidental + free()s inside the `al_contract::build_uri` test helper. +- **MAXDIM**: resolved empirically — plugin-parameter `dim`/`size` on + `al_setvalue_parameter_plugin` is **not** bounded by MAXDIM; the core + passes it through to the plugin unchecked + (`PluginTest.SetValueGenericAcceptsDimAboveMaxdim`). +- **Plugin-author audience (Part 3)**: low-level reentry (`PluginReentry.*`), + action-lifecycle data interception + (`PluginTest.BoundPluginIntercepts{Write,Read}InsteadOfBackend`), provenance + + `al_write_plugins_metadata` + (`PluginTest.WritePluginsMetadataStoresBoundPluginProvenance`), and readback + binding (`ReadbackPlugins.*`, with `test_plugin_fixture.cpp` extended to + opt into readback capability for one path via + `AL_CONTRACT_PLUGIN_READBACK_PATH`) are now all covered. +- **Two rows stay `gap`, by design, not omission**: Backend `getVersion` + drift and `initDataInterpolationComponent` — see their rows in Part 2 for + why each is a legitimate terminal gap rather than a deferred one. --- @@ -75,7 +92,7 @@ opened; until then they are referenced by build-order step + task-doc slug. | `al_context_info` describes a context (pulse/operation/arraystruct); **caller frees `*info`** (verified `malloc`-based) | :118-126 | ✓ | ✓ | `ContextInfo.{NullContextReturnsLiteralString, PulseContextDescribesItsUri, OperationContextDescribesDataobjectAndAccessmode, ArraystructContextDescribesPathAndIndex}` (frees via `free()`) | covered | | `al_get_backendID` returns the active `BACKEND` for a context | :128-135 | — | ✓ | `GetBackendId.ReturnsTheBackendUsedToOpen` (HDF5 only) | covered | | ↳ no context-type check — a non-pulse context is `static_cast` (not `dynamic_cast`) to `DataEntryContext*` with no validation, so it **silently "succeeds" with an undefined-behavior value** instead of erroring (confirmed empirically: it does not crash) | :128-135; src/al_lowlevel.cpp al_get_backendID | — | ✓ | `GetBackendIdKnownDefects.DISABLED_WrongContextTypeReturnsError` + tripwire `GetBackendIdKnownDefects.WrongContextTypeCurrentlySucceedsViaUnsafeCast` (HDF5 only) | **xfail** | -| `al_build_uri_from_legacy_parameters` builds a URI from legacy params | :137-149 | ✓ | ✓ | `al_contract::build_uri` (asserts OK) drives HDF5·Memory·ASCII in all on-disk suites. Caller-frees of `*uri` → ownership sweep (step 6) | covered | +| `al_build_uri_from_legacy_parameters` builds a URI from legacy params; **caller frees `*uri`** (verified malloc-based, src/al_context.cpp:241-261) | :137-149 | ✓ | ✓ | `al_contract::build_uri` (asserts OK, frees) drives HDF5·Memory·ASCII in all on-disk suites; ownership pinned explicitly by `UriOwnership.CallerFreesBuiltUri` | covered | | ↳ must also address the always-on FLEXBUFFERS backend — **throws (`getURIBackend` has no case)** | :137-149; src/al_context.cpp:280 | ✓ | — | `RoundTripKnownDefects.DISABLED_BuildUriSupportsFlexbuffers` + tripwire `RoundTripKnownDefects.BuildUriFlexbuffersCurrentlyFails` | **xfail** | ## Cluster 2 — Core data access (`FUNCTIONALITY_INVENTORY.md:153-314`) @@ -133,9 +150,12 @@ classified once (D2). Four defects are pinned as expected-fail with paired current-behavior tripwires: the two setvalue null-derefs (double + generic, joining the scaffold's int one), the registered-but-never-bound plugin left un-destroyed on unregister, and the `dlopen`-failure swallowed assert. The whole -suite is `unit` (in-process registry, no on-disk backend). Still deferred: -readback binding and `al_write_plugins_metadata` (need a get/put through a -readback-implementing plugin — Part 3 / ownership sweep). +suite is `unit` (in-process registry, no on-disk backend). Readback binding +and `al_write_plugins_metadata`, deferred at the time this paragraph was +written, are now covered — issue #8 extended `test_plugin_fixture.cpp` with +an env-var-gated readback capability and drove a real put→write-metadata→ +unregister→bind_readback_plugins cycle over a Memory-backend pulse (see +their rows below and Part 3). | Capability | Inventory ref | Unit | Integ. | Test(s) — and residual | Status | |---|---|:--:|:--:|---|---| @@ -143,7 +163,7 @@ readback-implementing plugin — Part 3 / ownership sweep). | ↳ unregister of a **registered-but-never-bound** plugin must destroy it — **it is left un-destroyed** (`unregisterPlugin` destroy+erase run only inside the `boundPlugins` loop) | :347-354; src/al_lowlevel.cpp:382-431 | ✓ | — | `PluginTest.DISABLED_UnregisterNeverBoundPluginDestroysIt` + tripwire `PluginTest.UnregisterNeverBoundPluginLeavesItRegistered_CurrentBehavior` | **xfail** | | ↳ `register` on an existing-but-unloadable `.so` must report the **dlopen** failure — the failure is guarded only by an `assert`, stripped under `-DNDEBUG` (misleading downstream "Cannot load symbol create"; would `SIGABRT` in an assert-enabled build) | :355-360; src/al_lowlevel.cpp:350-357 | ✓ | — | `PluginTest.DISABLED_RegisterUnloadableSharedLibReportsDlopenFailure` + tripwire `PluginTest.RegisterUnloadableSharedLibSwallowsAssert_CurrentBehavior` | **xfail** | | `al_bind_plugin` / `al_unbind_plugin` (bind registered OK; bind-unregistered throws; double-bind throws; unbind-unbound silent no-op) | :362-376 | ✓ | — | `PluginTest.{BindRegisteredPluginSucceeds, BindUnregisteredPluginReturnsError, DoubleBindSamePathReturnsError, UnbindNeverBoundPathIsSilentNoOp}` | covered | -| `al_bind_readback_plugins` / `al_unbind_readback_plugins` | :378-389 | — | — | needs a readback-implementing plugin + a get op → Part 3 / ownership sweep | gap | +| `al_bind_readback_plugins` / `al_unbind_readback_plugins` (auto-register + auto-bind from stored metadata before a get; auto-unregister after) | :378-389 | ✓ | — | `ReadbackPlugins.BindReadbackPluginsAutoRegistersAndBindsFromStoredMetadata` — `test_plugin_fixture.cpp` extended to opt into readback capability for one path via `AL_CONTRACT_PLUGIN_READBACK_PATH`; the bind is confirmed indirectly (double-bind now errors) since there is no direct `al_is_plugin_bound` ABI | covered | | `al_is_plugin_registered` boolean query (true/false; framework-gated) | :391-397 | ✓ | — | `PluginTest.{RegisterMakesPluginRegistered, IsPluginRegisteredIsFalseForNeverRegisteredName, IsPluginRegisteredWithFrameworkDisabledReturnsError}` | covered | | `al_setvalue_parameter_plugin` (generic typed variant) on a registered plugin | :399-420 | ✓ | — | `PluginTest.SetValueGenericOnRegisteredPluginSucceeds` | covered | | ↳ generic variant on an **unregistered** name must error, not crash | :399-417 | ✓ | — | `KnownDefects.DISABLED_SetValueGenericUnregisteredPluginReturnsError` + tripwire `KnownDefectsDeath.SetValueGenericUnregisteredPluginCurrentlyCrashes` (SIGSEGV) | **xfail** | @@ -151,7 +171,7 @@ readback-implementing plugin — Part 3 / ownership sweep). | ↳ int-scalar variant on an **unregistered** name must error, not crash | :399-417 | ✓ | — | `KnownDefects.DISABLED_SetValueIntScalarUnregisteredPluginReturnsError` + tripwire `KnownDefectsDeath.SetValueIntScalarUnregisteredPluginCurrentlyCrashes` (SIGSEGV) | **xfail** | | `al_setvalue_double_scalar_parameter_plugin` on a registered plugin reaches `setParameter` with the right value | :399-417 | ✓ | — | `PluginTest.SetValueDoubleScalarOnRegisteredPluginReachesPlugin` | covered | | ↳ double-scalar variant on an **unregistered** name must error, not crash | :399-417 | ✓ | — | `KnownDefects.DISABLED_SetValueDoubleScalarUnregisteredPluginReturnsError` + tripwire `KnownDefectsDeath.SetValueDoubleScalarUnregisteredPluginCurrentlyCrashes` (SIGSEGV) | **xfail** | -| `al_write_plugins_metadata` | :422-430 | — | — | needs bound provenance plugin + open ctx → Part 3 / ownership sweep | gap | +| `al_write_plugins_metadata` (stores bound put-capable plugins' provenance + any readback contribution under `ids_properties/plugins/node`) | :422-430 | ✓ | — | `PluginTest.WritePluginsMetadataStoresBoundPluginProvenance` (Memory backend; verifies stored `path`, `put_operation[]` provenance fields, and an empty `readback[]` for a non-readback-opted-in bind) | covered | ## Cluster 4 — Introspection / diagnostics (`FUNCTIONALITY_INVENTORY.md:434-459`) (issue #5 — the thin unit tier) @@ -190,8 +210,8 @@ and points at the same test. | Cluster / Capability | Inventory ref | Unit | Integ. | Test(s) — and residual | Status | |---|---|:--:|:--:|---|---| -| **A** `initBackend` factory selects the backend from the URI | :496-514 | ✓ (Memory) | ✓ (HDF5, ASCII) | every always-on backend instantiated via `build_uri` in `RoundTripMatrix`. Unrecognized-ID throw untested → step 6 | covered (indirect) | -| **B** `getVersion` (installed vs stored, drift check) | :520-540 | — | — | version-drift guard untested → step 6 | gap | +| **A** `initBackend` factory selects the backend from the URI | :496-514 | ✓ (Memory) | ✓ (HDF5, ASCII) | every always-on backend instantiated via `build_uri` in `RoundTripMatrix`. Residual: the unrecognized-ID throw path (`al_backend.cpp:82-86`) is straightforward by inspection (a plain `else` throwing `ALBackendException`) but untested — low-value relative to the rest of this row, left as a residual rather than a separate row | covered (indirect) | +| **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 | 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) | covered | @@ -200,7 +220,7 @@ and points at the same test. | **D** `get_occurrences` | :612-616 | — | ✓ | via `al_get_occurrences` — `Occurrences.*` | 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 | 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 | covered (indirect) | -| **E** `initDataInterpolationComponent` (framework-driven) | :644-690 | — | — | no-op where the factory calls it; not separately ABI-observable → step 6 | gap | +| **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 | gap | --- @@ -211,11 +231,11 @@ the plugins issue has a concrete checklist rather than a blank slate. | Cluster / Capability | Inventory ref | Unit | Integ. | Test(s) | Status | |---|---|:--:|:--:|---|---| -| **1** Provenance metadata (`getName`/`getVersion`/`getCommit`/…) | :739-759 | — | — | — → plugins (step 5) | gap | -| **2** Parameter configuration (`setParameter` / `setParameters`) | :763-780 | ✓ | — | `setParameter` is reached (with the exact value) via `al_setvalue_*` on the registered fixture plugin: `PluginTest.SetValue{IntScalar,DoubleScalar}OnRegisteredPluginReachesPlugin` (asserted through the plugin's parameter log). The User-side unregistered-name crash is xfail'd in Part 1 Cluster 3. Residual: `setParameters` (bulk) untested → step 6 | covered (indirect) | -| **3** Action lifecycle & data interception (`begin_*_action`, `read_data`/`write_data`, `node_operation`) | :784-824 | — | — | — → plugins (step 5) | gap | -| **4** Readback metadata (`getReadback*`) | :827-850 | — | — | — → plugins (step 5) | gap | -| **5** Low-level reentry (`al_plugin_*`); `al_plugin_begin_timerange_action` is broken (declaration/definition mismatch) | :854-895 | — | — | — → plugins (step 5); the reentry bug is tracked separately as an upstream GitHub issue | gap | +| **1** Provenance metadata (`getName`/`getVersion`/`getCommit`/…) | :739-759 | ✓ | — | Read back from stored metadata: `PluginTest.WritePluginsMetadataStoresBoundPluginProvenance` asserts `name`/`version`/`commit`/`repository` match `ContractTestPlugin`'s provenance; `ReadbackPlugins.*` additionally exercises `getReadbackVersion` being checked for self-consistency against `getVersion` | covered | +| **2** Parameter configuration (`setParameter` / `setParameters`) | :763-780 | ✓ | — | `setParameter` is reached (with the exact value) via `al_setvalue_*` on the registered fixture plugin: `PluginTest.SetValue{IntScalar,DoubleScalar}OnRegisteredPluginReachesPlugin` (asserted through the plugin's parameter log). The User-side unregistered-name crash is xfail'd in Part 1 Cluster 3. `setParameters` (bulk) has no C-ABI seam of its own (only the single-parameter `al_setvalue_*` functions exist) — it is invoked internally as part of `al_bind_readback_plugins` (`ReadbackPlugins.*` exercises the call, with an empty `parameters` string) but a caller cannot address it directly, so a residual "test bulk setParameters directly" is not achievable through the C ABI (D1). **Terminal gap** for the direct-call residual, not deferred | covered (indirect) | +| **3** Action lifecycle & data interception (`begin_*_action`, `read_data`/`write_data`, `node_operation`) | :784-824 | ✓ | — | `al_write_data`/`al_read_data` check `LLplugin::getBoundPlugins` before touching the backend (al_lowlevel.cpp:1685-1693,1721-1731): `PluginTest.BoundPluginInterceptsWriteInsteadOfBackend` proves a bound plugin's inert `write_data()` absorbs a write (the value never reaches the Memory backend, contrasted with an unbound sibling field that round-trips normally); `PluginTest.BoundPluginInterceptsReadInsteadOfBackend` proves the symmetric case for `read_data()`. `node_operation`'s PUT/GET classification is exercised as an asserted dependency of `findPutOperationPlugins`/`findGetOperationPlugins` in the `WritePluginsMetadata`/`ReadbackPlugins` tests. `begin_*_action` hooks on a *regular* bound plugin remain inert-only (`ContractTestPlugin`'s are no-ops) — no test drives a plugin that does real work in them, since none of the existing behaviors need one | covered (indirect) | +| **4** Readback metadata (`getReadback*`) | :827-850 | ✓ | — | `ReadbackPlugins.BindReadbackPluginsAutoRegistersAndBindsFromStoredMetadata` — `test_plugin_fixture.cpp`'s `ContractTestPlugin` extended with `AL_CONTRACT_PLUGIN_READBACK_PATH` to opt a single path into readback capability, self-consistent with its own provenance (so the version-match check in `bind_readback_plugins` passes) | covered | +| **5** Low-level reentry (`al_plugin_*`); `al_plugin_begin_timerange_action` is broken (declaration/definition mismatch) | :854-895 | ✓ | — | `PluginReentry.{WriteDataReentersTheBackendDirectly,ReadDataAlsoReentersTheBackendDirectly}` call `al_plugin_begin_global_action`/`al_plugin_write_data`/`al_plugin_read_data`/`al_plugin_end_action` directly (no plugin object needed — they bypass dispatch straight to the backend) and cross-check against the ordinary `al_write_data`/`al_read_data` path. `al_plugin_begin_timerange_action` is excluded — the reentry bug is tracked separately as an upstream GitHub issue, not re-characterized here | covered | --- diff --git a/tests/contract/test_plugin_fixture.cpp b/tests/contract/test_plugin_fixture.cpp index 2b4b18fb..537445d3 100644 --- a/tests/contract/test_plugin_fixture.cpp +++ b/tests/contract/test_plugin_fixture.cpp @@ -81,16 +81,35 @@ class ContractTestPlugin : public access_layer_plugin { std::string getParameters() override { return ""; } // --- readback_plugin_feature ------------------------------------------- - std::string getReadbackName(const std::string&, int*) override { - return ""; + // By default this plugin declares no readback capability for any path + // (every getReadback* below returns ""), matching the plugin-lifecycle + // tests (issue #7). AL_CONTRACT_PLUGIN_READBACK_PATH opts a single field + // path in, for the readback-binding contract test (issue #8): when set + // and equal to `path`, the plugin declares itself as its own readback + // provider for that path, self-consistent with its own provenance (so + // AccessLayerPluginManager::bind_readback_plugins' version check passes). + bool is_readback_path(const std::string& path) const { + const char* readback_path = + std::getenv("AL_CONTRACT_PLUGIN_READBACK_PATH"); + return readback_path != nullptr && path == readback_path; } - std::string getReadbackDescription(const std::string&) override { - return ""; + + std::string getReadbackName(const std::string& path, int* index) override { + if (!is_readback_path(path)) return ""; + *index = 0; + return getName(); } - std::string getReadbackCommit(const std::string&) override { return ""; } - std::string getReadbackVersion(const std::string&) override { return ""; } - std::string getReadbackRepository(const std::string&) override { - return ""; + std::string getReadbackDescription(const std::string& path) override { + return is_readback_path(path) ? getDescription() : ""; + } + std::string getReadbackCommit(const std::string& path) override { + return is_readback_path(path) ? getCommit() : ""; + } + std::string getReadbackVersion(const std::string& path) override { + return is_readback_path(path) ? getVersion() : ""; + } + std::string getReadbackRepository(const std::string& path) override { + return is_readback_path(path) ? getRepository() : ""; } std::string getReadbackParameters(const std::string&) override { return ""; diff --git a/tests/contract/test_plugins.cpp b/tests/contract/test_plugins.cpp index 50a92bac..27c681ce 100644 --- a/tests/contract/test_plugins.cpp +++ b/tests/contract/test_plugins.cpp @@ -77,6 +77,19 @@ void force_unregister(const char* name) { al_unregister_plugin(name); } +// Shared by every test below that needs a real (Memory-backend, unit-tier) +// pulse to write/read through — issue #8's ownership-sweep tests are the +// first in this file to need one, since the earlier plugin-registry tests +// above operate on bare path strings with no backend at all. +int open_memory_pulse(al_contract::TempBase& base, + const al_contract::PulseId& pulse) { + const std::string u = al_contract::build_uri(MEMORY_BACKEND, base.str(), pulse); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(u.c_str(), FORCE_CREATE_PULSE, &pctx).code, + 0); + return pctx; +} + class PluginTest : public ::testing::Test { protected: void SetUp() override { @@ -246,6 +259,42 @@ TEST_F(PluginTest, SetValueGenericOnRegisteredPluginReachesPlugin) { expect_param_logged(log, "mode", INTEGER_DATA, "7"); } +// =========================================================================== +// al_setvalue_parameter_plugin's dim/size (FUNCTIONALITY_INVENTORY.md:399-420): +// is `dim` bound by MAXDIM? (issue #8 ownership sweep — resolved empirically, +// not assumed.) +// +// First hypothesis tried here was that dim > MAXDIM would be rejected; it +// isn't (confirmed by running this test against the built library before +// settling on the assertion below). LLplugin::setvalueParameterPlugin +// (al_lowlevel.cpp:453-457) forwards dim/size straight through to the +// plugin's setParameter with no comparison against MAXDIM and no copy into +// any core-side fixed-size buffer. MAXDIM bounds only al_read_data's/ +// al_write_data's own shape arrays, not this call chain — a plugin author +// choosing to size an internal buffer at MAXDIM would be relying on a +// convention the core does not enforce for this ABI. +// =========================================================================== +TEST_F(PluginTest, SetValueGenericAcceptsDimAboveMaxdim) { + PluginLog log("maxdim"); + AL_ASSERT_OK(al_register_plugin(kPlugin)); + + constexpr int kDim = MAXDIM + 1; // one past the documented bound + int size[kDim]; + for (int i = 0; i < kDim; ++i) size[i] = i + 1; + int value = 99; + al_status_t s = al_setvalue_parameter_plugin("oversize", INTEGER_DATA, kDim, + size, &value, kPlugin); + EXPECT_EQ(s.code, 0) + << "dim beyond MAXDIM is not bounds-checked by the core, so the call " + "must still succeed"; + const std::string logged = log.contents(); + EXPECT_NE(logged.find("PARAM|oversize|" + std::to_string(INTEGER_DATA) + + "|" + std::to_string(kDim) + "|-"), + std::string::npos) + << "dim must reach the plugin unmodified (not clamped to MAXDIM); " + "got: [" << logged << "]"; +} + // =========================================================================== // framework-gating: with IMAS_AL_ENABLE_PLUGINS unset the calls must error // (src/al_lowlevel.cpp:116-119), not proceed. @@ -376,6 +425,306 @@ TEST_F(PluginTest, RegisterUnloadableSharedLibSwallowsAssert_CurrentBehavior) { fs::remove_all(dir); } +// =========================================================================== +// al_plugin_* low-level reentry (FUNCTIONALITY_INVENTORY.md:854-895, Part 3 +// cluster 5) — issue #8 ownership sweep. +// +// These are plain C-ABI functions that go straight to +// `lle.backend->{beginAction,writeData,readData,endAction}`, bypassing plugin +// dispatch entirely — they are exactly what al_write_data/al_read_data fall +// back to when no regular plugin is bound to the path +// (al_lowlevel.cpp:1692,1728). No plugin registry object or dlopen'd plugin +// is needed to call them directly: same preconditions as the ordinary +// al_begin_global_action/al_write_data/al_read_data/al_end_action quartet. +// (al_plugin_begin_timerange_action is excluded — a known declaration/ +// definition mismatch, tracked separately per TRACEABILITY.md.) +// =========================================================================== +class PluginReentry : public ::testing::Test { +protected: + al_contract::TempBase base_; + al_contract::PulseId pulse_{/*database=*/"test", /*version=*/"3", + /*pulse=*/12, /*run=*/0}; +}; + +TEST_F(PluginReentry, WriteDataReentersTheBackendDirectly) { + const int pctx = open_memory_pulse(base_, pulse_); + + int opctx = -1; + AL_ASSERT_OK(al_plugin_begin_global_action(pctx, "magnetics", "", + WRITE_OP, &opctx)); + int value = 55; + AL_ASSERT_OK(al_plugin_write_data(opctx, "comment", "", &value, + INTEGER_DATA, 0, nullptr)); + AL_ASSERT_OK(al_plugin_end_action(opctx)); + + // Read back through the *ordinary* ABI to prove al_plugin_write_data + // landed in the real backend storage, not some plugin-side stash. + int op2 = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, "magnetics", "", READ_OP, &op2)); + int got = -1; + AL_ASSERT_OK(al_contract::read_int_scalar(op2, "comment", &got)); + EXPECT_EQ(got, 55); + al_end_action(op2); + + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_F(PluginReentry, ReadDataAlsoReentersTheBackendDirectly) { + const int pctx = open_memory_pulse(base_, pulse_); + + // Write through the ordinary ABI... + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, "magnetics", "", WRITE_OP, &op)); + AL_ASSERT_OK(al_contract::write_int_scalar(op, "comment", 77)); + al_end_action(op); + + // ...and read it back through the plugin reentry path. + int opctx = -1; + AL_ASSERT_OK(al_plugin_begin_global_action(pctx, "magnetics", "", READ_OP, + &opctx)); + int got = -1; + int size[MAXDIM] = {0}; + void* buf = &got; + AL_ASSERT_OK(al_plugin_read_data(opctx, "comment", "", &buf, INTEGER_DATA, + 0, size)); + EXPECT_EQ(got, 77); + AL_ASSERT_OK(al_plugin_end_action(opctx)); + + al_close_pulse(pctx, CLOSE_PULSE); +} + +// =========================================================================== +// Action lifecycle & data interception (FUNCTIONALITY_INVENTORY.md:784-824, +// Part 3 cluster 3) — issue #8 ownership sweep. +// +// al_write_data/al_read_data check LLplugin::getBoundPlugins for the target +// field path *before* touching the backend (al_lowlevel.cpp:1685-1693, +// 1721-1731): if a plugin is bound there, the call dispatches to the +// plugin's write_data/read_data instead of the backend. ContractTestPlugin's +// write_data is an inert no-op and its read_data always returns 0 ("no +// data"), so binding it to a path makes writes there vanish (never reach the +// backend) and reads come back as the "no data" default — proof the plugin, +// not the backend, handled the call. +// =========================================================================== +TEST_F(PluginTest, BoundPluginInterceptsWriteInsteadOfBackend) { + al_contract::TempBase base; + al_contract::PulseId pulse{"test", "3", 12, 0}; + const int pctx = open_memory_pulse(base, pulse); + + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK(al_bind_plugin("magnetics/probe", kPlugin)); + + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, "magnetics", "", WRITE_OP, &op)); + AL_ASSERT_OK(al_contract::write_int_scalar(op, "probe", 42)); + // A sibling field, never bound to any plugin: the normal path, as a + // control proving the value 42 above wasn't lost for some unrelated + // reason. + AL_ASSERT_OK(al_contract::write_int_scalar(op, "control", 42)); + al_end_action(op); + + // Unregister *before* reading back, so the read genuinely reaches the + // backend — otherwise the still-bound plugin's read_data() would also + // intercept the read, and EXPECT_NE(probe, 42) below would pass even if + // the write had actually landed (conflating write-interception with + // read-interception, which BoundPluginInterceptsReadInsteadOfBackend + // covers separately). al_unregister_plugin, not al_unbind_plugin: bind + // normalizes "magnetics/probe" to "magnetics:0/probe" internally + // (al_lowlevel.cpp LLplugin::bindPlugin's occurrence-suffix regex), but + // unbind does no such normalization and looks up the raw string + // unchanged — so al_unbind_plugin("magnetics/probe", kPlugin) here would + // silently miss (confirmed: it prints "No plugin bound..." and leaves + // the binding intact). al_unregister_plugin instead scans boundPlugins + // by plugin name, not by path string, so it isn't sensitive to this + // normalization mismatch. + AL_ASSERT_OK(al_unregister_plugin(kPlugin)); + + int op2 = -1; + AL_ASSERT_OK( + al_begin_global_action(pctx, "magnetics", "", READ_OP, &op2)); + int control = -1; + AL_ASSERT_OK(al_contract::read_int_scalar(op2, "control", &control)); + EXPECT_EQ(control, 42) << "the unbound sibling field must round-trip " + "normally (control for the assertion below)"; + + int probe = -1; + AL_ASSERT_OK(al_contract::read_int_scalar(op2, "probe", &probe)); + EXPECT_NE(probe, 42) + << "the bound plugin's inert write_data() must have absorbed the " + "write — 42 must never have reached the Memory backend, even " + "read back directly (unbound) afterward"; + al_end_action(op2); + al_close_pulse(pctx, CLOSE_PULSE); +} + +TEST_F(PluginTest, BoundPluginInterceptsReadInsteadOfBackend) { + al_contract::TempBase base; + al_contract::PulseId pulse{"test", "3", 12, 0}; + const int pctx = open_memory_pulse(base, pulse); + + // Write for real, with no plugin bound. + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, "magnetics", "", WRITE_OP, &op)); + AL_ASSERT_OK(al_contract::write_int_scalar(op, "probe", 42)); + al_end_action(op); + + // Bind only for the read. + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK(al_bind_plugin("magnetics/probe", kPlugin)); + + int op2 = -1; + AL_ASSERT_OK( + al_begin_global_action(pctx, "magnetics", "", READ_OP, &op2)); + int got = -1; + AL_ASSERT_OK(al_contract::read_int_scalar(op2, "probe", &got)); + EXPECT_NE(got, 42) + << "the bound plugin's read_data() (returns 0, \"no data\") must " + "answer instead of the backend, even though 42 really is stored"; + al_end_action(op2); + al_close_pulse(pctx, CLOSE_PULSE); +} + +// =========================================================================== +// al_write_plugins_metadata (FUNCTIONALITY_INVENTORY.md:422-430, User Cluster +// 3 + Part 3 cluster 1 provenance) — issue #8 ownership sweep. +// +// Writes, under the just-written IDS's "ids_properties/plugins/node" AOS, one +// entry per distinct bound-and-put-capable path: the path itself, a nested +// "put_operation" AOS with the bound plugin's provenance +// (getName/getDescription/getCommit/getVersion/getRepository/getParameters), +// and a nested "readback" AOS built from getReadbackName/* for any plugin +// that opts in — empty here, since ContractTestPlugin declares no readback +// capability for any path (access_layer_plugin_manager.cpp:359-489). +// =========================================================================== +TEST_F(PluginTest, WritePluginsMetadataStoresBoundPluginProvenance) { + al_contract::TempBase base; + al_contract::PulseId pulse{"test", "3", 12, 0}; + const int pctx = open_memory_pulse(base, pulse); + + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK(al_bind_plugin("magnetics/comment", kPlugin)); + + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, "magnetics", "", WRITE_OP, &op)); + AL_ASSERT_OK(al_write_plugins_metadata(op)); + al_end_action(op); + + int op2 = -1; + AL_ASSERT_OK( + al_begin_global_action(pctx, "magnetics", "", READ_OP, &op2)); + int nsize = 0; + int naos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action( + op2, "ids_properties/plugins/node", "", &nsize, &naos)); + ASSERT_EQ(nsize, 1) << "one bound put-capable path -> one node entry"; + + std::string path; + AL_ASSERT_OK(al_contract::read_char_array(naos, "path", &path)); + EXPECT_EQ(path, "comment") + << "the dataobjectname prefix must be stripped from the stored path"; + + int put_size = 0; + int put_aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(naos, "put_operation", "", + &put_size, &put_aos)); + ASSERT_EQ(put_size, 1); + std::string name, version, commit, repository; + AL_ASSERT_OK(al_contract::read_char_array(put_aos, "name", &name)); + AL_ASSERT_OK(al_contract::read_char_array(put_aos, "version", &version)); + AL_ASSERT_OK(al_contract::read_char_array(put_aos, "commit", &commit)); + AL_ASSERT_OK( + al_contract::read_char_array(put_aos, "repository", &repository)); + EXPECT_EQ(name, "alcontract"); + EXPECT_EQ(version, "0.0.0"); + EXPECT_EQ(commit, "0000000"); + EXPECT_EQ(repository, "in-repo"); + if (put_aos != 0) al_end_action(put_aos); + + int readback_size = -1; + int readback_aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(naos, "readback", "", + &readback_size, &readback_aos)); + EXPECT_EQ(readback_size, 0) + << "ContractTestPlugin declares no readback capability for any path"; + if (readback_aos != 0) al_end_action(readback_aos); + + if (naos != 0) al_end_action(naos); + al_end_action(op2); + al_close_pulse(pctx, CLOSE_PULSE); +} + +// =========================================================================== +// al_bind_readback_plugins / al_unbind_readback_plugins +// (FUNCTIONALITY_INVENTORY.md:378-389, User Cluster 3 + Part 3 cluster 4 +// readback metadata) — issue #8 ownership sweep. +// +// Documented as "function called before/after a get()" +// (access_layer_plugin_manager.cpp:52,268): given metadata previously +// written by al_write_plugins_metadata, al_bind_readback_plugins reads it +// back and auto-registers + auto-binds any plugin that declared itself a +// readback provider for the stored path — the whole point being that the +// get()-side caller need not already know which plugins apply. +// al_unbind_readback_plugins then auto-unregisters exactly what it +// auto-bound. ContractTestPlugin opts into being its own readback provider +// for one path via AL_CONTRACT_PLUGIN_READBACK_PATH (test_plugin_fixture.cpp) +// — self-consistent with its own provenance, so the version check in +// AccessLayerPluginManager::bind_readback_plugins passes. +// =========================================================================== +// Same fixture as PluginTest, plus the one extra env var that opts +// ContractTestPlugin into readback capability for a single path. +class ReadbackPlugins : public PluginTest { +protected: + void SetUp() override { + PluginTest::SetUp(); + setenv("AL_CONTRACT_PLUGIN_READBACK_PATH", "comment", 1); + } + void TearDown() override { + unsetenv("AL_CONTRACT_PLUGIN_READBACK_PATH"); + PluginTest::TearDown(); + } +}; + +TEST_F(ReadbackPlugins, BindReadbackPluginsAutoRegistersAndBindsFromStoredMetadata) { + al_contract::TempBase base; + al_contract::PulseId pulse{"test", "3", 12, 0}; + const int pctx = open_memory_pulse(base, pulse); + + // --- put-time: bind, then write metadata describing this plugin as its + // own readback provider for "comment" --- + AL_ASSERT_OK(al_register_plugin(kPlugin)); + AL_ASSERT_OK(al_bind_plugin("magnetics/comment", kPlugin)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, "magnetics", "", WRITE_OP, &op)); + AL_ASSERT_OK(al_write_plugins_metadata(op)); + al_end_action(op); + AL_ASSERT_OK(al_unregister_plugin(kPlugin)); // simulate a fresh session + + ASSERT_FALSE(is_registered(kPlugin)) + << "must start the get() side genuinely unregistered"; + + // --- get-time: bind_readback_plugins must find the stored metadata and + // re-register + bind the plugin on its own --- + int op2 = -1; + AL_ASSERT_OK( + al_begin_global_action(pctx, "magnetics", "", READ_OP, &op2)); + AL_ASSERT_OK(al_bind_readback_plugins(op2)); + EXPECT_TRUE(is_registered(kPlugin)) + << "bind_readback_plugins must auto-register a plugin named in " + "stored metadata"; + al_status_t rebind = al_bind_plugin("magnetics/comment", kPlugin); + EXPECT_NE(rebind.code, 0) + << "the path must already be bound after bind_readback_plugins (a " + "double-bind is a documented error) -- indirect ABI-observable " + "proof of the bind"; + + AL_ASSERT_OK(al_unbind_readback_plugins(op2)); + EXPECT_FALSE(is_registered(kPlugin)) + << "unbind_readback_plugins must auto-unregister what it auto-bound"; + + al_end_action(op2); + al_close_pulse(pctx, CLOSE_PULSE); +} + } // namespace #endif // !_WIN32 diff --git a/tests/contract/test_pulse_lifecycle.cpp b/tests/contract/test_pulse_lifecycle.cpp index 1b95f56d..5aa65bdc 100644 --- a/tests/contract/test_pulse_lifecycle.cpp +++ b/tests/contract/test_pulse_lifecycle.cpp @@ -252,6 +252,28 @@ TEST_F(ContextInfo, ArraystructContextDescribesPathAndIndex) { al_close_pulse(pctx, CLOSE_PULSE); } +// =========================================================================== +// al_build_uri_from_legacy_parameters: caller frees `*uri` (malloc-based, +// src/al_context.cpp:241-261 `malloc`+`memcpy`+NUL) — issue #8 ownership +// sweep. al_contract::build_uri already frees it on every call across this +// whole suite; this test pins the ownership contract explicitly by calling +// the raw ABI function directly instead of through that helper, mirroring +// ContextInfo's explicit-free style above. +// =========================================================================== +TEST(UriOwnership, CallerFreesBuiltUri) { + al_contract::TempBase base; + PulseId pulse{/*database=*/"test", /*version=*/"3", /*pulse=*/12, + /*run=*/0}; + char* uri = nullptr; + al_status_t s = al_build_uri_from_legacy_parameters( + HDF5_BACKEND, pulse.pulse, pulse.run, base.str().c_str(), + pulse.database.c_str(), pulse.version.c_str(), "", &uri); + AL_ASSERT_OK(s); + ASSERT_NE(uri, nullptr); + EXPECT_NE(std::string(uri).find("pulse=12"), std::string::npos) << uri; + free(uri); +} + // =========================================================================== // al_get_backendID. // =========================================================================== From 8ffae4e45b5cf2dbd5aeab2387b8758eadc0849c Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:02:56 +0200 Subject: [PATCH 17/91] Update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index bfbb764a..635fac9a 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,5 @@ python/lib/ /.venv/ /vcpkg/ /UDA-*/ -.idea \ No newline at end of file +.idea +/graphify-out From c2c21db5431e46998b04705e866a0a5552fcc45a Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:39:20 +0200 Subject: [PATCH 18/91] final review updates --- .github/workflows/contract-tests.yml | 44 +++++++++++++++++++++ .github/workflows/sanitizers.yml | 8 +++- tests/contract/CMakeLists.txt | 10 +++-- tests/contract/TRACEABILITY.md | 6 +-- tests/contract/al_contract.h | 3 +- tests/contract/test_plugins.cpp | 5 +-- tests/contract/test_roundtrip_matrix.cpp | 49 ++++++++++++++++-------- 7 files changed, 96 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/contract-tests.yml diff --git a/.github/workflows/contract-tests.yml b/.github/workflows/contract-tests.yml new file mode 100644 index 00000000..e1c6de59 --- /dev/null +++ b/.github/workflows/contract-tests.yml @@ -0,0 +1,44 @@ +name: Contract suite — functional (blocking) + +# PRD user story 23 (issue #1): the whole suite runs in CI on every PR so a +# change breaking any pinned capability fails fast. This is the BLOCKING +# functional leg — a plain (non-sanitized) build that runs everything ctest +# knows about, including the Death tests and the CurrentlyCorrupts tripwires +# that the informational sanitizer leg (sanitizers.yml) excludes. An xfail +# flipping to pass (a DISABLED_ test's tripwire going red) or a green test +# going red both fail this check. + +on: + push: + branches: [develop] + pull_request: + +jobs: + contract-functional: + name: Contract tests (Linux, blocking) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 # git describe needs full history for the version macro + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y ninja-build libhdf5-dev libboost-filesystem-dev + + - name: Configure (tests on, no Python bindings) + run: > + cmake -S . -B build -G Ninja + -DAL_BUILD_TESTS=ON + -DAL_PYTHON_BINDINGS=OFF + -DCMAKE_BUILD_TYPE=RelWithDebInfo + + - name: Build + run: cmake --build build + + - name: Run the whole test suite + working-directory: build + run: ctest --output-on-failure diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index 607ceb51..41a16e2d 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -7,8 +7,11 @@ name: Contract suite — sanitizers (non-blocking) # (continue-on-error: true), until the baseline is triaged. LSan leak detection # is Linux-only, which is why this leg runs on ubuntu. +# push is limited to develop so a PR from an in-repo branch runs the leg once, +# not twice (push + pull_request). on: push: + branches: [develop] pull_request: jobs: @@ -52,8 +55,9 @@ jobs: # * `CurrentlyCorrupts` tripwires pin the ASCII rank-7 DOUBLE/COMPLEX # corruption, which is UB whose manifestation ASan's memory layout # masks (the data round-trips intact under ASan), so they legitimately - # fail here. They still guard against rot in the normal (non-sanitized) - # CI run. Real leaks/UAF in the ordinary paths still surface. + # fail here. They guard against rot in the blocking functional leg + # (contract-tests.yml). Real leaks/UAF in the ordinary paths still + # surface. env: ASAN_OPTIONS: detect_leaks=1:halt_on_error=0:abort_on_error=0 LSAN_OPTIONS: suppressions=${{ github.workspace }}/tests/contract/sanitizer_suppressions.txt:print_suppressions=1 diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 62374316..9de25cb0 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -143,8 +143,10 @@ endif() # is the on-disk tier: # unit = pure lookups (Introspection.*), the in-RAM Memory backend # (*Memory* parametrized instances), the plugin ABI death -# tests (KnownDefects*), and the plugin lifecycle suite -# (Plugin*, an in-process registry — no on-disk backend). +# tests (KnownDefects*), the plugin lifecycle suites +# (Plugin* and ReadbackPlugins.*, an in-process registry over +# the Memory backend — no on-disk backend), and the URI +# builder ownership check (UriOwnership.*, pure string). # integration = everything else (HDF5 / ASCII / Flexbuffers on disk). # Two disjoint discovery passes over the one binary; the TEST_FILTERs partition # the cases so each registers exactly once. TEST_FILTER needs CMake >= 3.22; on @@ -157,10 +159,10 @@ endif() # second pass re-registers the unit cases and CTest sees duplicates. if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") gtest_discover_tests(contract_tests - TEST_FILTER "Introspection.*:*Memory*:KnownDefects*:Plugin*" + TEST_FILTER "Introspection.*:*Memory*:KnownDefects*:Plugin*:ReadbackPlugins.*:UriOwnership.*" PROPERTIES LABELS "contract\;unit") gtest_discover_tests(contract_tests - TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*" + TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:ReadbackPlugins.*:UriOwnership.*" PROPERTIES LABELS "contract\;integration") else() gtest_discover_tests(contract_tests diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 639ef445..a2729951 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -118,7 +118,7 @@ genuine defect (expected-fail). | ↳ Flexbuffers must-refuse column (serializer, not a pulse store): write accepted, read refused, every datatype × shape | :244-263 / D4 | — | ✓ | `…/Flexbuffers_*_r{0..7}` (paired-negative) | covered | | ↳ CHAR > 2-D refused (documented "not implemented") on HDF5·ASCII | :244-263 / D4 | — | ✓ | `…/{HDF5,ASCII}_CHAR_r{3..7}` (paired-negative) | covered | | ↳ CHAR scalar (dim 0) must round-trip — **HDF5 crashes** | :244-263 | — | ✓ | `RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips` + tripwire `RoundTripKnownDefectsDeath.Hdf5CharScalarCurrentlyCrashes` | **xfail** | -| ↳ numeric at MAXDIM (rank 7) must round-trip — **ASCII corrupts (DOUBLE/COMPLEX) / aborts (INTEGER)** | :244-263 | — | ✓ | `RoundTripKnownDefects.DISABLED_Ascii{Integer,Double,Complex}MaxdimRoundTrips` + tripwires `RoundTripKnownDefectsDeath.AsciiIntegerMaxdimCurrentlyAborts`, `RoundTripKnownDefects.Ascii{Double,Complex}MaxdimCurrentlyCorrupts` | **xfail** | +| ↳ 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) | 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** | @@ -250,8 +250,8 @@ whoever fixed it to enable the paired `DISABLED_` correct-contract test: |---|---|---| | HDF5 CHAR scalar crashes | `RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips` | `RoundTripKnownDefectsDeath.Hdf5CharScalarCurrentlyCrashes` | | ASCII rank-7 INTEGER aborts | `…DISABLED_AsciiIntegerMaxdimRoundTrips` | `RoundTripKnownDefectsDeath.AsciiIntegerMaxdimCurrentlyAborts` | -| ASCII rank-7 DOUBLE corrupts | `…DISABLED_AsciiDoubleMaxdimRoundTrips` | `RoundTripKnownDefects.AsciiDoubleMaxdimCurrentlyCorrupts` | -| ASCII rank-7 COMPLEX corrupts | `…DISABLED_AsciiComplexMaxdimRoundTrips` | `RoundTripKnownDefects.AsciiComplexMaxdimCurrentlyCorrupts` | +| ASCII rank-7 DOUBLE corrupts (or crashes) | `…DISABLED_AsciiDoubleMaxdimRoundTrips` | `RoundTripKnownDefectsDeath.AsciiDoubleMaxdimCurrentlyCorrupts` | +| ASCII rank-7 COMPLEX corrupts (or crashes) | `…DISABLED_AsciiComplexMaxdimRoundTrips` | `RoundTripKnownDefectsDeath.AsciiComplexMaxdimCurrentlyCorrupts` | | `build_uri` can't address FLEXBUFFERS | `…DISABLED_BuildUriSupportsFlexbuffers` | `RoundTripKnownDefects.BuildUriFlexbuffersCurrentlyFails` | | slice append doesn't accumulate | `Hdf5SliceAppend.DISABLED_AppendedSlicesAllPersist` | `Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior` | | plugin setvalue (int) unregistered-name crash | `KnownDefects.DISABLED_SetValueIntScalarUnregisteredPluginReturnsError` | `KnownDefectsDeath.SetValueIntScalarUnregisteredPluginCurrentlyCrashes` | diff --git a/tests/contract/al_contract.h b/tests/contract/al_contract.h index e48a5473..2c6b3e46 100644 --- a/tests/contract/al_contract.h +++ b/tests/contract/al_contract.h @@ -49,8 +49,7 @@ inline ::testing::AssertionResult IsOk(const al_status_t& s) { return ::testing::AssertionSuccess(); } return ::testing::AssertionFailure() - << "al_status_t.code=" << s.code - << " message=" << (s.message ? s.message : "(null)"); + << "al_status_t.code=" << s.code << " message=" << s.message; } #define AL_ASSERT_OK(expr) ASSERT_TRUE(::al_contract::IsOk(expr)) diff --git a/tests/contract/test_plugins.cpp b/tests/contract/test_plugins.cpp index 27c681ce..205f9108 100644 --- a/tests/contract/test_plugins.cpp +++ b/tests/contract/test_plugins.cpp @@ -385,8 +385,7 @@ al_status_t register_unloadable(const std::filesystem::path& dir, // robust to the exact downstream text (which depends on dlsym's null-handle // diagnostic, and on no global "create" symbol existing). bool status_reports_dlopen(const al_status_t& s) { - return s.message != nullptr && - std::string(s.message).find("dlopen") != std::string::npos; + return std::string(s.message).find("dlopen") != std::string::npos; } // CORRECT-CONTRACT test, expected-fail (DISABLED_): the error must attribute the @@ -400,7 +399,7 @@ TEST_F(PluginTest, DISABLED_RegisterUnloadableSharedLibReportsDlopenFailure) { EXPECT_NE(s.code, 0); EXPECT_TRUE(status_reports_dlopen(s)) << "correct contract: report the dlopen failure (dlerror). Got: " - << (s.message ? s.message : "(null)"); + << s.message; fs::remove_all(dir); } diff --git a/tests/contract/test_roundtrip_matrix.cpp b/tests/contract/test_roundtrip_matrix.cpp index 298aacd3..85d17fdb 100644 --- a/tests/contract/test_roundtrip_matrix.cpp +++ b/tests/contract/test_roundtrip_matrix.cpp @@ -391,17 +391,29 @@ TEST(RoundTripKnownDefects, DISABLED_AsciiComplexMaxdimRoundTrips) { } // Current-behavior tripwires for the ASCII rank-7 defects, so the DISABLED_ -// correct-contract tests above can't rot. The three datatypes fail differently: -// INTEGER aborts the process (a death test), while DOUBLE and COMPLEX complete -// the op but return corrupted bytes (assert non-survival). When ASCII is fixed -// each tripwire goes red, forcing whoever fixed it to enable the paired -// DISABLED_ test. +// correct-contract tests above can't rot. INTEGER reliably aborts the process +// (a plain death test). DOUBLE and COMPLEX overrun the heap (issue #9): most +// runs complete the op and return corrupted bytes, but the overrun is UB and +// intermittently faults instead (observed: occasional SIGBUS on macOS). Each +// of those tripwires therefore runs the round trip in a death-test child that +// exits kMaxdimSurvivedExit only on an intact round trip — corruption and +// crash both keep the tripwire green; only a clean round trip turns it red, +// forcing whoever fixed ASCII to enable the paired DISABLED_ test. // -// NB: the DOUBLE/COMPLEX corruption is UB — deterministic in ordinary builds but -// *masked under AddressSanitizer* (the data round-trips intact there), so the -// two `…CurrentlyCorrupts` tripwires are excluded from the sanitizer CI leg -// (.github/workflows/sanitizers.yml, `-E CurrentlyCorrupts`). They remain live -// in the normal test run, which is where their rot-guard job matters. +// NB: under AddressSanitizer the corruption is masked (the data round-trips +// intact there), so the two `…CurrentlyCorrupts` tripwires are excluded from +// the sanitizer CI leg (.github/workflows/sanitizers.yml, +// `-E CurrentlyCorrupts`). Their rot-guard job runs in the blocking functional +// leg (.github/workflows/contract-tests.yml) and in local `ctest` runs. +constexpr int kMaxdimSurvivedExit = 77; + +template +void exit_with_maxdim_survival(const BackendCase& backend) { + const bool survives = + maxdim_round_trip_survives(backend, /*rank=*/MAXDIM); + std::_Exit(survives ? kMaxdimSurvivedExit : 0); +} + TEST(RoundTripKnownDefectsDeath, AsciiIntegerMaxdimCurrentlyAborts) { GTEST_FLAG_SET(death_test_style, "threadsafe"); ASSERT_DEATH(run_round_trip_here(kAscii, /*rank=*/MAXDIM), ".*") @@ -410,15 +422,22 @@ TEST(RoundTripKnownDefectsDeath, AsciiIntegerMaxdimCurrentlyAborts) { "RoundTripKnownDefects.DISABLED_AsciiIntegerMaxdimRoundTrips."; } -TEST(RoundTripKnownDefects, AsciiDoubleMaxdimCurrentlyCorrupts) { - EXPECT_FALSE(maxdim_round_trip_survives(kAscii, /*rank=*/MAXDIM)) +TEST(RoundTripKnownDefectsDeath, AsciiDoubleMaxdimCurrentlyCorrupts) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + const ::testing::ExitedWithCode survived(kMaxdimSurvivedExit); + EXPECT_EXIT( + exit_with_maxdim_survival(kAscii), + [&](int status) { return !survived(status); }, "") << "ASCII rank-7 DOUBLE now round-trips intact — the defect is fixed; " "enable RoundTripKnownDefects.DISABLED_AsciiDoubleMaxdimRoundTrips."; } -TEST(RoundTripKnownDefects, AsciiComplexMaxdimCurrentlyCorrupts) { - EXPECT_FALSE( - maxdim_round_trip_survives>(kAscii, /*rank=*/MAXDIM)) +TEST(RoundTripKnownDefectsDeath, AsciiComplexMaxdimCurrentlyCorrupts) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + const ::testing::ExitedWithCode survived(kMaxdimSurvivedExit); + EXPECT_EXIT( + exit_with_maxdim_survival>(kAscii), + [&](int status) { return !survived(status); }, "") << "ASCII rank-7 COMPLEX now round-trips intact — the defect is fixed; " "enable RoundTripKnownDefects.DISABLED_AsciiComplexMaxdimRoundTrips."; } From 86e7f38feaf6e012078af12e427b871fef1a10ca Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:48:19 +0200 Subject: [PATCH 19/91] update : D2 is now consistent, doc-to-code - TEST_STRATEGY.md D2 now states the actual (and better) discipline: each expected-fail's FUNCTIONALITY_INVENTORY / issue citation lives in tests/contract/TRACEABILITY.md as the single source of truth, and test files carry a pointer to the matrix instead of repeating citations in-line. - The three flagged test files (test_roundtrip_matrix.cpp, test_pulse_lifecycle.cpp, test_structured_data.cpp) each gained that one-line pointer in their header comment, so a reader landing on a DISABLED_ test knows exactly where its citation is. --- tests/contract/test_pulse_lifecycle.cpp | 3 +++ tests/contract/test_roundtrip_matrix.cpp | 3 +++ tests/contract/test_structured_data.cpp | 3 +++ 3 files changed, 9 insertions(+) diff --git a/tests/contract/test_pulse_lifecycle.cpp b/tests/contract/test_pulse_lifecycle.cpp index 5aa65bdc..5f442f8f 100644 --- a/tests/contract/test_pulse_lifecycle.cpp +++ b/tests/contract/test_pulse_lifecycle.cpp @@ -7,6 +7,9 @@ // real null-deref defect on the wrong context type), and al_close_pulse's // CLOSE_PULSE vs ERASE_PULSE distinction. // +// Per D2, the FUNCTIONALITY_INVENTORY / issue citation for every DISABLED_ +// expected-fail below lives in TRACEABILITY.md (xfail bookkeeping table). +// // Flexbuffers is excluded from the mode-matrix and erase tests: it is a pure // in-process serialize/deserialize buffer with no persistent pulse state, so it // does not distinguish among the four open modes at all (src/flexbuffers_backend.cpp diff --git a/tests/contract/test_roundtrip_matrix.cpp b/tests/contract/test_roundtrip_matrix.cpp index 85d17fdb..07fa10ab 100644 --- a/tests/contract/test_roundtrip_matrix.cpp +++ b/tests/contract/test_roundtrip_matrix.cpp @@ -30,6 +30,9 @@ // handle; the cell is skipped here and the *correct* contract // is asserted (expected-fail) in the RoundTripKnownDefects.* // tests below, so a fix flips them to pass. +// +// Per D2, the FUNCTIONALITY_INVENTORY / issue citation for every DISABLED_ +// expected-fail below lives in TRACEABILITY.md (xfail bookkeeping table). #include "al_contract.h" diff --git a/tests/contract/test_structured_data.cpp b/tests/contract/test_structured_data.cpp index 387310d8..24465996 100644 --- a/tests/contract/test_structured_data.cpp +++ b/tests/contract/test_structured_data.cpp @@ -5,6 +5,9 @@ // nested), al_delete_data at its three documented granularities, // al_get_occurrences, and the realistic equilibrium-seed round trip. // +// Per D2, the FUNCTIONALITY_INVENTORY / issue citation for every DISABLED_ +// expected-fail below lives in TRACEABILITY.md (xfail bookkeeping table). +// // Same idioms as the existing suite: al_contract.h for the fixture/generator // layer, a per-file backend-capability table + TEST_P (test_capability_gated.cpp), // and local DISABLED_/tripwire pairs for genuine defects, never frozen as spec From 353e4ca07ae81fa6e3996fbf3317cdb865faaa1b Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:27:31 +0200 Subject: [PATCH 20/91] Add MDSplus tracer-bullet contract test (issue #13) Stands up a real, runnable MDSplus in an aarch64 Docker image (docker/mdsplus/), bakes a DD-4.1.1 model tree, and proves one equilibrium scalar survives a write->read round trip through the public C ABI against it -- lighting up the whole characterization stack (build gate, runtime skip, CTest label) before any behavioral breadth is attempted per issue #12. - tests/contract/test_mdsplus.cpp: MdsplusTracerBullet.ScalarSurvivesWriteThenRead, reusing equilibrium_seed.h's real DD path/value. Compiles out entirely unless AL_CONTRACT_HAVE_MDSPLUS is defined; GTEST_SKIP()s at runtime when MDSPLUS_MODELS_PATH is unset (TEST_STRATEGY.md D4: absent/skipped, never failed). - tests/contract/CMakeLists.txt: defines AL_CONTRACT_HAVE_MDSPLUS when AL_BACKEND_MDSPLUS=ON, and adds a new `mdsplus` CTest label. Also documents (without fixing, out of scope) a pre-existing gtest_discover_tests defect found while wiring this up: multi-element LABELS lists silently collapse to their first token, so `ctest -L unit`/`-L integration` currently select nothing -- the new mdsplus pass sidesteps it with a single-element list. - docker/mdsplus/: Ubuntu 24.04 (not the issue's suggested 22.04 -- MDSplus publishes no arm64 .debs for 22.04/20.04, empirically confirmed) with MDSplus, a JDK for the model-tree compiler, and the DD-4.1.1 bake via pip-installed imas_data_dictionary. Verified end-to-end: image builds, model bakes, contract suite builds with AL_BACKEND_MDSPLUS=ON, and `ctest -L mdsplus` passes. - tests/contract/TRACEABILITY.md: new Part 4 section (MDSplus backend, compile-guarded tier) with the four-status scaffolding and the tracer bullet's first row. --- docker/mdsplus/Dockerfile | 48 +++++++++++++++++ docker/mdsplus/README.md | 59 +++++++++++++++++++++ tests/contract/CMakeLists.txt | 33 +++++++++++- tests/contract/TRACEABILITY.md | 30 +++++++++++ tests/contract/test_mdsplus.cpp | 94 +++++++++++++++++++++++++++++++++ 5 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 docker/mdsplus/Dockerfile create mode 100644 docker/mdsplus/README.md create mode 100644 tests/contract/test_mdsplus.cpp diff --git a/docker/mdsplus/Dockerfile b/docker/mdsplus/Dockerfile new file mode 100644 index 00000000..96248d10 --- /dev/null +++ b/docker/mdsplus/Dockerfile @@ -0,0 +1,48 @@ +# MDSplus characterization environment (issue #13, tracer bullet for #12). +# +# aarch64 (native on Apple Silicon) image that bakes a DD-4.1.1 MDSplus model +# tree and builds the contract suite with AL_BACKEND_MDSPLUS=ON. See +# docker/mdsplus/README.md for the characterization-discovered deviation from +# the issue's ubuntu:22.04 starting point (no arm64 MDSplus .debs exist for +# Ubuntu 22; Ubuntu 24 is the first LTS with an arm64 repo) and for the run +# recipe. +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# --- MDSplus apt repo ------------------------------------------------------- +# The repo key is an ASCII-armored PGP block; apt's signed-by wants a binary +# keyring, hence the dearmor step. Plain http (not https): www.mdsplus.org's +# TLS chain is missing the InCommon intermediate cert, so a fresh container's +# default trust store cannot verify it (characterization-discovered; a +# browser/host with a cached intermediate does not hit this). The repo +# content is still authenticated via the dearmored signed-by key below. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates gnupg wget \ + && mkdir -p /etc/apt/keyrings \ + && wget -qO- http://www.mdsplus.org/dist/mdsplus.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/mdsplus.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/mdsplus.gpg] http://www.mdsplus.org/dist/Ubuntu24/repo MDSplus stable" \ + > /etc/apt/sources.list.d/mdsplus.list \ + && apt-get update + +# --- Toolchain + MDSplus + DD-bake dependencies ----------------------------- +# mdsplus-devel: mdslib.h + link libs (common/cmake/FindMDSplus.cmake). +# mdsplus-java: jTraverser.jar (models/mdsplus/CMakeLists.txt CompileTree) + +# pulls a java-runtime. +# python3/venv: ALBuildDataDictionary.cmake's saxonche venv + pip-installs +# imas_data_dictionary==4.1.1 from PyPI (no DD git clone needed). +RUN apt-get install -y --no-install-recommends \ + mdsplus mdsplus-devel mdsplus-java \ + build-essential cmake git ninja-build pkg-config \ + libboost-filesystem-dev libhdf5-dev \ + python3 python3-venv python3-pip python3-dev \ + openjdk-21-jdk-headless \ + && rm -rf /var/lib/apt/lists/* + +ENV MDSPLUS_DIR=/usr/local/mdsplus +ENV PATH="${MDSPLUS_DIR}/bin:${PATH}" +ENV LD_LIBRARY_PATH="${MDSPLUS_DIR}/lib:${LD_LIBRARY_PATH}" +ENV CLASSPATH="${MDSPLUS_DIR}/java/classes/jTraverser.jar" + +WORKDIR /workspace diff --git a/docker/mdsplus/README.md b/docker/mdsplus/README.md new file mode 100644 index 00000000..d2230fad --- /dev/null +++ b/docker/mdsplus/README.md @@ -0,0 +1,59 @@ +# MDSplus characterization environment (issue #13) + +aarch64 image standing up a real, runnable MDSplus for the contract suite's +MDSplus tier (`tests/contract/test_mdsplus.cpp`, `TRACEABILITY.md` Part 4). + +## Deviation from the issue's starting point + +Issue #12/#13 specified `FROM ubuntu:22.04`. Empirically, MDSplus does not +publish arm64 `.deb` packages for Ubuntu 22 (or 20): its +`https://www.mdsplus.org/dist/Ubuntu22/repo/dists/MDSplus/stable/binary-arm64/Packages` +is a 0-byte file. Ubuntu 24.04 is the first LTS with a populated arm64 +repo, so this image uses `ubuntu:24.04` instead — still native aarch64 (no +emulation), which is what the issue's Apple-Silicon developer story (#12 +user story 8) actually needs. The issue's stated amd64-emulation fallback is +unnecessary as a result. + +## Build + +```sh +docker build --platform linux/arm64 -f docker/mdsplus/Dockerfile \ + -t imas-core-mdsplus:dev docker/mdsplus +``` + +## Bake the DD-4.1.1 model tree and build the contract suite + +Run from the repository root, mounting it into the container: + +```sh +docker run --rm --platform linux/arm64 \ + -v "$PWD":/workspace -w /workspace \ + imas-core-mdsplus:dev bash -c ' + cmake -B build-mdsplus -G Ninja \ + -DAL_BACKEND_MDSPLUS=ON \ + -DAL_BUILD_MDSPLUS_MODELS=ON \ + -DAL_BUILD_TESTS=ON \ + -DDD_VERSION=4.1.1 \ + -DCMAKE_BUILD_TYPE=Release && + cmake --build build-mdsplus -j$(nproc) + ' +``` + +`-DDD_VERSION=4.1.1` with the default `AL_DOWNLOAD_DEPENDENCIES=ON` makes +`common/cmake/ALBuildDataDictionary.cmake` `pip install +imas_data_dictionary==4.1.1` from PyPI — no data-dictionary git clone needed. +The model tree (`ids_model.tree`/`.datafile`/`.characteristics`) lands under +`build-mdsplus/models/mdsplus/`. + +## Run the MDSplus tests + +```sh +docker run --rm --platform linux/arm64 \ + -v "$PWD":/workspace -w /workspace \ + -e MDSPLUS_MODELS_PATH=/workspace/build-mdsplus/models/mdsplus \ + imas-core-mdsplus:dev bash -c \ + 'cd build-mdsplus/tests/contract && ctest -L mdsplus --output-on-failure' +``` + +Omitting `MDSPLUS_MODELS_PATH` makes the same tests report `Skipped` rather +than failing (`GTEST_SKIP()`, TEST_STRATEGY.md D4). diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 62374316..52edf30d 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -70,12 +70,26 @@ add_executable(contract_tests test_plugins.cpp test_structured_data.cpp test_pulse_lifecycle.cpp + test_mdsplus.cpp ) target_link_libraries(contract_tests PRIVATE al GTest::gtest GTest::gtest_main) add_dependencies(contract_tests al) target_compile_features(contract_tests PRIVATE cxx_std_17) +######################################################################################################################## +# MDSplus build gate (issue #13 / TEST_STRATEGY.md D4 "compile-guarded tier"). +# +# test_mdsplus.cpp wraps its whole body in #ifdef AL_CONTRACT_HAVE_MDSPLUS, so +# it compiles out to nothing when the backend is off — a developer without +# MDSplus installed never pays for it, and CI legs that build with +# AL_BACKEND_MDSPLUS=OFF are unaffected. When the backend is on, the file's +# lone test still runtime-skips (GTEST_SKIP()) if MDSPLUS_MODELS_PATH is +# unset, so "on but unconfigured" never fails either. +if(AL_BACKEND_MDSPLUS) + target_compile_definitions(contract_tests PRIVATE AL_CONTRACT_HAVE_MDSPLUS) +endif() + ######################################################################################################################## # Test plugin fixture (issue #7, TEST_STRATEGY.md step 5). # @@ -155,13 +169,30 @@ endif() # TEST_FILTER uses gtest's one-"-"-then-colon-separated negative syntax # ("*:-a:b:c", NOT "*:-a:-b:-c") so the two passes stay disjoint — otherwise the # second pass re-registers the unit cases and CTest sees duplicates. +# MdsplusTracerBullet* is carved out of the integration pass into its own +# third pass (issue #13: new CTest label "mdsplus") so `ctest -L mdsplus` +# selects it without also pulling in every other on-disk backend case. +# +# Empirically discovered running this against a real build (issue #13, +# CMake 3.28 + Ninja): gtest_discover_tests' PROPERTIES forwarding does NOT +# actually preserve a multi-element LABELS list through its escaped-";" +# encoding -- it collapses to just the first token ("contract"), silently +# dropping "unit"/"integration" as bogus trailing property names with no +# value. That pre-existing defect (present in the two passes below +# unmodified by this issue) means `ctest -L unit`/`-L integration` currently +# select nothing; it is out of this issue's scope to fix. The new mdsplus +# pass sidesteps it by using a single-element LABELS list, which has no +# separator to lose. if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") gtest_discover_tests(contract_tests TEST_FILTER "Introspection.*:*Memory*:KnownDefects*:Plugin*" PROPERTIES LABELS "contract\;unit") gtest_discover_tests(contract_tests - TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*" + TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:MdsplusTracerBullet*" PROPERTIES LABELS "contract\;integration") + gtest_discover_tests(contract_tests + TEST_FILTER "MdsplusTracerBullet*" + PROPERTIES LABELS "mdsplus") else() gtest_discover_tests(contract_tests PROPERTIES LABELS "contract") diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 639ef445..585b3355 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -268,3 +268,33 @@ whoever fixed it to enable the paired `DISABLED_` correct-contract test: | HDF5 leaf/structure delete wipes the whole occurrence | `DeleteKnownDefects.DISABLED_Hdf5LeafDeleteLeavesSiblingIntact` | `DeleteKnownDefects.Hdf5LeafDeleteCurrentlyWipesWholeOccurrence` | | Memory root delete is a no-op | `DeleteKnownDefects.DISABLED_MemoryRootDeleteClearsWholeIds` | `DeleteKnownDefects.MemoryRootDeleteCurrentlyDoesNothing` | | ASCII delete is a no-op at every granularity | `DeleteKnownDefects.DISABLED_AsciiDeleteRemovesLeaf` | `DeleteKnownDefects.AsciiDeleteIsCurrentlyANoOp` | + +--- + +# Part 4 — MDSplus backend (compile-guarded tier) (issue #12) + +**Progress: 1 row landed so far** (tracer bullet, issue #13); the parity and +unique rows below are not yet enumerated one-by-one (that's later #12 +sub-issues' job). Parity rows (a +MDSplus instantiation of `EquilibriumSeedMatrix`/`CapabilityMatrix`/ +`AosMatrix`/`DataEntryModes`/`Occurrences`) and unique rows (struct-aware +slice interpolation, timerange resampling, segments, timebase cache, +version-drift check) are `gap (deferred — later #12 sub-issue)` until those +issues land. Per issue #12's definition of done, every row must eventually +reach one of four terminal statuses — `covered` / `xfail` / `divergence` / +`terminal-gap` — and none may stay `gap`. + +Build-gated by `AL_CONTRACT_HAVE_MDSPLUS` (`tests/contract/CMakeLists.txt`, +defined only when `AL_BACKEND_MDSPLUS=ON`), so `test_mdsplus.cpp` compiles +out entirely otherwise. Runtime-skipped (`GTEST_SKIP()`, never failed, per +D4) when `MDSPLUS_MODELS_PATH` is unset. New CTest label `mdsplus` +(`ctest -L mdsplus`). Characterization environment: `docker/mdsplus/` +(aarch64, DD 4.1.1 baked model tree — see its README for the +characterization-discovered deviation from the issue's Ubuntu 22.04 starting +point). + +| Cluster / Capability | Test(s) | Status | +|---|---|---| +| Tracer bullet: one equilibrium scalar (`vacuum_toroidal_field/r0`, reusing `equilibrium_seed.h`) write→read self-consistency round trip through the C ABI against a real MDSplus tree | `MdsplusTracerBullet.ScalarSurvivesWriteThenRead` | covered | +| Parity: MDSplus instantiation of `EquilibriumSeedMatrix`, `CapabilityMatrix`, `AosMatrix`, `DataEntryModes`, `Occurrences` | — | gap (deferred — later #12 sub-issue) | +| Unique: struct-aware slice interpolation, timerange resampling, segments, timebase cache, version-drift check | — | gap (deferred — later #12 sub-issue) | diff --git a/tests/contract/test_mdsplus.cpp b/tests/contract/test_mdsplus.cpp new file mode 100644 index 00000000..99da9d98 --- /dev/null +++ b/tests/contract/test_mdsplus.cpp @@ -0,0 +1,94 @@ +// MDSplus tracer-bullet round trip (issue #13, TRACEABILITY.md Part 4 row 1). +// +// Proves the whole MDSplus characterization machine end-to-end before any +// behavioral breadth is attempted (TEST_STRATEGY.md §4, issue #12's +// vertical-slice-1 scoping note): one equilibrium scalar round-trips through +// the public C ABI against a real MDSplus tree baked from DD 4.1.1 +// (AL_BUILD_MDSPLUS_MODELS=ON, see docker/mdsplus/). Reuses the real DD path +// and value from equilibrium_seed.h rather than inventing a new one, since +// MDSplus resolves paths against its model tree (issue #12 Q2) and cannot +// take the synthetic opaque paths RoundTrip/RoundTripMatrix use. +// +// Build-gated by AL_CONTRACT_HAVE_MDSPLUS (defined in CMakeLists.txt only +// when AL_BACKEND_MDSPLUS=ON) so the file compiles out entirely otherwise; +// runtime-skipped (GTEST_SKIP()) when MDSPLUS_MODELS_PATH is unset, per +// TEST_STRATEGY D4 — absent/skipped, never failed. +#ifdef AL_CONTRACT_HAVE_MDSPLUS + +#include "al_contract.h" +#include "equilibrium_seed.h" + +#include +#include + +#include + +#include +#include +#include + +using al_contract::PulseId; + +namespace { + +class MdsplusTracerBullet : public ::testing::Test { +protected: + void SetUp() override { + const char* models_path = std::getenv("MDSPLUS_MODELS_PATH"); + if (!models_path || !*models_path) { + GTEST_SKIP() << "MDSPLUS_MODELS_PATH is unset -- MDSplus " + "characterization tests are skipped, not " + "failed (TEST_STRATEGY.md D4)."; + } + } + + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/13, + /*run=*/0}; +}; + +TEST_F(MdsplusTracerBullet, ScalarSurvivesWriteThenRead) { + base_.make_legacy_tree(pulse_); + + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + + const double kValue = equilibrium_seed::scalar_r0(); + + // --- write --- + { + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, + "", WRITE_OP, &op_ctx)); + AL_EXPECT_OK(al_contract::write_data( + op_ctx, equilibrium_seed::kScalar, {}, {kValue})); + AL_ASSERT_OK(al_end_action(op_ctx)); + } + + // --- read back --- + { + int op_ctx = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, + "", READ_OP, &op_ctx)); + + std::vector shape; + std::vector value; + AL_EXPECT_OK(al_contract::read_data( + op_ctx, equilibrium_seed::kScalar, 0, &shape, &value)); + ASSERT_EQ(value.size(), 1u); + EXPECT_EQ(value.at(0), kValue); + + AL_ASSERT_OK(al_end_action(op_ctx)); + } + + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); +} + +} // namespace + +#endif // AL_CONTRACT_HAVE_MDSPLUS From ce74eba60a441343270ec355a5f141df8d60d359 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:22:35 +0200 Subject: [PATCH 21/91] Join MDSplus to the parametrized contract fixtures (issue #14) Adds MDSplus as a parameter/case to DataEntryModes, CapabilityMatrix, Occurrences, and AosMatrix, reusing their existing test bodies, and triages every resulting outcome into Part 4 of TRACEABILITY.md. Empirically resolved against a real DD-4.1.1 model tree (docker/mdsplus/): occurrence naming is "/" (a third convention, same slash form as ASCII); get_occurrences is real (gated on homogeneous_time being set); list_filled_paths always throws (BACKEND_ERR); slice read/write genuinely round-trip (supportsTimeDataInterpolation() is unconditionally true) while timerange is refused (LOWLEVEL_ERR, supportsTimeRangeOperation() is unconditionally false); and DataEntryModes' CREATE_PULSE guard matches HDF5/Memory, not ASCII's defect. AosMatrix and EquilibriumSeedMatrix hit the same divergence: MDSplus resolves every path against its real DD-baked model tree, so their synthetic/DD-agnostic field shapes ("elements"/"val", the seed's flat profiles_1d/psi write, the generic constraints AOS) throw Node Not Found at flush time. This is a legitimate storage-model difference, not a defect, so AosMatrix's MDSplus case is skipped (via a new AosExpect:: Divergence, mirroring the existing KnownDefect skip) and MDSplus is left out of EquilibriumSeedMatrix's backend list (like Flexbuffers already is, for its own reason) rather than landing a permanently red test -- both are documented in-place and in TRACEABILITY.md. No genuine defect surfaced this round, so no new xfail pairs. --- tests/contract/CMakeLists.txt | 9 +- tests/contract/TRACEABILITY.md | 85 ++++++++++++++---- tests/contract/test_capability_gated.cpp | 28 ++++++ tests/contract/test_pulse_lifecycle.cpp | 20 +++++ tests/contract/test_structured_data.cpp | 106 ++++++++++++++++++++++- 5 files changed, 227 insertions(+), 21 deletions(-) diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 52edf30d..5b6794cd 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -172,6 +172,11 @@ endif() # MdsplusTracerBullet* is carved out of the integration pass into its own # third pass (issue #13: new CTest label "mdsplus") so `ctest -L mdsplus` # selects it without also pulling in every other on-disk backend case. +# Issue #14 joins MDSplus onto four more fixtures (DataEntryModes, +# CapabilityMatrix, AosMatrix, Occurrences) as a "Mdsplus"-named parameter/ +# case; "*/Mdsplus" and "Occurrences.Mdsplus*" carve those into the same +# mdsplus pass so `ctest -L mdsplus` covers the whole parity set, not just +# the tracer bullet. # # Empirically discovered running this against a real build (issue #13, # CMake 3.28 + Ninja): gtest_discover_tests' PROPERTIES forwarding does NOT @@ -188,10 +193,10 @@ if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") TEST_FILTER "Introspection.*:*Memory*:KnownDefects*:Plugin*" PROPERTIES LABELS "contract\;unit") gtest_discover_tests(contract_tests - TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:MdsplusTracerBullet*" + TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:MdsplusTracerBullet*:*\/Mdsplus:Occurrences.Mdsplus*" PROPERTIES LABELS "contract\;integration") gtest_discover_tests(contract_tests - TEST_FILTER "MdsplusTracerBullet*" + TEST_FILTER "MdsplusTracerBullet*:*\/Mdsplus:Occurrences.Mdsplus*" PROPERTIES LABELS "mdsplus") else() gtest_discover_tests(contract_tests diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 585b3355..7cd15d90 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -273,28 +273,77 @@ whoever fixed it to enable the paired `DISABLED_` correct-contract test: # Part 4 — MDSplus backend (compile-guarded tier) (issue #12) -**Progress: 1 row landed so far** (tracer bullet, issue #13); the parity and -unique rows below are not yet enumerated one-by-one (that's later #12 -sub-issues' job). Parity rows (a -MDSplus instantiation of `EquilibriumSeedMatrix`/`CapabilityMatrix`/ -`AosMatrix`/`DataEntryModes`/`Occurrences`) and unique rows (struct-aware -slice interpolation, timerange resampling, segments, timebase cache, -version-drift check) are `gap (deferred — later #12 sub-issue)` until those -issues land. Per issue #12's definition of done, every row must eventually -reach one of four terminal statuses — `covered` / `xfail` / `divergence` / -`terminal-gap` — and none may stay `gap`. +**Progress: parity sweep landed (issue #14), on top of the tracer bullet +(issue #13).** MDSplus is now a parameter/case in all five fixtures issue #14 +named — `DataEntryModes`, `CapabilityMatrix`, `Occurrences`, +`AosMatrix`, `EquilibriumSeedMatrix` — with every resulting row triaged to a +terminal status (`covered` or `divergence`; no genuine defect surfaced, so no +`xfail` this round). The MDSplus-unique surface (struct-aware slice +interpolation, timerange resampling, segments, timebase cache, version-drift +check) remains `gap (deferred — later #12 sub-issue)`. Build-gated by `AL_CONTRACT_HAVE_MDSPLUS` (`tests/contract/CMakeLists.txt`, -defined only when `AL_BACKEND_MDSPLUS=ON`), so `test_mdsplus.cpp` compiles -out entirely otherwise. Runtime-skipped (`GTEST_SKIP()`, never failed, per -D4) when `MDSPLUS_MODELS_PATH` is unset. New CTest label `mdsplus` -(`ctest -L mdsplus`). Characterization environment: `docker/mdsplus/` -(aarch64, DD 4.1.1 baked model tree — see its README for the -characterization-discovered deviation from the issue's Ubuntu 22.04 starting -point). +defined only when `AL_BACKEND_MDSPLUS=ON`), so every MDSplus case in +`test_mdsplus.cpp`, `test_pulse_lifecycle.cpp`, `test_structured_data.cpp`, +and `test_capability_gated.cpp` compiles out entirely otherwise. +Runtime-skipped (`GTEST_SKIP()`, never failed, per D4) when +`MDSPLUS_MODELS_PATH` is unset. CTest label `mdsplus` (`ctest -L mdsplus`) +now covers the whole parity set, not just the tracer bullet (issue #14 +extended the label's `TEST_FILTER` carve-out in `CMakeLists.txt`). +Characterization environment: `docker/mdsplus/` (aarch64, DD 4.1.1 baked +model tree — see its README for the characterization-discovered deviation +from the issue's Ubuntu 22.04 starting point). + +## Characterization-discovered facts (issue #14) + +Resolved empirically against the real baked DD-4.1.1 model tree (issue #12's +explicit open items), not assumed: + +- **Occurrence-naming convention**: a *third* convention, distinct from HDF5's + `_` and ASCII's `/` — except it turns out to be the exact + same slash form as ASCII, `/`. `mdsconvertPath`'s `SEPARATORS` + (`src/mdsplus/mdsplus_backend.cpp:25`) tokenizes on `/`, so + `"equilibrium/2"` resolves to the real, pre-baked structural child node the + model tree already contains for occurrence 2 — occurrence slots are static + model structure, never dynamically created. Confirmed: + `Occurrences.MdsplusListsWrittenOccurrences` writes occurrence 0 and + occurrence 2 (as `"equilibrium/2"`) and gets both back from + `al_get_occurrences`. +- **`get_occurrences` is real** (`src/mdsplus/mdsplus_backend.cpp:4887-4954`): + it walks the IDS node's numeric-named children and reports an occurrence as + "filled" iff its `ids_properties/homogeneous_time` has nonzero length — so a + write that sets only a data field, without `homogeneous_time`, is invisible + to `get_occurrences` even though the data is genuinely there. +- **`list_filled_paths` is NOT real**: it throws unconditionally + (`src/mdsplus/mdsplus_backend.cpp:4956-4958`, "not implemented in the + MDSplus Backend") — `BACKEND_ERR`, matching every other non-HDF5 always-on + backend. Confirmed via `CapabilityMatrix.ListFilledPathsPositiveOrRefused/Mdsplus`. +- **`supportsTimeDataInterpolation()` is unconditionally `true`** + (`src/mdsplus/mdsplus_backend.h:255-257`) and slice read/write genuinely + round-trip (confirmed: a `CLOSEST_INTERP` slice read returns the exact + written value). **`supportsTimeRangeOperation()` is unconditionally `false`** + (`src/mdsplus/mdsplus_backend.cpp:5444-5446`) → `LOWLEVEL_ERR`, matching + Memory/ASCII/Flexbuffers. +- **MDSplus requires real, DD-conformant paths for anything beyond a plain + scalar** (issue #12 Q2's premise, now with concrete evidence): a synthetic + field name with no corresponding model-tree node throws `%TREE-W-NNF, Node + Not Found` — immediately for a plain (non-timed) `al_write_data`, but + **deferred to `al_end_action`** for a timed write or an AOS flush (the value + is buffered in an in-memory `MDSplus::Apd`/segment first). This is why + `AosMatrix`'s and `EquilibriumSeedMatrix`'s synthetic-path bodies fail + MDSplus at flush time even though their early write calls report success — + see the divergence rows below. A real DD-conformant AOS + (`equilibrium/time_slice/global_quantities/ip`, confirmed separately during + characterization) round-trips through the identical begin/write/iterate/end + sequence without error, so this is specific to non-conformant paths, not a + general MDSplus AOS limitation. | Cluster / Capability | Test(s) | Status | |---|---|---| | Tracer bullet: one equilibrium scalar (`vacuum_toroidal_field/r0`, reusing `equilibrium_seed.h`) write→read self-consistency round trip through the C ABI against a real MDSplus tree | `MdsplusTracerBullet.ScalarSurvivesWriteThenRead` | covered | -| Parity: MDSplus instantiation of `EquilibriumSeedMatrix`, `CapabilityMatrix`, `AosMatrix`, `DataEntryModes`, `Occurrences` | — | gap (deferred — later #12 sub-issue) | +| Parity: `DataEntryModes` mode matrix (`OPEN_PULSE` absent/present, `FORCE_OPEN_PULSE`, `CREATE_PULSE` guard) — `openPulse`'s mode switch guards `CREATE_PULSE` against an existing `ids_001.tree` exactly like HDF5/Memory (`src/mdsplus/mdsplus_backend.cpp:4507-4510`); no ASCII-style defect | `Backends/DataEntryModes.*/Mdsplus` (4 cases) | covered | +| Parity: `CapabilityMatrix` — slice read/write positive (real interpolation round trip), timerange refused (`LOWLEVEL_ERR`), `list_filled_paths` refused (`BACKEND_ERR`) | `Backends/CapabilityMatrix.{TimeRangeReadPositiveOrRefused,SliceReadPositiveOrRefused,SliceWriteBeginPositiveOrRefused,ListFilledPathsPositiveOrRefused}/Mdsplus` (4 cases) | covered | +| Parity: `Occurrences` — real implementation, `/` naming convention (see characterization facts above) | `Occurrences.MdsplusListsWrittenOccurrences` | covered | +| Parity: `AosMatrix` (top-level + nested write→iterate→read) — **divergence, not a defect**: MDSplus resolves every path against its real DD-baked model tree, so this fixture's synthetic field names (`"elements"`/`"val"`, `"outer"`/`"inner"`) have no corresponding node; `al_begin_arraystruct_action` and the per-element writes report success (buffered), but the flush at `al_end_action` throws `%TREE-W-NNF, Node Not Found`. Real DD-conformant AOS paths round-trip through the identical sequence (see characterization facts above) — the fixture's synthetic-path design simply cannot transfer to a model-tree-backed backend, the same reason `RoundTripMatrix` already excludes MDSplus (issue #12 Q5) | `Backends/AosMatrix.{TopLevelWriteIterateRead,NestedWriteIterateRead}/Mdsplus` (both `GTEST_SKIP()`, `AosExpect::Divergence`) | divergence | +| Parity: `EquilibriumSeedMatrix` (composite scalar + timebase-carrying 2-D array + `constraints` AOS, hash oracle) — **divergence, not a defect**: the scalar sub-shape round-trips (already proven by the tracer bullet above), but the seed's flat-tensorized `profiles_1d/psi` write and its generic `constraints`/`{measured,weight}` AOS shape don't match the real equilibrium DD layout MDSplus enforces (real `profiles_1d` is a genuine dynamic AOS, not a flat dataset at that path; real `constraints` is a fixed container of specific constraint sub-objects, not a generic AOS) — both throw `%TREE-W-NNF, Node Not Found`. MDSplus is therefore **not instantiated** in `kSeedBackends[]` (same treatment as Flexbuffers, excluded for its own, different reason) rather than left as a permanently-red parametrized case; the exclusion and full reasoning are documented in `test_structured_data.cpp`'s `EquilibriumSeedMatrix` header comment | — (see `test_structured_data.cpp` comment; not instantiated) | divergence | | Unique: struct-aware slice interpolation, timerange resampling, segments, timebase cache, version-drift check | — | gap (deferred — later #12 sub-issue) | diff --git a/tests/contract/test_capability_gated.cpp b/tests/contract/test_capability_gated.cpp index 93416e0b..8dd4d0e2 100644 --- a/tests/contract/test_capability_gated.cpp +++ b/tests/contract/test_capability_gated.cpp @@ -41,6 +41,7 @@ #include +#include #include #include #include @@ -86,6 +87,20 @@ const CapBackend kBackends[] = { /*timerange=*/false, /*listpaths=*/false}, {FLEXBUFFERS_BACKEND, "Flexbuffers", /*on_disk=*/true, /*slice=*/false, /*timerange=*/false, /*listpaths=*/false}, +#ifdef AL_CONTRACT_HAVE_MDSPLUS + // Confirmed empirically (issue #14): seed_signal's fields ("ip" as a + // top-level dynamic leaf of "magnetics", timebase "time") are real DD + // paths, so this fixture is fully applicable, unlike AosMatrix/ + // EquilibriumSeedMatrix above. supportsTimeDataInterpolation() is + // unconditionally true (src/mdsplus/mdsplus_backend.h:255-257) and slice + // read genuinely round-trips (CLOSEST/read gave back the written value). + // supportsTimeRangeOperation() is unconditionally false + // (src/mdsplus/mdsplus_backend.cpp:5444-5446) -> LOWLEVEL_ERR, matching + // the always-on non-HDF5 backends. list_filled_paths throws + // unconditionally (src/mdsplus/mdsplus_backend.cpp:4956-4958) -> BACKEND_ERR. + {MDSPLUS_BACKEND, "Mdsplus", /*on_disk=*/true, /*slice=*/true, + /*timerange=*/false, /*listpaths=*/false}, +#endif }; // Write the 2-point dynamic signal through an ordinary GLOBAL write. Slice/ @@ -127,6 +142,19 @@ al_status_t read_slice_value(int op, double* out) { // =========================================================================== class CapabilityMatrix : public ::testing::TestWithParam { protected: + void SetUp() override { +#ifdef AL_CONTRACT_HAVE_MDSPLUS + if (GetParam().id == MDSPLUS_BACKEND) { + const char* models_path = std::getenv("MDSPLUS_MODELS_PATH"); + if (!models_path || !*models_path) { + GTEST_SKIP() << "MDSPLUS_MODELS_PATH is unset -- MDSplus " + "characterization tests are skipped, not " + "failed (TEST_STRATEGY.md D4)."; + } + } +#endif + } + al_contract::TempBase base_; PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; diff --git a/tests/contract/test_pulse_lifecycle.cpp b/tests/contract/test_pulse_lifecycle.cpp index 5aa65bdc..df014bad 100644 --- a/tests/contract/test_pulse_lifecycle.cpp +++ b/tests/contract/test_pulse_lifecycle.cpp @@ -58,10 +58,30 @@ const ModeBackendCase kModeBackends[] = { {HDF5_BACKEND, "HDF5", /*on_disk=*/true, /*create_guards_existing=*/true}, {MEMORY_BACKEND, "Memory", /*on_disk=*/false, /*create_guards_existing=*/true}, {ASCII_BACKEND, "ASCII", /*on_disk=*/true, /*create_guards_existing=*/false}, +#ifdef AL_CONTRACT_HAVE_MDSPLUS + // Confirmed empirically (issue #14): MDSplusBackend::openPulse's mode + // switch guards CREATE_PULSE against an existing "ids_001.tree" exactly + // like HDF5/Memory (src/mdsplus/mdsplus_backend.cpp:4507-4510) — no defect + // here, unlike ASCII's. + {MDSPLUS_BACKEND, "Mdsplus", /*on_disk=*/true, /*create_guards_existing=*/true}, +#endif }; class DataEntryModes : public ::testing::TestWithParam { protected: + void SetUp() override { +#ifdef AL_CONTRACT_HAVE_MDSPLUS + if (GetParam().id == MDSPLUS_BACKEND) { + const char* models_path = std::getenv("MDSPLUS_MODELS_PATH"); + if (!models_path || !*models_path) { + GTEST_SKIP() << "MDSPLUS_MODELS_PATH is unset -- MDSplus " + "characterization tests are skipped, not " + "failed (TEST_STRATEGY.md D4)."; + } + } +#endif + } + al_contract::TempBase base_; PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; diff --git a/tests/contract/test_structured_data.cpp b/tests/contract/test_structured_data.cpp index 387310d8..01f4d47f 100644 --- a/tests/contract/test_structured_data.cpp +++ b/tests/contract/test_structured_data.cpp @@ -25,6 +25,7 @@ #include #include +#include #include #include @@ -52,7 +53,19 @@ constexpr const char* kIds = "magnetics"; // AOS-size lookup (ascii_backend.cpp:656-682) depends on that same sequential // position via `getline`, so it always finds nothing. See AosKnownDefects // below for the paired DISABLED_/tripwire. -enum class AosExpect { RoundTrip, Refused, KnownDefect }; +// MDSplus is a Divergence, not a defect (issue #14): unlike every other +// backend here, it resolves paths against a real DD-baked model tree, so +// this fixture's synthetic field names ("elements"/"val", "outer"/"inner") +// have no corresponding node. Confirmed empirically: al_begin_arraystruct_action +// and the per-element al_write_data calls all report success (buffered in an +// in-memory MDSplus::Apd), but the AOS flush at al_end_action throws +// "%TREE-W-NNF, Node Not Found". Real DD-conformant AOS paths (e.g. +// equilibrium/time_slice/global_quantities/ip) round-trip through this exact +// same begin/write/iterate/end sequence without error — this is a structural +// requirement of MDSplus's storage model (issue #12 Q2), not a bug, so it is +// not paired with a DISABLED_/tripwire like KnownDefect. See TRACEABILITY.md +// Part 4. +enum class AosExpect { RoundTrip, Refused, KnownDefect, Divergence }; struct AosBackendCase { int id; @@ -68,6 +81,9 @@ const AosBackendCase kAosBackends[] = { {MEMORY_BACKEND, "Memory", /*on_disk=*/false, AosExpect::RoundTrip}, {ASCII_BACKEND, "ASCII", /*on_disk=*/true, AosExpect::KnownDefect}, {FLEXBUFFERS_BACKEND, "Flexbuffers", /*on_disk=*/true, AosExpect::Refused}, +#ifdef AL_CONTRACT_HAVE_MDSPLUS + {MDSPLUS_BACKEND, "Mdsplus", /*on_disk=*/true, AosExpect::Divergence}, +#endif }; // Shared by AosMatrix and DeleteMatrix below (their backend-descriptor @@ -94,6 +110,12 @@ TEST_P(AosMatrix, TopLevelWriteIterateRead) { GTEST_SKIP() << "known defect for " << b.name << " AOS read — see AosKnownDefects.*"; } + if (b.expect == AosExpect::Divergence) { + GTEST_SKIP() << b.name << " requires real DD-conformant AOS paths " + "(issue #12 Q2) -- this fixture's synthetic field " + "names are refused, not a defect. See " + "TRACEABILITY.md Part 4."; + } const int pctx = open_fresh_pulse(b.id, b.on_disk, base_, pulse_); constexpr int kN = 3; @@ -146,6 +168,12 @@ TEST_P(AosMatrix, NestedWriteIterateRead) { GTEST_SKIP() << "known defect for " << b.name << " AOS read — see AosKnownDefects.*"; } + if (b.expect == AosExpect::Divergence) { + GTEST_SKIP() << b.name << " requires real DD-conformant AOS paths " + "(issue #12 Q2) -- this fixture's synthetic field " + "names are refused, not a defect. See " + "TRACEABILITY.md Part 4."; + } const int pctx = open_fresh_pulse(b.id, b.on_disk, base_, pulse_); constexpr int kOuter = 2, kInner = 2; @@ -268,6 +296,25 @@ TEST(AosKnownDefects, AsciiAosReadCurrentlyReportsZero) { // Equilibrium seed: realistic scalar + timebase-carrying 2-D array + AOS, // oracle = content hash (decision D5). Flexbuffers is excluded for the same // reason as the AOS matrix above (read refused within the session). +// +// MDSplus (issue #14) is excluded too, for a Divergence rather than a defect: +// unlike the always-on backends, it resolves every path against a real +// DD-baked model tree. Confirmed empirically -- the seed's SCALAR sub-shape +// ("vacuum_toroidal_field/r0") round-trips fine (that is exactly what the +// issue #13 tracer bullet, tests/contract/test_mdsplus.cpp, already pins), +// but the seed's other two sub-shapes don't match the real equilibrium DD +// layout MDSplus enforces: writing "profiles_1d/psi" as a flat tensorized +// 2-D array (the HDF5-tensorization convention this generator targets) +// throws "%TREE-W-NNF, Node Not Found" immediately, because MDSplus's real +// profiles_1d is a genuine dynamic array-of-structures, not a flat dataset at +// that path; and the "constraints" AOS's generic {measured, weight} fields +// don't exist in the real DD equilibrium/constraints structure (a fixed +// container of specific constraint-type sub-objects), so the AOS write +// buffers successfully but throws the same NNF when flushed at +// al_end_action. Real DD-conformant AOS/timed paths do round-trip on MDSplus +// through this exact write/read sequence -- the divergence is specific to +// this generator's DD-agnostic shape choices, not a general MDSplus +// limitation. See TRACEABILITY.md Part 4. // =========================================================================== class EquilibriumSeedMatrix : public ::testing::TestWithParam { protected: @@ -624,6 +671,20 @@ TEST_F(DeleteKnownDefects, AsciiDeleteIsCurrentlyANoOp) { // (src/ascii_backend.cpp:194-197), so occurrence N must be passed as // "/" to produce the on-disk stem "" its own directory scan // expects. Memory and Flexbuffers throw unconditionally (not implemented). +// +// MDSplus (issue #14) implements it for real too, resolved empirically as a +// THIRD convention: occurrence N must be passed as "/", same slash +// form as ASCII (mdsconvertPath's SEPARATORS, src/mdsplus/mdsplus_backend.cpp:25, +// tokenizes on '/' and turns "equilibrium/2" into the real child node +// "EQUILIBRIUM:2" the baked model already contains -- occurrence subnodes are +// pre-baked structural slots, not dynamically created). get_occurrences +// (src/mdsplus/mdsplus_backend.cpp:4887-4954) walks ids_node's numeric-named +// children and reports an occurrence "filled" iff its +// ids_properties/homogeneous_time has nonzero length, so the write below must +// set homogeneous_time, not just the scalar. list_filled_paths is NOT +// real on MDSplus -- it throws unconditionally +// (src/mdsplus/mdsplus_backend.cpp:4956-4958, "not implemented in the MDSplus +// Backend"), confirmed via CapabilityMatrix below. TEST(Occurrences, Hdf5ListsWrittenOccurrences) { al_contract::TempBase base; PulseId pulse{"test", "3", 12, 0}; @@ -705,4 +766,47 @@ TEST(Occurrences, FlexbuffersRefuses) { al_close_pulse(pctx, CLOSE_PULSE); } +#ifdef AL_CONTRACT_HAVE_MDSPLUS +// MDSplus resolves paths against a real DD-baked model tree, so this reuses +// equilibrium_seed's real DD path instead of the synthetic "magnetics"/"leaf" +// the other three Occurrences.* cases use (see the divergence notes on +// EquilibriumSeedMatrix/AosMatrix above for why a synthetic path can't +// transfer). homogeneous_time must be written for get_occurrences to see the +// occurrence as filled at all (see comment above). +TEST(Occurrences, MdsplusListsWrittenOccurrences) { + const char* models_path = std::getenv("MDSPLUS_MODELS_PATH"); + if (!models_path || !*models_path) { + GTEST_SKIP() << "MDSPLUS_MODELS_PATH is unset -- MDSplus " + "characterization tests are skipped, not failed " + "(TEST_STRATEGY.md D4)."; + } + al_contract::TempBase base; + PulseId pulse{"test", "3", 12, 0}; + base.make_legacy_tree(pulse); + const std::string uri = al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + for (const char* name : {equilibrium_seed::kIds, "equilibrium/2"}) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, name, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, equilibrium_seed::kScalar, {}, {6.2})); + int ht = 1; + AL_EXPECT_OK(al_write_data(op, "ids_properties/homogeneous_time", "", &ht, + INTEGER_DATA, 0, nullptr)); + AL_ASSERT_OK(al_end_action(op)); + } + + int* occ = nullptr; + int n = -1; + AL_ASSERT_OK(al_get_occurrences(pctx, equilibrium_seed::kIds, &occ, &n)); + ASSERT_EQ(n, 2); + EXPECT_EQ(occ[0], 0); + EXPECT_EQ(occ[1], 2); + free(occ); // malloc'd (src/mdsplus/mdsplus_backend.cpp:4949) -> free() is correct. + + al_close_pulse(pctx, CLOSE_PULSE); +} +#endif // AL_CONTRACT_HAVE_MDSPLUS + } // namespace From 2c206a876fc5012a50e47dc8407c6979075d3fa3 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:30:29 +0200 Subject: [PATCH 22/91] Address code review: join MDSplus to EquilibriumSeedMatrix, dedupe skip logic - EquilibriumSeedMatrix now takes an actual MDSplus instantiation (skipped in-place via GTEST_SKIP) instead of omitting it from kSeedBackends[], matching AosMatrix's divergence treatment and issue #12 Q5's explicit listing of this fixture as one MDSplus can join. - Extract the repeated "skip if MDSPLUS_MODELS_PATH unset" block into a single AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED() macro in al_contract.h (a macro, not a function, since GTEST_SKIP()'s implicit return must unwind the calling SetUp()/TEST body directly); reuse it in test_mdsplus.cpp, test_pulse_lifecycle.cpp, test_capability_gated.cpp, and the new Occurrences test. - Add `divergence` to TRACEABILITY.md's status vocabulary list (it was used as a live status without ever being defined there). - Fix a stray escaped-slash in CMakeLists.txt's mdsplus TEST_FILTER that didn't match its own explanatory comment. --- tests/contract/CMakeLists.txt | 4 ++-- tests/contract/TRACEABILITY.md | 10 ++++++++- tests/contract/al_contract.h | 19 +++++++++++++++++ tests/contract/test_capability_gated.cpp | 9 +------- tests/contract/test_mdsplus.cpp | 9 +------- tests/contract/test_pulse_lifecycle.cpp | 9 +------- tests/contract/test_structured_data.cpp | 26 ++++++++++++++++-------- 7 files changed, 51 insertions(+), 35 deletions(-) diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 5b6794cd..19130ec6 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -193,10 +193,10 @@ if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") TEST_FILTER "Introspection.*:*Memory*:KnownDefects*:Plugin*" PROPERTIES LABELS "contract\;unit") gtest_discover_tests(contract_tests - TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:MdsplusTracerBullet*:*\/Mdsplus:Occurrences.Mdsplus*" + TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*" PROPERTIES LABELS "contract\;integration") gtest_discover_tests(contract_tests - TEST_FILTER "MdsplusTracerBullet*:*\/Mdsplus:Occurrences.Mdsplus*" + TEST_FILTER "MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*" PROPERTIES LABELS "mdsplus") else() gtest_discover_tests(contract_tests diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 7cd15d90..492bef58 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -20,6 +20,14 @@ Status vocabulary: - **xfail** — a genuine defect; the test asserts the *correct* behavior and is expected-fail (decision D2, `DISABLED_` + a paired current-behavior tripwire). Flipping to pass means someone fixed it. +- **divergence** (issue #12, Part 4) — not a defect: a legitimate storage-model + difference where a parity row's synthetic/DD-agnostic test shape doesn't + transfer to a backend that enforces something the others don't (e.g. + MDSplus resolving every path against a real DD-baked model tree). The test + is skipped with an explanation (`GTEST_SKIP()`), not paired with a + `DISABLED_`/tripwire — there is nothing to "fix," since flipping it would + require changing the test's own shape, not the backend. Distinct from + `xfail` (a bug that could be fixed) and from `gap` (not tested at all). - **gap** — not tested. Either *deferred*, naming the later build-order step (TEST_STRATEGY §4) that owns it, or — now that issue #8 (the last build-order step) is closing — *terminal*: reachable in principle but out of this @@ -345,5 +353,5 @@ explicit open items), not assumed: | Parity: `CapabilityMatrix` — slice read/write positive (real interpolation round trip), timerange refused (`LOWLEVEL_ERR`), `list_filled_paths` refused (`BACKEND_ERR`) | `Backends/CapabilityMatrix.{TimeRangeReadPositiveOrRefused,SliceReadPositiveOrRefused,SliceWriteBeginPositiveOrRefused,ListFilledPathsPositiveOrRefused}/Mdsplus` (4 cases) | covered | | Parity: `Occurrences` — real implementation, `/` naming convention (see characterization facts above) | `Occurrences.MdsplusListsWrittenOccurrences` | covered | | Parity: `AosMatrix` (top-level + nested write→iterate→read) — **divergence, not a defect**: MDSplus resolves every path against its real DD-baked model tree, so this fixture's synthetic field names (`"elements"`/`"val"`, `"outer"`/`"inner"`) have no corresponding node; `al_begin_arraystruct_action` and the per-element writes report success (buffered), but the flush at `al_end_action` throws `%TREE-W-NNF, Node Not Found`. Real DD-conformant AOS paths round-trip through the identical sequence (see characterization facts above) — the fixture's synthetic-path design simply cannot transfer to a model-tree-backed backend, the same reason `RoundTripMatrix` already excludes MDSplus (issue #12 Q5) | `Backends/AosMatrix.{TopLevelWriteIterateRead,NestedWriteIterateRead}/Mdsplus` (both `GTEST_SKIP()`, `AosExpect::Divergence`) | divergence | -| Parity: `EquilibriumSeedMatrix` (composite scalar + timebase-carrying 2-D array + `constraints` AOS, hash oracle) — **divergence, not a defect**: the scalar sub-shape round-trips (already proven by the tracer bullet above), but the seed's flat-tensorized `profiles_1d/psi` write and its generic `constraints`/`{measured,weight}` AOS shape don't match the real equilibrium DD layout MDSplus enforces (real `profiles_1d` is a genuine dynamic AOS, not a flat dataset at that path; real `constraints` is a fixed container of specific constraint sub-objects, not a generic AOS) — both throw `%TREE-W-NNF, Node Not Found`. MDSplus is therefore **not instantiated** in `kSeedBackends[]` (same treatment as Flexbuffers, excluded for its own, different reason) rather than left as a permanently-red parametrized case; the exclusion and full reasoning are documented in `test_structured_data.cpp`'s `EquilibriumSeedMatrix` header comment | — (see `test_structured_data.cpp` comment; not instantiated) | divergence | +| Parity: `EquilibriumSeedMatrix` (composite scalar + timebase-carrying 2-D array + `constraints` AOS, hash oracle) — **divergence, not a defect**: the scalar sub-shape round-trips (already proven by the tracer bullet above), but the seed's flat-tensorized `profiles_1d/psi` write and its generic `constraints`/`{measured,weight}` AOS shape don't match the real equilibrium DD layout MDSplus enforces (real `profiles_1d` is a genuine dynamic AOS, not a flat dataset at that path; real `constraints` is a fixed container of specific constraint sub-objects, not a generic AOS) — both throw `%TREE-W-NNF, Node Not Found`. MDSplus **is** instantiated in `kSeedBackends[]` (mirroring `AosMatrix` above), and the case is skipped in-place via `GTEST_SKIP()` rather than run and fail | `Backends/EquilibriumSeedMatrix.RoundTripHashMatches/Mdsplus` (`GTEST_SKIP()`) | divergence | | Unique: struct-aware slice interpolation, timerange resampling, segments, timebase cache, version-drift check | — | gap (deferred — later #12 sub-issue) | diff --git a/tests/contract/al_contract.h b/tests/contract/al_contract.h index e48a5473..daaa66f6 100644 --- a/tests/contract/al_contract.h +++ b/tests/contract/al_contract.h @@ -61,6 +61,25 @@ inline ::testing::AssertionResult IsOk(const al_status_t& s) { inline constexpr const char* kUnregisteredPluginName = "no_such_plugin_ever_registered"; +// --------------------------------------------------------------------------- +// MDSplus runtime skip (TEST_STRATEGY.md D4): absent/unconfigured is skipped, +// never failed. A macro, not a function -- GTEST_SKIP() expands to a `return` +// that must unwind the *calling* SetUp()/TEST body directly; wrapping it in an +// ordinary function would only return from that function, leaving the caller +// to run on unskipped. Shared by every MDSplus-parametrized fixture +// (test_mdsplus.cpp, test_pulse_lifecycle.cpp, test_capability_gated.cpp, +// test_structured_data.cpp) so the check has exactly one wording. +// --------------------------------------------------------------------------- +#define AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED() \ + do { \ + const char* models_path = std::getenv("MDSPLUS_MODELS_PATH"); \ + if (!models_path || !*models_path) { \ + GTEST_SKIP() << "MDSPLUS_MODELS_PATH is unset -- MDSplus " \ + "characterization tests are skipped, not " \ + "failed (TEST_STRATEGY.md D4)."; \ + } \ + } while (0) + // --------------------------------------------------------------------------- // BackendCase descriptor for value-parametrized tests (TEST_P). // --------------------------------------------------------------------------- diff --git a/tests/contract/test_capability_gated.cpp b/tests/contract/test_capability_gated.cpp index 8dd4d0e2..dd7846b2 100644 --- a/tests/contract/test_capability_gated.cpp +++ b/tests/contract/test_capability_gated.cpp @@ -144,14 +144,7 @@ class CapabilityMatrix : public ::testing::TestWithParam { protected: void SetUp() override { #ifdef AL_CONTRACT_HAVE_MDSPLUS - if (GetParam().id == MDSPLUS_BACKEND) { - const char* models_path = std::getenv("MDSPLUS_MODELS_PATH"); - if (!models_path || !*models_path) { - GTEST_SKIP() << "MDSPLUS_MODELS_PATH is unset -- MDSplus " - "characterization tests are skipped, not " - "failed (TEST_STRATEGY.md D4)."; - } - } + if (GetParam().id == MDSPLUS_BACKEND) AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); #endif } diff --git a/tests/contract/test_mdsplus.cpp b/tests/contract/test_mdsplus.cpp index 99da9d98..486786cd 100644 --- a/tests/contract/test_mdsplus.cpp +++ b/tests/contract/test_mdsplus.cpp @@ -33,14 +33,7 @@ namespace { class MdsplusTracerBullet : public ::testing::Test { protected: - void SetUp() override { - const char* models_path = std::getenv("MDSPLUS_MODELS_PATH"); - if (!models_path || !*models_path) { - GTEST_SKIP() << "MDSPLUS_MODELS_PATH is unset -- MDSplus " - "characterization tests are skipped, not " - "failed (TEST_STRATEGY.md D4)."; - } - } + void SetUp() override { AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); } al_contract::TempBase base_; PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/13, diff --git a/tests/contract/test_pulse_lifecycle.cpp b/tests/contract/test_pulse_lifecycle.cpp index df014bad..af911ed2 100644 --- a/tests/contract/test_pulse_lifecycle.cpp +++ b/tests/contract/test_pulse_lifecycle.cpp @@ -71,14 +71,7 @@ class DataEntryModes : public ::testing::TestWithParam { protected: void SetUp() override { #ifdef AL_CONTRACT_HAVE_MDSPLUS - if (GetParam().id == MDSPLUS_BACKEND) { - const char* models_path = std::getenv("MDSPLUS_MODELS_PATH"); - if (!models_path || !*models_path) { - GTEST_SKIP() << "MDSPLUS_MODELS_PATH is unset -- MDSplus " - "characterization tests are skipped, not " - "failed (TEST_STRATEGY.md D4)."; - } - } + if (GetParam().id == MDSPLUS_BACKEND) AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); #endif } diff --git a/tests/contract/test_structured_data.cpp b/tests/contract/test_structured_data.cpp index 01f4d47f..1c23221c 100644 --- a/tests/contract/test_structured_data.cpp +++ b/tests/contract/test_structured_data.cpp @@ -297,7 +297,7 @@ TEST(AosKnownDefects, AsciiAosReadCurrentlyReportsZero) { // oracle = content hash (decision D5). Flexbuffers is excluded for the same // reason as the AOS matrix above (read refused within the session). // -// MDSplus (issue #14) is excluded too, for a Divergence rather than a defect: +// MDSplus (issue #14) joins this fixture as a Divergence case, not a defect: // unlike the always-on backends, it resolves every path against a real // DD-baked model tree. Confirmed empirically -- the seed's SCALAR sub-shape // ("vacuum_toroidal_field/r0") round-trips fine (that is exactly what the @@ -314,7 +314,9 @@ TEST(AosKnownDefects, AsciiAosReadCurrentlyReportsZero) { // al_end_action. Real DD-conformant AOS/timed paths do round-trip on MDSplus // through this exact write/read sequence -- the divergence is specific to // this generator's DD-agnostic shape choices, not a general MDSplus -// limitation. See TRACEABILITY.md Part 4. +// limitation. Skipped with GTEST_SKIP() (mirroring AosMatrix's +// AosExpect::Divergence above) rather than run and fail. See TRACEABILITY.md +// Part 4. // =========================================================================== class EquilibriumSeedMatrix : public ::testing::TestWithParam { protected: @@ -326,10 +328,23 @@ const BackendCase kSeedBackends[] = { {HDF5_BACKEND, "HDF5", /*on_disk=*/true}, {MEMORY_BACKEND, "Memory", /*on_disk=*/false}, {ASCII_BACKEND, "ASCII", /*on_disk=*/true}, +#ifdef AL_CONTRACT_HAVE_MDSPLUS + {MDSPLUS_BACKEND, "Mdsplus", /*on_disk=*/true}, +#endif }; TEST_P(EquilibriumSeedMatrix, RoundTripHashMatches) { const BackendCase b = GetParam(); +#ifdef AL_CONTRACT_HAVE_MDSPLUS + if (b.id == MDSPLUS_BACKEND) { + GTEST_SKIP() << "Mdsplus requires real DD-conformant paths for the " + "profile/AOS sub-shapes (issue #12 Q2) -- this " + "generator's flat profiles_1d/psi write and generic " + "constraints AOS are refused, not a defect (the " + "scalar sub-shape is separately covered by " + "MdsplusTracerBullet). See TRACEABILITY.md Part 4."; + } +#endif if (b.on_disk) base_.make_legacy_tree(pulse_); const std::string uri = al_contract::build_uri(b.id, base_.str(), pulse_); ASSERT_FALSE(uri.empty()); @@ -774,12 +789,7 @@ TEST(Occurrences, FlexbuffersRefuses) { // transfer). homogeneous_time must be written for get_occurrences to see the // occurrence as filled at all (see comment above). TEST(Occurrences, MdsplusListsWrittenOccurrences) { - const char* models_path = std::getenv("MDSPLUS_MODELS_PATH"); - if (!models_path || !*models_path) { - GTEST_SKIP() << "MDSPLUS_MODELS_PATH is unset -- MDSplus " - "characterization tests are skipped, not failed " - "(TEST_STRATEGY.md D4)."; - } + AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); al_contract::TempBase base; PulseId pulse{"test", "3", 12, 0}; base.make_legacy_tree(pulse); From 09a4d61eb5f85e757af9393e30e77ee981e421c1 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:12:58 +0200 Subject: [PATCH 23/91] Add MDSplus real-DD-path datatype x rank breadth matrix (issue #15) RoundTripMatrix sweeps {CHAR,INTEGER,DOUBLE,COMPLEX} x rank against synthetic opaque paths MDSplus structurally can't join. test_mdsplus_real_paths.cpp curates one real DD-4.1.1 path per (type, rank) cell instead, sourced by walking build-mdsplus/_deps/data-dictionary-src/IDSDef.xml directly (the imas-dd MCP tool wasn't available in this session), spanning multiple IDSs. 32 cells: 18 covered (13 plain leaf, 5 via 1-3 level AOS descent reusing AosMatrix's proven idiom), 1 divergence (CHAR r0 against a real STR_0D node: MDSplus's text node is inherently string-shaped, so it cleanly refuses a dim=0 read-back instead of HDF5's crash on the same synthetic shape), 13 terminal-gap (DD 4.1.1 has no field of that type/rank anywhere, e.g. no STR_2D+, no CPX_0D). Verified against a real MDSplus tree in docker/mdsplus/: ctest -L mdsplus is green (45/45). --- tests/contract/CMakeLists.txt | 9 +- tests/contract/TRACEABILITY.md | 105 ++++++- tests/contract/test_mdsplus_real_paths.cpp | 306 +++++++++++++++++++++ 3 files changed, 402 insertions(+), 18 deletions(-) create mode 100644 tests/contract/test_mdsplus_real_paths.cpp diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 19130ec6..05208337 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -71,6 +71,7 @@ add_executable(contract_tests test_structured_data.cpp test_pulse_lifecycle.cpp test_mdsplus.cpp + test_mdsplus_real_paths.cpp ) target_link_libraries(contract_tests PRIVATE al GTest::gtest GTest::gtest_main) @@ -176,7 +177,9 @@ endif() # CapabilityMatrix, AosMatrix, Occurrences) as a "Mdsplus"-named parameter/ # case; "*/Mdsplus" and "Occurrences.Mdsplus*" carve those into the same # mdsplus pass so `ctest -L mdsplus` covers the whole parity set, not just -# the tracer bullet. +# the tracer bullet. Issue #15 adds the real-DD-path datatype x rank breadth +# matrix (test_mdsplus_real_paths.cpp); "Mdsplus/MdsplusRealPathMatrix.*" +# carves its instances into the same mdsplus pass. # # Empirically discovered running this against a real build (issue #13, # CMake 3.28 + Ninja): gtest_discover_tests' PROPERTIES forwarding does NOT @@ -193,10 +196,10 @@ if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") TEST_FILTER "Introspection.*:*Memory*:KnownDefects*:Plugin*" PROPERTIES LABELS "contract\;unit") gtest_discover_tests(contract_tests - TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*" + TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*" PROPERTIES LABELS "contract\;integration") gtest_discover_tests(contract_tests - TEST_FILTER "MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*" + TEST_FILTER "MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*" PROPERTIES LABELS "mdsplus") else() gtest_discover_tests(contract_tests diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 492bef58..82e7add4 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -281,26 +281,32 @@ whoever fixed it to enable the paired `DISABLED_` correct-contract test: # Part 4 — MDSplus backend (compile-guarded tier) (issue #12) -**Progress: parity sweep landed (issue #14), on top of the tracer bullet -(issue #13).** MDSplus is now a parameter/case in all five fixtures issue #14 -named — `DataEntryModes`, `CapabilityMatrix`, `Occurrences`, -`AosMatrix`, `EquilibriumSeedMatrix` — with every resulting row triaged to a -terminal status (`covered` or `divergence`; no genuine defect surfaced, so no -`xfail` this round). The MDSplus-unique surface (struct-aware slice +**Progress: real-DD-path datatype × rank breadth landed (issue #15), on top of +the parity sweep (issue #14) and the tracer bullet (issue #13).** MDSplus is a +parameter/case in all five fixtures issue #14 named — `DataEntryModes`, +`CapabilityMatrix`, `Occurrences`, `AosMatrix`, `EquilibriumSeedMatrix` — with +every resulting row triaged to a terminal status (`covered` or `divergence`; +no genuine defect surfaced, so no `xfail` this round). Issue #15 adds a +dedicated real-DD-path breadth matrix (`test_mdsplus_real_paths.cpp`, +`Mdsplus/MdsplusRealPathMatrix.*`) covering every `{CHAR, INTEGER, DOUBLE, +COMPLEX} x rank {0..7}` cell the DD actually contains — the one parity area +`RoundTripMatrix` structurally cannot join MDSplus for (its opaque synthetic +paths have no model-tree node). The MDSplus-unique surface (struct-aware slice interpolation, timerange resampling, segments, timebase cache, version-drift check) remains `gap (deferred — later #12 sub-issue)`. Build-gated by `AL_CONTRACT_HAVE_MDSPLUS` (`tests/contract/CMakeLists.txt`, defined only when `AL_BACKEND_MDSPLUS=ON`), so every MDSplus case in -`test_mdsplus.cpp`, `test_pulse_lifecycle.cpp`, `test_structured_data.cpp`, -and `test_capability_gated.cpp` compiles out entirely otherwise. -Runtime-skipped (`GTEST_SKIP()`, never failed, per D4) when -`MDSPLUS_MODELS_PATH` is unset. CTest label `mdsplus` (`ctest -L mdsplus`) -now covers the whole parity set, not just the tracer bullet (issue #14 -extended the label's `TEST_FILTER` carve-out in `CMakeLists.txt`). -Characterization environment: `docker/mdsplus/` (aarch64, DD 4.1.1 baked -model tree — see its README for the characterization-discovered deviation -from the issue's Ubuntu 22.04 starting point). +`test_mdsplus.cpp`, `test_mdsplus_real_paths.cpp`, `test_pulse_lifecycle.cpp`, +`test_structured_data.cpp`, and `test_capability_gated.cpp` compiles out +entirely otherwise. Runtime-skipped (`GTEST_SKIP()`, never failed, per D4) +when `MDSPLUS_MODELS_PATH` is unset. CTest label `mdsplus` (`ctest -L +mdsplus`) covers the whole parity set plus the breadth matrix (issue #15 +extended the label's `TEST_FILTER` carve-out in `CMakeLists.txt` with +`Mdsplus/MdsplusRealPathMatrix.*`). Characterization environment: +`docker/mdsplus/` (aarch64, DD 4.1.1 baked model tree — see its README for +the characterization-discovered deviation from the issue's Ubuntu 22.04 +starting point). ## Characterization-discovered facts (issue #14) @@ -354,4 +360,73 @@ explicit open items), not assumed: | Parity: `Occurrences` — real implementation, `/` naming convention (see characterization facts above) | `Occurrences.MdsplusListsWrittenOccurrences` | covered | | Parity: `AosMatrix` (top-level + nested write→iterate→read) — **divergence, not a defect**: MDSplus resolves every path against its real DD-baked model tree, so this fixture's synthetic field names (`"elements"`/`"val"`, `"outer"`/`"inner"`) have no corresponding node; `al_begin_arraystruct_action` and the per-element writes report success (buffered), but the flush at `al_end_action` throws `%TREE-W-NNF, Node Not Found`. Real DD-conformant AOS paths round-trip through the identical sequence (see characterization facts above) — the fixture's synthetic-path design simply cannot transfer to a model-tree-backed backend, the same reason `RoundTripMatrix` already excludes MDSplus (issue #12 Q5) | `Backends/AosMatrix.{TopLevelWriteIterateRead,NestedWriteIterateRead}/Mdsplus` (both `GTEST_SKIP()`, `AosExpect::Divergence`) | divergence | | Parity: `EquilibriumSeedMatrix` (composite scalar + timebase-carrying 2-D array + `constraints` AOS, hash oracle) — **divergence, not a defect**: the scalar sub-shape round-trips (already proven by the tracer bullet above), but the seed's flat-tensorized `profiles_1d/psi` write and its generic `constraints`/`{measured,weight}` AOS shape don't match the real equilibrium DD layout MDSplus enforces (real `profiles_1d` is a genuine dynamic AOS, not a flat dataset at that path; real `constraints` is a fixed container of specific constraint sub-objects, not a generic AOS) — both throw `%TREE-W-NNF, Node Not Found`. MDSplus **is** instantiated in `kSeedBackends[]` (mirroring `AosMatrix` above), and the case is skipped in-place via `GTEST_SKIP()` rather than run and fail | `Backends/EquilibriumSeedMatrix.RoundTripHashMatches/Mdsplus` (`GTEST_SKIP()`) | divergence | + +### Real-DD-path datatype × rank breadth (issue #15) + +Where the rows above join MDSplus to the *existing* fixtures' synthetic +shapes, this matrix instead curates one **real** DD-4.1.1 path per +`{CHAR, INTEGER, DOUBLE, COMPLEX} x rank {0..7}` cell — the axis +`RoundTripMatrix` already sweeps on the always-on tier, but against opaque +paths MDSplus cannot resolve (issue #12 Q2/Q5). Path curation method: the +`imas-dd` MCP tool CLAUDE.md names was not present in this session's MCP +configuration, so the same question it would answer was resolved by walking +the actual baked-from artifact directly — +`build-mdsplus/_deps/data-dictionary-src/IDSDef.xml` (the DD-4.1.1 source +`ALBuildDataDictionary.cmake` downloads and the model tree is compiled from) +— which is still empirical characterization against the real shipped +artifact (D2), just queried by hand instead of through the MCP wrapper. Every +covered cell spans a different real IDS (`equilibrium`, `b_field_non_axisymmetric`, +`amns_data`, `magnetics`, `temporary`, `balance_of_plant`, `bolometer`, +`gyrokinetics_local`, `waves`, `runaway_electrons`), confirming the model +tree's whole-DD bake, not one container. + +**Characterization-discovered facts:** + +- **Five (type, rank) cells exist in the DD only inside a struct_array** — + INTEGER rank 3, DOUBLE rank 6, and COMPLEX ranks 1/3/5. Each is reachable + through the identical `al_begin_arraystruct_action` idiom `AosMatrix` + already proved round-trips against MDSplus for real DD-conformant paths (a + single element is written, no breadth beyond one element — AOS breadth + itself stays `AosMatrix`/#14's job): INTEGER r3 and DOUBLE r6 use the DD's + own generic self-test IDS, `temporary` (`constant_integer3d/value`, + `constant_float6d/value`, one AOS level); COMPLEX r3/r5 use + `runaway_electrons` (`distribution/markers/orbit_integrals[_instant]/values`, + one AOS level, since `distribution` itself is a plain structure, not an + AOS); COMPLEX r1 is the deepest real case found anywhere in the whole DD — + `waves`' `coherent_wave/full_wave/e_field/plus/values` needs **three** + nested AOS levels (`coherent_wave`, `full_wave`, `e_field/plus`) — and + round-trips through the same idiom generalized to three descents. All five + passed as plain round trips; no genuine defect surfaced from the AOS + descent itself. +- **CHAR's rank axis is one off from the DD's own `STR_ND` suffix**: the + C-ABI's `dim` parameter for CHAR_DATA models a *string* at dim=1 (DD + `STR_0D`) and an *array of strings* at dim=2 (DD `STR_1D`) — dim=0 (a bare + scalar char) has no DD analogue at all. Confirmed empirically by CHAR r0's + divergence below. +- **CHAR r0 against a real STR_0D node is a divergence, not a crash**: + writing a dim=0 (bare scalar char) value to `equilibrium`'s `code/name` + (a real STR_0D node) and reading it back at dim=0 fails with "Wrong + dimension of Data returned by backend: expected CHAR_DATA in 0D but got + CHAR_DATA in 1D" — MDSplus's real text node is inherently string-shaped + (1-D), so it correctly refuses the mismatched rank instead of silently + misreading it. This contrasts with HDF5's genuine CHAR-scalar crash + (`RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips`): MDSplus's + behavior is a clean, typed refusal against a real DD node, and RoundTripMatrix's + synthetic "dim=0 CHAR" shape simply has no DD counterpart to transfer to — + the same divergence pattern as `AosMatrix`/`EquilibriumSeedMatrix` above, + just surfacing on a single scalar cell instead of a whole fixture. +- **Thirteen (type, rank) cells are terminal-gap — the DD contains no field + of that type at that rank, at any nesting depth, anywhere in DD 4.1.1**: + CHAR ranks 3–7 (only `STR_0D`/`STR_1D` exist), INTEGER ranks 4–7 (max is + `INT_3D`), DOUBLE rank 7 (max is `FLT_6D`), COMPLEX rank 0 (no scalar + complex type at all) and ranks 6–7 (max is `CPX_5D`). Each cell's own test + instance records this via `GTEST_SKIP()` with the specific empirical + reason, rather than being silently absent from the matrix. + +| Cluster / Capability | Test(s) | Status | +|---|---|---| +| Real-path breadth: 18 (type, rank) cells with a plain leaf field (no AOS needed) round-trip against real DD-4.1.1 paths spanning `equilibrium`, `b_field_non_axisymmetric`, `amns_data`, `magnetics`, `balance_of_plant`, `bolometer`, `gyrokinetics_local` — CHAR r1/r2, INTEGER r0–r2, DOUBLE r0–r5, COMPLEX r2/r4 | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/{CHAR_r1,CHAR_r2,INTEGER_r0,INTEGER_r1,INTEGER_r2,DOUBLE_r0..DOUBLE_r5,COMPLEX_r2,COMPLEX_r4}` (13 cases; the fixture curates 5 more AOS-nested cells separately below, 18 covered in total) | covered | +| Real-path breadth: 5 (type, rank) cells reachable only inside a struct_array — INTEGER r3, DOUBLE r6 (`temporary`, 1 AOS level), COMPLEX r3/r5 (`runaway_electrons`, 1 AOS level), COMPLEX r1 (`waves`, 3 nested AOS levels, the deepest real case in the whole DD) — round-trip via the same `al_begin_arraystruct_action` idiom `AosMatrix` already proved against MDSplus | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/{INTEGER_r3,DOUBLE_r6,COMPLEX_r1,COMPLEX_r3,COMPLEX_r5}` | covered | +| ↳ CHAR r0 (a bare scalar char) against a real STR_0D node (`equilibrium`'s `code/name`) — **divergence, not a defect**: MDSplus's real text node is inherently string-shaped (1-D) and correctly refuses a dim=0 read-back ("expected CHAR_DATA in 0D but got CHAR_DATA in 1D") instead of silently misreading it; contrast HDF5's genuine crash on the same synthetic shape (see characterization facts above) | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/CHAR_r0` (`GTEST_SKIP()`) | divergence | +| ↳ 13 (type, rank) cells the DD contains nowhere at any nesting depth: CHAR r3–r7, INTEGER r4–r7, DOUBLE r7, COMPLEX r0, COMPLEX r6–r7 (see characterization facts above for the per-type ceiling each hits) | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/{CHAR_r3..CHAR_r7,INTEGER_r4..INTEGER_r7,DOUBLE_r7,COMPLEX_r0,COMPLEX_r6,COMPLEX_r7}` (13 cases, each `GTEST_SKIP()` with its specific reason) | terminal-gap | | Unique: struct-aware slice interpolation, timerange resampling, segments, timebase cache, version-drift check | — | gap (deferred — later #12 sub-issue) | diff --git a/tests/contract/test_mdsplus_real_paths.cpp b/tests/contract/test_mdsplus_real_paths.cpp new file mode 100644 index 00000000..10feb132 --- /dev/null +++ b/tests/contract/test_mdsplus_real_paths.cpp @@ -0,0 +1,306 @@ +// MDSplus real-DD-path datatype x rank breadth (issue #15, TRACEABILITY.md +// Part 4). +// +// RoundTripMatrix (test_roundtrip_matrix.cpp) sweeps +// {CHAR, INTEGER, DOUBLE, COMPLEX} x rank {scalar .. MAXDIM} +// against SYNTHETIC opaque paths on the always-on backend tier -- a shape +// MDSplus cannot join (issue #12 Q2/Q5, confirmed by the AosMatrix/ +// EquilibriumSeedMatrix divergence rows in test_structured_data.cpp): it +// resolves every path against a real, DD-4.1.1-baked model tree, so a made-up +// field name throws "%TREE-W-NNF, Node Not Found". This file is +// RoundTripMatrix's MDSplus-only counterpart: the same type x rank space, +// curated as real DD-4.1.1 paths so MDSplus can actually store them, spanning +// multiple IDSs (the model tree is baked from the whole IDSDef.xml, so this +// exercises that whole-DD bake, not one container). +// +// Path curation method: the imas-dd MCP tool named in CLAUDE.md was not +// present in this session's MCP configuration, so the same question it would +// answer was resolved by walking the actual baked-from artifact directly -- +// build-mdsplus/_deps/data-dictionary-src/IDSDef.xml (the DD-4.1.1 source +// ALBuildDataDictionary.cmake downloads and the model tree is compiled from) +// -- to find, per (type, rank) cell, a real field of that data_type together +// with its struct_array ancestor chain (if any). This is still empirical +// characterization against the real shipped artifact (TEST_STRATEGY D2), +// simply queried by hand instead of through the MCP wrapper. +// +// Per (type, rank) cell, one of three shapes: +// * plain leaf -- straightforward GLOBAL write/read, exactly like a +// RoundTripMatrix cell (`aos_chain` empty). +// * AOS-nested -- the field only exists inside an array-of-structures in +// the whole DD; the AOS is entered (one to three levels, +// matching that field's real nesting) via the same +// al_begin_arraystruct_action idiom AosMatrix already +// proved round-trips against MDSplus for real +// DD-conformant paths (test_structured_data.cpp's +// Divergence note names +// equilibrium/time_slice/global_quantities/ip as the +// proof); a single element is written and read back, so +// AOS *breadth* itself stays AosMatrix/#14's job. +// * terminal-gap -- the DD contains no field of that (type, rank) at any +// nesting depth; the test itself GTEST_SKIP()s with the +// empirical reason, so the cell is visibly accounted for +// rather than silently absent from the matrix. +// +// Oracle: per-backend write->read self-consistency (issue #12 Q2), the same +// al_contract::synth_value/synth_buffer/shape_for_rank generator +// RoundTripMatrix uses -- only the field *path* is real; the *content* stays +// opaque synthetic data clear of the write-path "absent" sentinels +// (al_contract.h). This stays a storage-shape probe, not a DD-semantics test +// (decision D1: the core attaches no DD semantics to any path). +#ifdef AL_CONTRACT_HAVE_MDSPLUS + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include +#include + +using al_contract::PulseId; + +namespace { + +enum class DType { Char, Int, Double, Complex }; + +// One (type, rank) cell. `aos_chain` lists the struct_array field(s) to enter +// in order (each may itself be a multi-segment path through intervening plain +// structures, e.g. "e_field/plus" or "distribution/markers"); empty means the +// leaf hangs directly off the GLOBAL op. `terminal_gap_reason` non-null means +// the DD contains no field of this (type, rank) anywhere and the rest of the +// row is ignored. `divergence_reason` non-null means the cell round-tripped +// but not as a plain pass -- a legitimate storage-model difference empirically +// confirmed by running it (see CHAR_r0 below), not a defect (D2); the round +// trip is still attempted up to the point that confirms the divergence, then +// the test self-reports via GTEST_SKIP() rather than asserting equality. +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; +}; + +// Curated against build-mdsplus/_deps/data-dictionary-src/IDSDef.xml (DD +// 4.1.1). CHAR's rank axis is the raw C-ABI `dim` argument, which is one off +// from the DD's STR_ND suffix (IMAS convention: a *string* is CHAR dim=1, an +// *array of strings* is CHAR dim=2 -- dim=0 is a bare scalar char with no DD +// analogue of its own, reusing the same STR_0D node as dim=1). INTEGER, +// DOUBLE, and COMPLEX map dim directly to their _ND suffix. +const PathSpec kPathSpecs[] = { + // --- CHAR --------------------------------------------------------------- + {DType::Char, 0, "equilibrium", {}, "code/name", nullptr, + "MDSplus's real STR_0D node is inherently string-shaped (1-D): reading a " + "dim=0 write back reports CHAR_DATA in 1D, not 0D ('Wrong dimension of " + "Data returned by backend'), confirmed empirically. Not a crash/defect " + "(contrast HDF5's genuine CHAR-scalar crash, RoundTripKnownDefects) -- " + "the C-ABI's synthetic 'dim=0 CHAR' shape has no DD analogue at all " + "(the DD's smallest text type, STR_0D, is a *string*, i.e. dim=1); " + "against a real DD-conformant node the backend correctly refuses the " + "mismatched rank instead of silently misreading it"}, + {DType::Char, 1, "equilibrium", {}, "code/name", nullptr}, + {DType::Char, 2, "b_field_non_axisymmetric", {}, "control_surface_names", + nullptr}, + {DType::Char, 3, nullptr, {}, nullptr, + "DD 4.1.1 has no STR_2D+ at all (only STR_0D/STR_1D exist -- there is no " + "DD concept a CHAR dim>=3 could model)"}, + {DType::Char, 4, nullptr, {}, nullptr, "see rank 3"}, + {DType::Char, 5, nullptr, {}, nullptr, "see rank 3"}, + {DType::Char, 6, nullptr, {}, nullptr, "see rank 3"}, + {DType::Char, 7, nullptr, {}, nullptr, "see rank 3"}, + + // --- INTEGER -------------------------------------------------------------- + {DType::Int, 0, "amns_data", {}, "z_n", nullptr}, + {DType::Int, 1, "magnetics", {}, "code/output_flag", nullptr}, + {DType::Int, 2, "magnetics", {}, "b_field_pol_probe_equivalent", nullptr}, + {DType::Int, 3, "temporary", {"constant_integer3d"}, "value", nullptr}, + {DType::Int, 4, nullptr, {}, nullptr, + "DD 4.1.1 has no INT_4D+ anywhere (max is INT_3D, itself only inside a " + "struct_array)"}, + {DType::Int, 5, nullptr, {}, nullptr, "see rank 4"}, + {DType::Int, 6, nullptr, {}, nullptr, "see rank 4"}, + {DType::Int, 7, nullptr, {}, nullptr, "see rank 4"}, + + // --- DOUBLE ------------------------------------------------------------- + {DType::Double, 0, "amns_data", {}, "a", nullptr}, + {DType::Double, 1, "balance_of_plant", {}, "gain_plant", nullptr}, + {DType::Double, 2, "bolometer", {}, "grid/volume_element", nullptr}, + {DType::Double, 3, "bolometer", {}, "power_density/data", nullptr}, + {DType::Double, 4, "gyrokinetics_local", {}, + "non_linear/fluxes_4d/particles_phi_potential", nullptr}, + {DType::Double, 5, "gyrokinetics_local", {}, + "non_linear/fluxes_5d/particles_phi_potential", nullptr}, + {DType::Double, 6, "temporary", {"constant_float6d"}, "value", nullptr}, + {DType::Double, 7, nullptr, {}, nullptr, + "DD 4.1.1 has no FLT_7D (the DD's own array-rank ceiling for DOUBLE is " + "6, one short of MAXDIM)"}, + + // --- COMPLEX -------------------------------------------------------------- + {DType::Complex, 0, nullptr, {}, nullptr, + "DD 4.1.1 has no CPX_0D -- no scalar complex type exists at all"}, + {DType::Complex, 1, "waves", {"coherent_wave", "full_wave", "e_field/plus"}, + "values", nullptr}, + {DType::Complex, 2, "gyrokinetics_local", {}, + "non_linear/fields_zonal_2d/phi_potential_perturbed_norm", nullptr}, + {DType::Complex, 3, "runaway_electrons", {"distribution/markers"}, + "orbit_integrals_instant/values", nullptr}, + {DType::Complex, 4, "gyrokinetics_local", {}, + "non_linear/fields_4d/phi_potential_perturbed_norm", nullptr}, + {DType::Complex, 5, "runaway_electrons", {"distribution/markers"}, + "orbit_integrals/values", nullptr}, + {DType::Complex, 6, nullptr, {}, nullptr, + "DD 4.1.1 has no CPX_6D anywhere"}, + {DType::Complex, 7, nullptr, {}, nullptr, + "DD 4.1.1 has no CPX_7D anywhere"}, +}; + +// --- write/read a leaf field at whatever context it lives in ---------------- +template +void write_leaf(int ctx, const char* leaf, const std::vector& shape, + const std::vector& data) { + AL_EXPECT_OK(al_contract::write_data(ctx, leaf, shape, data)); +} + +template +void read_leaf_and_expect(int ctx, const char* leaf, int rank, + const std::vector& shape, + const std::vector& written) { + std::vector read_shape; + std::vector read_data; + AL_EXPECT_OK(al_contract::read_data(ctx, leaf, rank, &read_shape, + &read_data)); + EXPECT_EQ(read_shape, shape) << "shape changed across the round trip"; + ASSERT_EQ(read_data.size(), written.size()) + << "element count changed across the round trip"; + EXPECT_EQ(read_data, written) << "data changed across the round trip"; +} + +// One real-path round trip: open a fresh MDSplus pulse, descend `aos_chain` +// (if any, one al_begin_arraystruct_action per segment, a single element +// each), write the leaf, read it back, assert self-consistency. +template +void run_cell(const PathSpec& spec) { + al_contract::TempBase base; + PulseId pulse{/*database=*/"test", /*version=*/"3", + /*pulse=*/15, /*run=*/0}; + base.make_legacy_tree(pulse); + + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse); + ASSERT_FALSE(uri.empty()); + + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + + const std::vector shape = al_contract::shape_for_rank(spec.rank); + const std::vector written = + al_contract::synth_buffer(al_contract::element_count(shape)); + + // --- write --- + { + int op = -1; + AL_ASSERT_OK( + al_begin_global_action(pulse_ctx, spec.ids, "", WRITE_OP, &op)); + + int ctx = op; + std::vector aos_ctxs; + for (const char* name : spec.aos_chain) { + int size = 1; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(ctx, name, "", &size, + &aos)); + aos_ctxs.push_back(aos); + ctx = aos; + } + + write_leaf(ctx, spec.leaf, shape, written); + + for (auto it = aos_ctxs.rbegin(); it != aos_ctxs.rend(); ++it) { + AL_EXPECT_OK(al_end_action(*it)); + } + AL_ASSERT_OK(al_end_action(op)); + } + + // --- read back --- + { + int op = -1; + AL_ASSERT_OK( + al_begin_global_action(pulse_ctx, spec.ids, "", READ_OP, &op)); + + int ctx = op; + std::vector aos_ctxs; + for (const char* name : spec.aos_chain) { + int size = 0; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(ctx, name, "", &size, + &aos)); + EXPECT_EQ(size, 1) << "single-element AOS size must round-trip"; + aos_ctxs.push_back(aos); + ctx = aos; + } + + read_leaf_and_expect(ctx, spec.leaf, spec.rank, shape, written); + + for (auto it = aos_ctxs.rbegin(); it != aos_ctxs.rend(); ++it) { + AL_EXPECT_OK(al_end_action(*it)); + } + AL_ASSERT_OK(al_end_action(op)); + } + + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); +} + +class MdsplusRealPathMatrix : public ::testing::TestWithParam { +protected: + void SetUp() override { AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); } +}; + +TEST_P(MdsplusRealPathMatrix, RealPathRoundTrip) { + const 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; + } + 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 NameMdsplusRealPathMatrixCase( + 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(Mdsplus, MdsplusRealPathMatrix, + ::testing::ValuesIn(kPathSpecs), + NameMdsplusRealPathMatrixCase); + +} // namespace + +#endif // AL_CONTRACT_HAVE_MDSPLUS From 43d68494fbd6c3dacfcc6891e8332a5bf8621efb Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:43:24 +0200 Subject: [PATCH 24/91] Pin the MDSplus version-drift check as its own test (issue #16) al_lowlevel.cpp's al_begin_dataentry_action compares the compiled backend version against the version stored in an opened pulse and throws on mismatch -- the one place MDSplus behavior is DD-version-sensitive today, and the exact seam the north-star Phase-4 de-coupling rewrites. Forces both the matching and mismatched cases through the C ABI: the mismatch is produced by overwriting the pulse's VERSION:BACK_MAJOR tree node directly via the raw MDSplus C++ API, which needed test-only find_package(MDSplus) wiring on contract_tests (gated behind AL_BACKEND_MDSPLUS). Unlike HDF5's identical drift-check gap (Part 2 row B, left as a terminal gap because that suite has no HDF5 C API wiring), this closes cleanly for MDSplus because its tier is already a separate, compile-guarded, Docker- characterized target -- recorded as a finding in TRACEABILITY.md Part 4. Verified against a real MDSplus backend (DD 4.1.1, docker/mdsplus/): both new cases pass, and the full mdsplus-labeled suite (47 cases) stays green. --- tests/contract/CMakeLists.txt | 21 ++- tests/contract/TRACEABILITY.md | 108 ++++++++---- tests/contract/test_mdsplus_version_drift.cpp | 156 ++++++++++++++++++ 3 files changed, 252 insertions(+), 33 deletions(-) create mode 100644 tests/contract/test_mdsplus_version_drift.cpp diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 05208337..dd079f76 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -72,6 +72,7 @@ add_executable(contract_tests test_pulse_lifecycle.cpp test_mdsplus.cpp test_mdsplus_real_paths.cpp + test_mdsplus_version_drift.cpp ) target_link_libraries(contract_tests PRIVATE al GTest::gtest GTest::gtest_main) @@ -89,6 +90,18 @@ target_compile_features(contract_tests PRIVATE cxx_std_17) # unset, so "on but unconfigured" never fails either. if(AL_BACKEND_MDSPLUS) target_compile_definitions(contract_tests PRIVATE AL_CONTRACT_HAVE_MDSPLUS) + + # test_mdsplus_version_drift.cpp (issue #16) forces the version-mismatch + # case by writing directly into the pulse's VERSION:BACK_MAJOR tree node + # through the raw MDSplus C++ API () -- something reachable + # only outside the public C ABI. `al`'s own src/mdsplus/CMakeLists.txt + # already links MDSplus, but PRIVATEly, so it doesn't propagate here; + # find_package(MDSplus) is repeated (idempotent) to wire the same + # include dir / libs into contract_tests, test-only and build-gated behind + # the same AL_BACKEND_MDSPLUS option as the backend itself. + find_package(MDSplus REQUIRED) + target_include_directories(contract_tests PRIVATE ${MDSPLUS_INCLUDES}) + target_link_libraries(contract_tests PRIVATE ${MDSPLUS_LIBRARIES}) endif() ######################################################################################################################## @@ -179,7 +192,9 @@ endif() # mdsplus pass so `ctest -L mdsplus` covers the whole parity set, not just # the tracer bullet. Issue #15 adds the real-DD-path datatype x rank breadth # matrix (test_mdsplus_real_paths.cpp); "Mdsplus/MdsplusRealPathMatrix.*" -# carves its instances into the same mdsplus pass. +# carves its instances into the same mdsplus pass. Issue #16 adds the +# version-drift check (test_mdsplus_version_drift.cpp); "MdsplusVersionDrift*" +# carves its two cases into the same mdsplus pass. # # Empirically discovered running this against a real build (issue #13, # CMake 3.28 + Ninja): gtest_discover_tests' PROPERTIES forwarding does NOT @@ -196,10 +211,10 @@ if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") TEST_FILTER "Introspection.*:*Memory*:KnownDefects*:Plugin*" PROPERTIES LABELS "contract\;unit") gtest_discover_tests(contract_tests - TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*" + TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*" PROPERTIES LABELS "contract\;integration") gtest_discover_tests(contract_tests - TEST_FILTER "MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*" + TEST_FILTER "MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*" PROPERTIES LABELS "mdsplus") else() gtest_discover_tests(contract_tests diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 82e7add4..1906c749 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -281,32 +281,57 @@ whoever fixed it to enable the paired `DISABLED_` correct-contract test: # Part 4 — MDSplus backend (compile-guarded tier) (issue #12) -**Progress: real-DD-path datatype × rank breadth landed (issue #15), on top of -the parity sweep (issue #14) and the tracer bullet (issue #13).** MDSplus is a -parameter/case in all five fixtures issue #14 named — `DataEntryModes`, -`CapabilityMatrix`, `Occurrences`, `AosMatrix`, `EquilibriumSeedMatrix` — with -every resulting row triaged to a terminal status (`covered` or `divergence`; -no genuine defect surfaced, so no `xfail` this round). Issue #15 adds a -dedicated real-DD-path breadth matrix (`test_mdsplus_real_paths.cpp`, -`Mdsplus/MdsplusRealPathMatrix.*`) covering every `{CHAR, INTEGER, DOUBLE, -COMPLEX} x rank {0..7}` cell the DD actually contains — the one parity area -`RoundTripMatrix` structurally cannot join MDSplus for (its opaque synthetic -paths have no model-tree node). The MDSplus-unique surface (struct-aware slice -interpolation, timerange resampling, segments, timebase cache, version-drift -check) remains `gap (deferred — later #12 sub-issue)`. +**Progress: version-drift check landed (issue #16), on top of the real-DD-path +datatype × rank breadth (issue #15), the parity sweep (issue #14), and the +tracer bullet (issue #13).** MDSplus is a parameter/case in all five fixtures +issue #14 named — `DataEntryModes`, `CapabilityMatrix`, `Occurrences`, +`AosMatrix`, `EquilibriumSeedMatrix` — with every resulting row triaged to a +terminal status (`covered` or `divergence`; no genuine defect surfaced, so no +`xfail` this round). Issue #15 adds a dedicated real-DD-path breadth matrix +(`test_mdsplus_real_paths.cpp`, `Mdsplus/MdsplusRealPathMatrix.*`) covering +every `{CHAR, INTEGER, DOUBLE, COMPLEX} x rank {0..7}` cell the DD actually +contains — the one parity area `RoundTripMatrix` structurally cannot join +MDSplus for (its opaque synthetic paths have no model-tree node). Issue #16 +adds the version-drift check (`test_mdsplus_version_drift.cpp`, +`MdsplusVersionDrift.*`), pinning both the matching-version and +mismatched-version cases through the public C ABI. The remaining +MDSplus-unique surface (struct-aware slice interpolation, timerange +resampling, segments, timebase cache) stays `gap (deferred — later #12 +sub-issue)`. + +**Finding (issue #16): this closes Part 2 row B's "gap" for MDSplus, but not +for HDF5.** Part 2 row B left the version-drift check a `gap` because forcing +the mismatch needs writing a different backend-version value into the opened +pulse after creation — for HDF5 that means poking its on-disk backend-version +attribute directly via the HDF5 C API, which that suite has no include/link +wiring for (out of scope there). MDSplus hits the identical shape of problem — +the mismatch needs overwriting the pulse's stored `VERSION:BACK_MAJOR` tree +node after creation, also unreachable through the C ABI — but the MDSplus tier +is already a separate, compile-guarded, Docker-characterized target +(`tests/contract/CMakeLists.txt`'s `AL_BACKEND_MDSPLUS` gate), so adding the +raw MDSplus C++ API (``) as a test-only, build-gated dependency +of `contract_tests` was a small, low-blast-radius addition — unlike wiring the +HDF5 C API into the always-on suite. So the gap is closed for MDSplus +specifically because of that pre-existing gate, not because the underlying +problem (a version-drift mismatch cannot be forced through the C ABI alone) is +actually different between the two backends. **Part 2 row B itself is left +unchanged** (HDF5 remains a terminal gap); this is a Part 4 finding about *why* +the two backends diverged, not a fix to Part 2. Build-gated by `AL_CONTRACT_HAVE_MDSPLUS` (`tests/contract/CMakeLists.txt`, defined only when `AL_BACKEND_MDSPLUS=ON`), so every MDSplus case in -`test_mdsplus.cpp`, `test_mdsplus_real_paths.cpp`, `test_pulse_lifecycle.cpp`, -`test_structured_data.cpp`, and `test_capability_gated.cpp` compiles out -entirely otherwise. Runtime-skipped (`GTEST_SKIP()`, never failed, per D4) -when `MDSPLUS_MODELS_PATH` is unset. CTest label `mdsplus` (`ctest -L -mdsplus`) covers the whole parity set plus the breadth matrix (issue #15 -extended the label's `TEST_FILTER` carve-out in `CMakeLists.txt` with -`Mdsplus/MdsplusRealPathMatrix.*`). Characterization environment: -`docker/mdsplus/` (aarch64, DD 4.1.1 baked model tree — see its README for -the characterization-discovered deviation from the issue's Ubuntu 22.04 -starting point). +`test_mdsplus.cpp`, `test_mdsplus_real_paths.cpp`, `test_mdsplus_version_drift.cpp`, +`test_pulse_lifecycle.cpp`, `test_structured_data.cpp`, and +`test_capability_gated.cpp` compiles out entirely otherwise. Runtime-skipped +(`GTEST_SKIP()`, never failed, per D4) when `MDSPLUS_MODELS_PATH` is unset. +CTest label `mdsplus` (`ctest -L mdsplus`) covers the whole parity set plus the +breadth matrix and the version-drift check (issue #16 extended the label's +`TEST_FILTER` carve-out in `CMakeLists.txt` with `MdsplusVersionDrift*`, and +added a test-only `find_package(MDSplus)` + include/link wiring onto +`contract_tests`, gated behind the same `AL_BACKEND_MDSPLUS` option). +Characterization environment: `docker/mdsplus/` (aarch64, DD 4.1.1 baked model +tree — see its README for the characterization-discovered deviation from the +issue's Ubuntu 22.04 starting point). ## Characterization-discovered facts (issue #14) @@ -374,11 +399,12 @@ the actual baked-from artifact directly — `build-mdsplus/_deps/data-dictionary-src/IDSDef.xml` (the DD-4.1.1 source `ALBuildDataDictionary.cmake` downloads and the model tree is compiled from) — which is still empirical characterization against the real shipped -artifact (D2), just queried by hand instead of through the MCP wrapper. Every -covered cell spans a different real IDS (`equilibrium`, `b_field_non_axisymmetric`, -`amns_data`, `magnetics`, `temporary`, `balance_of_plant`, `bolometer`, -`gyrokinetics_local`, `waves`, `runaway_electrons`), confirming the model -tree's whole-DD bake, not one container. +artifact (D2), just queried by hand instead of through the MCP wrapper. The +18 covered cells collectively span 10 distinct real IDSs (`equilibrium`, +`b_field_non_axisymmetric`, `amns_data`, `magnetics`, `temporary`, +`balance_of_plant`, `bolometer`, `gyrokinetics_local`, `waves`, +`runaway_electrons` — some, like `gyrokinetics_local`, backing more than one +cell), confirming the model tree's whole-DD bake, not one container. **Characterization-discovered facts:** @@ -425,8 +451,30 @@ tree's whole-DD bake, not one container. | Cluster / Capability | Test(s) | Status | |---|---|---| -| Real-path breadth: 18 (type, rank) cells with a plain leaf field (no AOS needed) round-trip against real DD-4.1.1 paths spanning `equilibrium`, `b_field_non_axisymmetric`, `amns_data`, `magnetics`, `balance_of_plant`, `bolometer`, `gyrokinetics_local` — CHAR r1/r2, INTEGER r0–r2, DOUBLE r0–r5, COMPLEX r2/r4 | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/{CHAR_r1,CHAR_r2,INTEGER_r0,INTEGER_r1,INTEGER_r2,DOUBLE_r0..DOUBLE_r5,COMPLEX_r2,COMPLEX_r4}` (13 cases; the fixture curates 5 more AOS-nested cells separately below, 18 covered in total) | covered | +| Real-path breadth: 13 (type, rank) cells with a plain leaf field (no AOS needed) round-trip against real DD-4.1.1 paths spanning `equilibrium`, `b_field_non_axisymmetric`, `amns_data`, `magnetics`, `balance_of_plant`, `bolometer`, `gyrokinetics_local` — CHAR r1/r2, INTEGER r0–r2, DOUBLE r0–r5, COMPLEX r2/r4 | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/{CHAR_r1,CHAR_r2,INTEGER_r0,INTEGER_r1,INTEGER_r2,DOUBLE_r0..DOUBLE_r5,COMPLEX_r2,COMPLEX_r4}` (13 cases; the fixture curates 5 more AOS-nested cells separately below, 18 covered in total) | covered | | Real-path breadth: 5 (type, rank) cells reachable only inside a struct_array — INTEGER r3, DOUBLE r6 (`temporary`, 1 AOS level), COMPLEX r3/r5 (`runaway_electrons`, 1 AOS level), COMPLEX r1 (`waves`, 3 nested AOS levels, the deepest real case in the whole DD) — round-trip via the same `al_begin_arraystruct_action` idiom `AosMatrix` already proved against MDSplus | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/{INTEGER_r3,DOUBLE_r6,COMPLEX_r1,COMPLEX_r3,COMPLEX_r5}` | covered | | ↳ CHAR r0 (a bare scalar char) against a real STR_0D node (`equilibrium`'s `code/name`) — **divergence, not a defect**: MDSplus's real text node is inherently string-shaped (1-D) and correctly refuses a dim=0 read-back ("expected CHAR_DATA in 0D but got CHAR_DATA in 1D") instead of silently misreading it; contrast HDF5's genuine crash on the same synthetic shape (see characterization facts above) | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/CHAR_r0` (`GTEST_SKIP()`) | divergence | | ↳ 13 (type, rank) cells the DD contains nowhere at any nesting depth: CHAR r3–r7, INTEGER r4–r7, DOUBLE r7, COMPLEX r0, COMPLEX r6–r7 (see characterization facts above for the per-type ceiling each hits) | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/{CHAR_r3..CHAR_r7,INTEGER_r4..INTEGER_r7,DOUBLE_r7,COMPLEX_r0,COMPLEX_r6,COMPLEX_r7}` (13 cases, each `GTEST_SKIP()` with its specific reason) | terminal-gap | -| Unique: struct-aware slice interpolation, timerange resampling, segments, timebase cache, version-drift check | — | gap (deferred — later #12 sub-issue) | +| Unique: struct-aware slice interpolation, timerange resampling, segments, timebase cache | — | gap (deferred — later #12 sub-issue) | + +### Version-drift check (issue #16) + +`al_begin_dataentry_action` (`src/al_lowlevel.cpp:868-883`) compares +`backend->getVersion(NULL)` (the compiled backend's own version) against +`backend->getVersion(pctx)` (the version stored in the pulse being opened) on +`OPEN_PULSE`/`FORCE_OPEN_PULSE` and throws `LOWLEVEL_ERR` on a mismatch. For +MDSplus, the stored value is `VERSION:BACK_MAJOR`/`BACK_MINOR` +(`src/mdsplus/mdsplus_backend.cpp`'s `saveVersion`, called once at pulse +creation from the compiled `MDSPLUS_BACKEND_MAJOR`/`MINOR` constants, currently +1/1). Forcing the mismatch case needs a value in the pulse different from +what today's build would write — done here by opening the pulse's "ids" tree +directly through the raw MDSplus C++ API (``, not the C ABI) and +overwriting `VERSION:BACK_MAJOR` after creation, mirroring the +`ids_path`/`setDataEnv` environment-variable dance the backend itself uses +internally. See the finding above on why this was achievable for MDSplus but +stays a `gap` for HDF5 (Part 2 row B). + +| Cluster / Capability | Test(s) | Status | +|---|---|---| +| Matching case: a pulse created and reopened by the same compiled backend carries the version it just wrote, so the drift check's comparison is a no-op and `OPEN_PULSE` succeeds | `MdsplusVersionDrift.MatchingVersionOpensCleanly` | covered | +| Mismatching case: `VERSION:BACK_MAJOR` is overwritten (via the raw MDSplus tree API) to a value the compiled backend can never match; reopening through the C ABI hits `al_lowlevel.cpp`'s `(ver.first != sver.first)` branch and fails with `LOWLEVEL_ERR` (message contains "Compatibility"), never a silent open | `MdsplusVersionDrift.MismatchedBackendVersionRefusesOpen` | covered | diff --git a/tests/contract/test_mdsplus_version_drift.cpp b/tests/contract/test_mdsplus_version_drift.cpp new file mode 100644 index 00000000..c463b3a9 --- /dev/null +++ b/tests/contract/test_mdsplus_version_drift.cpp @@ -0,0 +1,156 @@ +// MDSplus version-drift check (issue #16, TRACEABILITY.md Part 4). +// +// al_lowlevel.cpp's al_begin_dataentry_action (src/al_lowlevel.cpp:868-883) +// compares the backend's compiled version (`getVersion(NULL)`) against the +// version stored in the pulse being opened (`getVersion(pctx)`) on +// OPEN_PULSE/FORCE_OPEN_PULSE and throws LOWLEVEL_ERR on a mismatch. This is +// generic al_lowlevel.cpp logic shared by every backend, but MDSplus is the +// one backend where `getVersion(pctx)` reads a value a pulse can genuinely +// carry different from today's build: `VERSION:BACK_MAJOR`/`BACK_MINOR`, +// written into the tree once at pulse creation +// (`mdsplus_backend.cpp`'s `saveVersion`, called from `createPulse`) from the +// compiled `MDSPLUS_BACKEND_MAJOR`/`MINOR` constants (currently 1/1) and read +// back unchanged forever after. Forcing the mismatch case therefore needs +// writing a different value into that node after the fact -- through the raw +// MDSplus C++ API (``), not the public C ABI, exactly the kind +// of out-of-band access issue #12's Part 2 row B ("gap", see TRACEABILITY.md) +// declined for HDF5 because that suite has no HDF5 include/link wiring. This +// file adds that wiring for MDSplus only (tests/contract/CMakeLists.txt, +// gated the same way as AL_CONTRACT_HAVE_MDSPLUS): the MDSplus tier already +// links libMDSplus into `al` PRIVATE-ly for the backend itself, and doing the +// same for `contract_tests` is a test-only, build-gated addition -- so this +// finding *is* closeable for MDSplus even though it stayed a terminal gap for +// HDF5 (whose always-on suite deliberately doesn't carry an HDF5 C API +// dependency at all). +#ifdef AL_CONTRACT_HAVE_MDSPLUS + +#include "al_contract.h" + +#include +#include + +#include + +#include + +#include +#include + +using al_contract::PulseId; + +namespace { + +// A backend version the compiled MDSplus backend (MDSPLUS_BACKEND_MAJOR == 1, +// see mdsplus_backend.cpp) can never match, forcing al_lowlevel.cpp's major +// mismatch branch ((ver.first != sver.first) || ...). +constexpr int kMismatchedMajor = 99; + +class MdsplusVersionDrift : public ::testing::Test { +protected: + void SetUp() override { AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); } + + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/16, + /*run=*/0}; + + // The directory DataEntryContext::buildFullPath computes for this pulse + // (al_context.cpp:140-179) and hands to the backend as the URI "path" + // query parameter -- i.e. where the "ids" tree/shot-1 files actually + // live. Mirrors al_contract::TempBase::make_legacy_tree's own layout. + std::string pulse_dir() const { + return base_.str() + "/" + pulse_.database + "/" + pulse_.version + + "/" + std::to_string(pulse_.pulse) + "/" + + std::to_string(pulse_.run); + } + + // Reproduces MDSplusBackend::setDataEnv/resetIdsPath's "ids_path" env-var + // dance (mdsplus_backend.cpp:1458-1517) well enough to open the same tree + // the backend itself opened through the C ABI -- this test has no access + // to the backend's private state, only the public MDSplus tree API. + void PokeStoredBackendMajorVersion(int mismatched_major) { + const char* models_path = std::getenv("MDSPLUS_MODELS_PATH"); + ASSERT_TRUE(models_path && *models_path); + const std::string original_ids_path = + std::getenv("ids_path") ? std::getenv("ids_path") : ""; + + const std::string new_ids_path = pulse_dir() + ";" + models_path; + ASSERT_EQ(setenv("ids_path", new_ids_path.c_str(), 1), 0); + + try { + MDSplus::Tree tree("ids", /*shot=*/1, "NORMAL"); + MDSplus::TreeNode* node = tree.getNode("VERSION:BACK_MAJOR"); + MDSplus::Int32* value = new MDSplus::Int32(mismatched_major); + node->putData(value); + MDSplus::deleteData(value); + delete node; + } catch (MDSplus::MdsException& exc) { + FAIL() << "Could not poke VERSION:BACK_MAJOR directly via the " + "MDSplus tree API: " + << exc.what(); + } + + if (!original_ids_path.empty()) { + setenv("ids_path", original_ids_path.c_str(), 1); + } else { + unsetenv("ids_path"); + } + } +}; + +// Matching case: a pulse created and immediately reopened by the same +// compiled backend carries the same VERSION:BACK_MAJOR/MINOR it just wrote, +// so the drift check's comparison is a no-op and OPEN_PULSE succeeds. +TEST_F(MdsplusVersionDrift, MatchingVersionOpensCleanly) { + base_.make_legacy_tree(pulse_); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int create_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &create_ctx)); + EXPECT_EQ(al_close_pulse(create_ctx, CLOSE_PULSE).code, 0); + + int open_ctx = -1; + al_status_t status = + al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &open_ctx); + EXPECT_TRUE(al_contract::IsOk(status)); + if (status.code == 0) { + EXPECT_EQ(al_close_pulse(open_ctx, CLOSE_PULSE).code, 0); + } +} + +// Mismatching case: after creation, VERSION:BACK_MAJOR is overwritten to a +// value the compiled backend (MDSPLUS_BACKEND_MAJOR == 1) can never match. +// Reopening through the C ABI must hit al_lowlevel.cpp's +// `(ver.first != sver.first)` branch and fail with LOWLEVEL_ERR, never a +// silent open of stale/incompatible data. +TEST_F(MdsplusVersionDrift, MismatchedBackendVersionRefusesOpen) { + base_.make_legacy_tree(pulse_); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int create_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &create_ctx)); + EXPECT_EQ(al_close_pulse(create_ctx, CLOSE_PULSE).code, 0); + + PokeStoredBackendMajorVersion(kMismatchedMajor); + + int open_ctx = -1; + al_status_t status = + al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &open_ctx); + EXPECT_NE(status.code, 0) + << "expected the version-drift check to refuse opening a pulse whose " + "stored VERSION:BACK_MAJOR (" + << kMismatchedMajor << ") doesn't match the compiled backend's"; + ASSERT_TRUE(status.message != nullptr); + EXPECT_NE(std::string(status.message).find("Compatibility"), + std::string::npos) + << "status.message=" << status.message; +} + +} // namespace + +#endif // AL_CONTRACT_HAVE_MDSPLUS From f903fbfffe47c50f94a7aedd258bcdf1c73e4585 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:45:56 +0200 Subject: [PATCH 25/91] Fix AOS-descent lifecycle safety in MdsplusRealPathMatrix (issue #15 review) run_cell() used AL_ASSERT_OK (fatal) for each al_begin_arraystruct_action in the aos_chain descent, so a failure on a non-first level would skip the unwind of already-opened contexts, the outer action, and the pulse close. Switch to AL_EXPECT_OK, matching AosMatrix.NestedWriteIterateRead's established convention so descent failures still fall through to cleanup. --- tests/contract/test_mdsplus_real_paths.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/contract/test_mdsplus_real_paths.cpp b/tests/contract/test_mdsplus_real_paths.cpp index 10feb132..1ea4a304 100644 --- a/tests/contract/test_mdsplus_real_paths.cpp +++ b/tests/contract/test_mdsplus_real_paths.cpp @@ -213,7 +213,7 @@ void run_cell(const PathSpec& spec) { for (const char* name : spec.aos_chain) { int size = 1; int aos = -1; - AL_ASSERT_OK(al_begin_arraystruct_action(ctx, name, "", &size, + AL_EXPECT_OK(al_begin_arraystruct_action(ctx, name, "", &size, &aos)); aos_ctxs.push_back(aos); ctx = aos; @@ -238,7 +238,7 @@ void run_cell(const PathSpec& spec) { for (const char* name : spec.aos_chain) { int size = 0; int aos = -1; - AL_ASSERT_OK(al_begin_arraystruct_action(ctx, name, "", &size, + 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); From d4c5e89c11a621e706516d23bf41398240a49bde Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:10:43 +0200 Subject: [PATCH 26/91] Add MDSplus-unique surface test file (issue #17) Characterizes struct-aware slice interpolation, timerange resampling, segment-backed dynamic writes, and the timebase cache through the public C ABI against a real DD-4.1.1 model tree (equilibrium/time_slice). Timerange resampling has no unique MDSplus surface (supportsTimeRangeOperation() is unconditionally false, already parity-pinned); struct-aware interpolation, segment-backed appends, and the timebase cache are all covered. Characterization surfaced one genuine crash defect: MDSplusBackend::writeData dereferences size[0] unconditionally in its SLICE_OP branch, but the C ABI passes size=nullptr for any dim=0 write, segfaulting on a scalar write inside a slice action. Pinned as DISABLED_/tripwire per the suite's D2 discipline. Closes TRACEABILITY.md Part 4's last deferred row. --- tests/contract/CMakeLists.txt | 12 +- tests/contract/TRACEABILITY.md | 148 ++++- .../contract/test_mdsplus_unique_surface.cpp | 577 ++++++++++++++++++ 3 files changed, 709 insertions(+), 28 deletions(-) create mode 100644 tests/contract/test_mdsplus_unique_surface.cpp diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index dd079f76..624665de 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -73,6 +73,7 @@ add_executable(contract_tests test_mdsplus.cpp test_mdsplus_real_paths.cpp test_mdsplus_version_drift.cpp + test_mdsplus_unique_surface.cpp ) target_link_libraries(contract_tests PRIVATE al GTest::gtest GTest::gtest_main) @@ -194,7 +195,12 @@ endif() # matrix (test_mdsplus_real_paths.cpp); "Mdsplus/MdsplusRealPathMatrix.*" # carves its instances into the same mdsplus pass. Issue #16 adds the # version-drift check (test_mdsplus_version_drift.cpp); "MdsplusVersionDrift*" -# carves its two cases into the same mdsplus pass. +# carves its two cases into the same mdsplus pass. Issue #17 adds the +# MDSplus-unique surface (test_mdsplus_unique_surface.cpp): +# "MdsplusStructAwareSliceInterpolation*", "MdsplusTimerangeResampling*", +# "MdsplusSegmentBackedWrites*", "MdsplusSliceWriteKnownDefects*", +# "MdsplusSliceWriteKnownDefectsDeath*", and "MdsplusTimebaseCache*" carve its +# cases into the same mdsplus pass. # # Empirically discovered running this against a real build (issue #13, # CMake 3.28 + Ninja): gtest_discover_tests' PROPERTIES forwarding does NOT @@ -211,10 +217,10 @@ if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") TEST_FILTER "Introspection.*:*Memory*:KnownDefects*:Plugin*" PROPERTIES LABELS "contract\;unit") gtest_discover_tests(contract_tests - TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*" + TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*:MdsplusStructAwareSliceInterpolation*:MdsplusTimerangeResampling*:MdsplusSegmentBackedWrites*:MdsplusSliceWriteKnownDefects*:MdsplusSliceWriteKnownDefectsDeath*:MdsplusTimebaseCache*" PROPERTIES LABELS "contract\;integration") gtest_discover_tests(contract_tests - TEST_FILTER "MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*" + TEST_FILTER "MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*:MdsplusStructAwareSliceInterpolation*:MdsplusTimerangeResampling*:MdsplusSegmentBackedWrites*:MdsplusSliceWriteKnownDefects*:MdsplusSliceWriteKnownDefectsDeath*:MdsplusTimebaseCache*" PROPERTIES LABELS "mdsplus") else() gtest_discover_tests(contract_tests diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 1906c749..c70c04a4 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -281,23 +281,26 @@ whoever fixed it to enable the paired `DISABLED_` correct-contract test: # Part 4 — MDSplus backend (compile-guarded tier) (issue #12) -**Progress: version-drift check landed (issue #16), on top of the real-DD-path -datatype × rank breadth (issue #15), the parity sweep (issue #14), and the -tracer bullet (issue #13).** MDSplus is a parameter/case in all five fixtures -issue #14 named — `DataEntryModes`, `CapabilityMatrix`, `Occurrences`, -`AosMatrix`, `EquilibriumSeedMatrix` — with every resulting row triaged to a -terminal status (`covered` or `divergence`; no genuine defect surfaced, so no -`xfail` this round). Issue #15 adds a dedicated real-DD-path breadth matrix -(`test_mdsplus_real_paths.cpp`, `Mdsplus/MdsplusRealPathMatrix.*`) covering -every `{CHAR, INTEGER, DOUBLE, COMPLEX} x rank {0..7}` cell the DD actually -contains — the one parity area `RoundTripMatrix` structurally cannot join -MDSplus for (its opaque synthetic paths have no model-tree node). Issue #16 -adds the version-drift check (`test_mdsplus_version_drift.cpp`, +**Progress: the MDSplus-unique surface landed (issue #17), closing Part 4's +last deferred row, on top of the version-drift check (issue #16), the +real-DD-path datatype × rank breadth (issue #15), the parity sweep (issue +#14), and the tracer bullet (issue #13).** MDSplus is a parameter/case in all +five fixtures issue #14 named — `DataEntryModes`, `CapabilityMatrix`, +`Occurrences`, `AosMatrix`, `EquilibriumSeedMatrix` — with every resulting row +triaged to a terminal status (`covered` or `divergence`; no genuine defect +surfaced, so no `xfail` this round). Issue #15 adds a dedicated real-DD-path +breadth matrix (`test_mdsplus_real_paths.cpp`, `Mdsplus/MdsplusRealPathMatrix.*`) +covering every `{CHAR, INTEGER, DOUBLE, COMPLEX} x rank {0..7}` cell the DD +actually contains — the one parity area `RoundTripMatrix` structurally cannot +join MDSplus for (its opaque synthetic paths have no model-tree node). Issue +#16 adds the version-drift check (`test_mdsplus_version_drift.cpp`, `MdsplusVersionDrift.*`), pinning both the matching-version and -mismatched-version cases through the public C ABI. The remaining -MDSplus-unique surface (struct-aware slice interpolation, timerange -resampling, segments, timebase cache) stays `gap (deferred — later #12 -sub-issue)`. +mismatched-version cases through the public C ABI. Issue #17 adds the +MDSplus-unique surface (`test_mdsplus_unique_surface.cpp`) — struct-aware +slice interpolation (`covered`), timerange resampling (`covered`, but with no +unique surface — see below), segment-backed dynamic writes (`covered`, plus +one genuine `xfail` crash defect it surfaced), and the timebase cache +(`covered`) — see its own subsection below for details. **Finding (issue #16): this closes Part 2 row B's "gap" for MDSplus, but not for HDF5.** Part 2 row B left the version-drift check a `gap` because forcing @@ -321,14 +324,21 @@ the two backends diverged, not a fix to Part 2. Build-gated by `AL_CONTRACT_HAVE_MDSPLUS` (`tests/contract/CMakeLists.txt`, defined only when `AL_BACKEND_MDSPLUS=ON`), so every MDSplus case in `test_mdsplus.cpp`, `test_mdsplus_real_paths.cpp`, `test_mdsplus_version_drift.cpp`, -`test_pulse_lifecycle.cpp`, `test_structured_data.cpp`, and -`test_capability_gated.cpp` compiles out entirely otherwise. Runtime-skipped -(`GTEST_SKIP()`, never failed, per D4) when `MDSPLUS_MODELS_PATH` is unset. -CTest label `mdsplus` (`ctest -L mdsplus`) covers the whole parity set plus the -breadth matrix and the version-drift check (issue #16 extended the label's -`TEST_FILTER` carve-out in `CMakeLists.txt` with `MdsplusVersionDrift*`, and -added a test-only `find_package(MDSplus)` + include/link wiring onto -`contract_tests`, gated behind the same `AL_BACKEND_MDSPLUS` option). +`test_mdsplus_unique_surface.cpp`, `test_pulse_lifecycle.cpp`, +`test_structured_data.cpp`, and `test_capability_gated.cpp` compiles out +entirely otherwise. Runtime-skipped (`GTEST_SKIP()`, never failed, per D4) +when `MDSPLUS_MODELS_PATH` is unset. CTest label `mdsplus` (`ctest -L mdsplus`) +covers the whole parity set plus the breadth matrix, the version-drift check, +and the unique surface (issue #16 extended the label's `TEST_FILTER` +carve-out in `CMakeLists.txt` with `MdsplusVersionDrift*`, and added a +test-only `find_package(MDSplus)` + include/link wiring onto `contract_tests`, +gated behind the same `AL_BACKEND_MDSPLUS` option; issue #17 extends the same +carve-out with `MdsplusStructAwareSliceInterpolation*`, +`MdsplusTimerangeResampling*`, `MdsplusSegmentBackedWrites*`, +`MdsplusSliceWriteKnownDefects*`, `MdsplusSliceWriteKnownDefectsDeath*`, and +`MdsplusTimebaseCache*` -- no new build plumbing needed, since every case in +the file, including its crash-class death test, goes through the public C +ABI only and adds no raw MDSplus C++ API dependency). Characterization environment: `docker/mdsplus/` (aarch64, DD 4.1.1 baked model tree — see its README for the characterization-discovered deviation from the issue's Ubuntu 22.04 starting point). @@ -455,7 +465,95 @@ cell), confirming the model tree's whole-DD bake, not one container. | Real-path breadth: 5 (type, rank) cells reachable only inside a struct_array — INTEGER r3, DOUBLE r6 (`temporary`, 1 AOS level), COMPLEX r3/r5 (`runaway_electrons`, 1 AOS level), COMPLEX r1 (`waves`, 3 nested AOS levels, the deepest real case in the whole DD) — round-trip via the same `al_begin_arraystruct_action` idiom `AosMatrix` already proved against MDSplus | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/{INTEGER_r3,DOUBLE_r6,COMPLEX_r1,COMPLEX_r3,COMPLEX_r5}` | covered | | ↳ CHAR r0 (a bare scalar char) against a real STR_0D node (`equilibrium`'s `code/name`) — **divergence, not a defect**: MDSplus's real text node is inherently string-shaped (1-D) and correctly refuses a dim=0 read-back ("expected CHAR_DATA in 0D but got CHAR_DATA in 1D") instead of silently misreading it; contrast HDF5's genuine crash on the same synthetic shape (see characterization facts above) | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/CHAR_r0` (`GTEST_SKIP()`) | divergence | | ↳ 13 (type, rank) cells the DD contains nowhere at any nesting depth: CHAR r3–r7, INTEGER r4–r7, DOUBLE r7, COMPLEX r0, COMPLEX r6–r7 (see characterization facts above for the per-type ceiling each hits) | `Mdsplus/MdsplusRealPathMatrix.RealPathRoundTrip/{CHAR_r3..CHAR_r7,INTEGER_r4..INTEGER_r7,DOUBLE_r7,COMPLEX_r0,COMPLEX_r6,COMPLEX_r7}` (13 cases, each `GTEST_SKIP()` with its specific reason) | terminal-gap | -| Unique: struct-aware slice interpolation, timerange resampling, segments, timebase cache | — | gap (deferred — later #12 sub-issue) | +| Unique: struct-aware slice interpolation, timerange resampling, segments, timebase cache | see the "MDSplus-unique surface" subsection below | covered / xfail (issue #17) | + +### MDSplus-unique surface (issue #17) + +`test_mdsplus_unique_surface.cpp` — behavior with no HDF5-tier analogue, +because it depends on MDSplus's real segment-based time-series storage, not +just its real-DD-path model tree. Real path used throughout: +`equilibrium/time_slice` (a `timebasepath="time"` struct_array — each element +carries its own `time` leaf, matching DD 4.1.1's own `coordinate1 +"time_slice(itime)/time"`) and its `global_quantities/ip` leaf, the same path +named as the round-tripping proof in the `AosMatrix`/`EquilibriumSeedMatrix` +divergence notes above and in `test_mdsplus_real_paths.cpp`'s header. + +- **Struct-aware slice interpolation**: `al_begin_arraystruct_action` on a + SLICE_OP context reaches `MDSplusBackend::readSliceApd` + (`mdsplus_backend.cpp:3359`) instead of the plain-leaf `readSlice`; for + `LINEAR_INTERP` it calls `interpolateStruct` (`mdsplus_backend.cpp:3675`), + which interpolates every numeric leaf of the struct, not just one — + confirmed by writing two leaves (`time`, `global_quantities/ip`) inside the + same AOS element and observing both come back correctly interpolated from + one call. HDF5's slice mode has no such capability (it only ever + interpolates a single scalar leaf). +- **Timerange resampling**: no unique MDSplus surface exists. + `supportsTimeRangeOperation()` is unconditionally `false` + (`mdsplus_backend.cpp:5444-5446`), so `al_lowlevel.cpp`'s capability gate + refuses `al_begin_timerange_action` with `LOWLEVEL_ERR` before the backend + is reached at all — already pinned as parity by + `CapabilityMatrix.TimeRangeReadPositiveOrRefused/Mdsplus` + (`test_capability_gated.cpp`). This file re-asserts the same refusal + directly against its own real DD path so the finding is self-contained. +- **Segment-backed dynamic writes**: `MDSplusBackend::beginWriteArraystruct` + dispatches to `writeDynamicApd` (`mdsplus_backend.cpp:2688`) with + `append=true` specifically for a SLICE_OP write against a timebase-carrying + AOS (`mdsplus_backend.cpp:5257,5264,5317,5324`) — genuinely appending one + more element's serialized `MDSplus::Apd` as a new segment, rather than the + whole-container rewrite a GLOBAL_OP write does + (`mdsplus_backend.cpp:5243,5245,5292,5295`). Confirmed by appending five + elements one WRITE session at a time and reading every one back + independently — in contrast to HDF5's own scalar slice-append defect + (`Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior`, + `test_capability_gated.cpp`), MDSplus keeps all of them. One caveat found + empirically: the very first element of a brand-new AOS must still be + established via an ordinary GLOBAL write — appending it via SLICE_OP hits + the same first-segment fragility as the crash defect below (writeDynamicApd + can't create a segment from nothing under `append=true`); once the AOS has + its first segment, further SLICE_OP appends succeed. A narrower shape — a + bare top-level scalar leaf slice-appended against a *separate* external + timebase field, mirroring `Hdf5SliceAppend`'s own idiom verbatim — was tried + first and empirically throws `%TREE-E-NOSEGMENTS` the first time either + field is written with no pre-existing segment in the other; dropped in + favor of the AOS-append form, which is the one this codebase's write + dispatch already treats as the deliberate, first-class append path. Whether + the scalar-leaf shape is a distinct, fixable defect is left unresolved — + out of scope for this pass. +- **New defect found (xfail)**: `MDSplusBackend::writeData`'s SLICE_OP branch + (`mdsplus_backend.cpp:4586-4591`) dereferences `size[0]` unconditionally — + `if(size[0] > 1) writeTimedData(...); else writeSlice(...);` — but the C ABI + passes `size == nullptr` for any dim=0 (scalar) write. Writing **any** + scalar field inside a SLICE_OP write action therefore segfaults MDSplus, + unconditionally — discovered when this file's first draft tried the exact + idiom `Hdf5SliceAppend` uses to seed `homogeneous_time` inside its + slice-write loop (fine on HDF5, crashes on MDSplus). Pinned per D2: + `MdsplusSliceWriteKnownDefects.DISABLED_ScalarWriteWithinSliceActionSucceeds` + (correct contract) + + `MdsplusSliceWriteKnownDefectsDeath.ScalarWriteWithinSliceActionCurrentlyCrashes` + (current-behavior death-test tripwire). +- **Timebase cache**: `MDSplusBackend` caches computed segment/timebase-index + mappings per node (`segmentIdxMap`, `mdsplus_backend.h:283`), populated by + `getSegmentIdxFromSliceIdx` (`mdsplus_backend.cpp:3221`) and reused by every + subsequent `getApdSliceAt` call (e.g. both lookups a single `LINEAR_INTERP` + struct-aware read needs). Every touch site shows it is invalidated + unconditionally (a whole-map clear, not scoped to the written node) at the + *start* of every write dispatch (`writeData`, `mdsplus_backend.cpp:4577`; + `beginWriteArraystruct`, `mdsplus_backend.cpp:4634`) and again at the *end* + of an AOS write (`mdsplus_backend.cpp:5342`) and at `deleteData` + (`mdsplus_backend.cpp:2141`) — so no write path through the C ABI can leave + a stale segment map for a later read. Confirmed by warming the cache with a + slice-interpolated read, forcing an intervening write (an unrelated GLOBAL + scalar write, then a SLICE_OP append growing the very AOS whose segment map + was cached), and showing a repeated slice read afterwards is neither + corrupted by the invalidation nor stale to the new layout. + +| Cluster / Capability | Test(s) | Status | +|---|---|---| +| Struct-aware slice interpolation (CLOSEST/PREVIOUS/LINEAR, whole-struct) | `MdsplusStructAwareSliceInterpolation.{ClosestPicksNearerElement,PreviousPicksLastElementAtOrBelow,LinearInterpolatesWholeStruct}` | covered | +| Timerange resampling — no unique MDSplus surface, refused unconditionally | `MdsplusTimerangeResampling.RefusedWithNoUniqueSurface` | covered | +| Segment-backed dynamic writes — incremental AOS appends all persist | `MdsplusSegmentBackedWrites.AppendedElementsAllPersist` | covered | +| ↳ a dim=0 scalar `al_write_data` inside a SLICE_OP action segfaults MDSplus (`size[0]` dereferenced unconditionally against a `nullptr` size) | `MdsplusSliceWriteKnownDefects.DISABLED_ScalarWriteWithinSliceActionSucceeds` + tripwire `MdsplusSliceWriteKnownDefectsDeath.ScalarWriteWithinSliceActionCurrentlyCrashes` | **xfail** | +| Timebase/segment-index cache survives an intervening invalidating write without staleness or corruption | `MdsplusTimebaseCache.SurvivesInterveningWritesWithoutStaleness` | covered | ### Version-drift check (issue #16) diff --git a/tests/contract/test_mdsplus_unique_surface.cpp b/tests/contract/test_mdsplus_unique_surface.cpp new file mode 100644 index 00000000..001cb1f2 --- /dev/null +++ b/tests/contract/test_mdsplus_unique_surface.cpp @@ -0,0 +1,577 @@ +// MDSplus-unique surface: struct-aware slice interpolation, timerange +// resampling, segment-backed dynamic writes, timebase cache (issue #17, +// TRACEABILITY.md Part 4's last "gap (deferred)" row). +// +// Every other MDSplus test file joins MDSplus to a fixture that also runs on +// HDF5/Memory/ASCII/Flexbuffers (parity) or curates real DD paths for the +// synthetic-path shapes those fixtures can't transfer (issue #15's breadth +// matrix). This file is different: it exercises behavior that has **no +// HDF5-tier analogue at all**, because it depends on MDSplus's real +// segment-based time-series storage model, not just its real-DD-path model +// tree. +// +// --- struct-aware slice interpolation ----------------------------------- +// HDF5's slice mode (Hdf5TimeDependent.SliceInterpolationModes, +// test_capability_gated.cpp) only interpolates a single scalar leaf. +// MDSplus's slice mode can also interpolate a whole array-of-structures +// *element* at once: al_begin_arraystruct_action, called on a SLICE_OP +// operation context whose target path is a real DD-conformant dynamic AOS, +// reaches MDSplusBackend::readSliceApd (src/mdsplus/mdsplus_backend.cpp:3359) +// instead of the plain-leaf MDSplusBackend::readSlice. For LINEAR_INTERP with +// the requested time strictly between two stored elements, readSliceApd +// calls MDSplusBackend::interpolateStruct (mdsplus_backend.cpp:3675), which +// walks the whole struct recursively and interpolates every numeric leaf it +// finds -- confirmed empirically below by writing two leaves inside the same +// AOS element ("time" and "global_quantities/ip") and observing BOTH come +// back correctly interpolated from a single al_begin_arraystruct_action call. +// +// Real path used: equilibrium/time_slice (struct_array, +// timebasepath="time" -- i.e. each element carries its own "time" leaf, +// matching DD 4.1.1's own coordinate1 "time_slice(itime)/time"; confirmed by +// walking build-mdsplus/_deps/data-dictionary-src/IDSDef.xml, the same +// artifact issue #15 curated real paths against). This is the same path +// TRACEABILITY.md's AosMatrix/EquilibriumSeedMatrix divergence notes and +// test_mdsplus_real_paths.cpp's file header already name as the proof that +// real DD-conformant AOS paths round-trip on MDSplus. +// +// --- timerange resampling ------------------------------------------------- +// Empirically, there is no unique surface here: supportsTimeRangeOperation() +// is unconditionally false for MDSplus (mdsplus_backend.cpp:5444-5446), so +// al_lowlevel.cpp's capability gate (al_lowlevel.cpp:1053) refuses +// al_begin_timerange_action with LOWLEVEL_ERR before the backend is ever +// reached -- already pinned as parity by +// CapabilityMatrix.TimeRangeReadPositiveOrRefused/Mdsplus +// (test_capability_gated.cpp). MDSplus's real time-dependent reads go +// through slice mode instead (struct-aware interpolation above), never +// through time-range resampling. The test below re-asserts the refusal +// directly against this file's own real DD path, so this file is a +// self-contained record of the finding rather than a cross-file assumption. +// +// --- segment-backed dynamic writes ----------------------------------------- +// MDSplusBackend::beginWriteArraystruct dispatches to +// MDSplusBackend::writeDynamicApd (mdsplus_backend.cpp:2688) with its `append` +// parameter set to true specifically when the enclosing operation is a +// SLICE_OP against a real timebase-carrying AOS (mdsplus_backend.cpp:5257, +// 5264,5317,5324) -- as opposed to the whole-container rewrite a GLOBAL_OP +// write does (mdsplus_backend.cpp:5243,5245,5292,5295, the form the +// struct-aware-interpolation/timebase-cache tests above use to seed their +// fixtures). This is the ABI-observable manifestation of "segment-backed +// dynamic write": al_begin_slice_action(WRITE_OP, UNDEFINED_TIME) + +// al_begin_arraystruct_action, called once per new element, appends that +// element's serialized MDSplus::Apd as one more segment on the AOS node (the +// same per-segment storage readDynamicApd/getApdSliceAt already walk on read, +// mdsplus_backend.cpp:3158,3293) -- confirmed below by appending kN elements +// one WRITE session at a time and reading every one of them back +// independently, in contrast to HDF5's scalar slice-append defect +// (Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior, +// test_capability_gated.cpp) where only the last write survives. This is +// correct MDSplus behavior, not a defect -- no xfail pairing needed. +// +// A different, narrower shape -- a bare top-level scalar leaf slice-appended +// against a *separate* external timebase field via al_write_data's `timebase` +// parameter (mirroring Hdf5SliceAppend's own idiom verbatim) -- was tried +// first and empirically found to throw "%TREE-E-NOSEGMENTS" on MDSplus the +// very first time either field is written with no pre-existing segment in +// the other; that narrower shape was dropped in favor of the AOS-append form +// above, which is the one this codebase's own write dispatch already treats +// as the deliberate, first-class "append" path (the `append=true` call sites +// just cited). Whether the scalar-leaf shape is a distinct, fixable defect or +// an unsupported combination is left unresolved -- out of scope for this +// characterization pass, which pins the crash-class defect it did surface +// (MdsplusSliceWriteKnownDefects* below) and otherwise sticks to the +// mechanism the backend actually supports. +// +// --- timebase cache -------------------------------------------------------- +// MDSplusBackend keeps a per-node cache of computed segment/timebase-index +// mappings (segmentIdxMap, mdsplus_backend.h:283), populated on first use by +// getSegmentIdxFromSliceIdx (mdsplus_backend.cpp:3221) and consulted again by +// every subsequent getApdSliceAt call -- e.g. the two lookups a single +// LINEAR_INTERP struct-aware slice read needs (sliceIdx and sliceIdx1) reuse +// the one cached vector rather than recomputing it twice. Grepping every +// touch site shows it is invalidated unconditionally (a whole-map clear, not +// scoped to the written node) at the *start* of every write dispatch +// (MDSplusBackend::writeData, mdsplus_backend.cpp:4577; +// MDSplusBackend::beginWriteArraystruct, mdsplus_backend.cpp:4634) and again +// at the *end* of an AOS write (mdsplus_backend.cpp:5342) and at +// MDSplusBackend::deleteData (mdsplus_backend.cpp:2141) -- so no write path +// through the C ABI can leave a stale segment map for a later read to +// consult. The test below is this finding's characterization: it warms the +// cache with a slice-interpolated read, forces an intervening write (both an +// unrelated GLOBAL scalar write, and a SLICE_OP append that grows the very +// AOS whose segment map was cached by one more element -- see the +// segment-backed-writes section above for why append, not a second GLOBAL +// rewrite, is the correct way to grow it), and confirms a repeated slice read +// afterwards is neither corrupted by the invalidation nor stale to the new +// layout the intervening write introduced. +#ifdef AL_CONTRACT_HAVE_MDSPLUS + +#include "al_contract.h" +#include "equilibrium_seed.h" + +#include +#include + +#include + +#include +#include + +using al_contract::PulseId; + +namespace { + +// --- real DD-4.1.1 paths (equilibrium/time_slice, see file header) -------- +constexpr const char* kIds = equilibrium_seed::kIds; // "equilibrium" +constexpr const char* kAos = "time_slice"; // struct_array +constexpr const char* kAosTime = "time"; // timebasepath +constexpr const char* kIp = "global_quantities/ip"; // FLT_0D leaf + +// =========================================================================== +// Struct-aware slice interpolation: a whole AOS element, two leaves at once. +// =========================================================================== +class MdsplusStructAwareSliceInterpolation : public ::testing::Test { +protected: + void SetUp() override { + AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); + base_.make_legacy_tree(pulse_); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + AL_ASSERT_OK( + al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx_)); + write_two_time_slices(); + } + void TearDown() override { + if (pctx_ >= 0) al_close_pulse(pctx_, CLOSE_PULSE); + } + + // time_slice[0] = {time=1.0, ip=10.0}; time_slice[1] = {time=2.0, ip=20.0}. + void write_two_time_slices() { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx_, kIds, "", WRITE_OP, &op)); + int size = 2; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {kT0})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {kV0})); + AL_EXPECT_OK(al_iterate_over_arraystruct(aos, 1)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {kT1})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {kV1})); + AL_ASSERT_OK(al_end_action(aos)); + AL_ASSERT_OK(al_end_action(op)); + } + + // Slice-reads time_slice at `time`/`interp`; returns the single + // interpolated element's ("time", "ip") pair via out params. Fails the + // test fatally (not via a return code) if the struct-aware slice itself + // is refused, so a downstream value mismatch can never be blamed on a + // broken setup. + void slice_read_struct(double time, int interp, double* out_time, + double* out_ip) { + int op = -1; + ASSERT_EQ(al_begin_slice_action(pctx_, kIds, READ_OP, time, interp, &op) + .code, + 0); + int size = 0; + int aos = -1; + ASSERT_EQ( + al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos).code, 0) + << "struct-aware slice read must be accepted"; + ASSERT_EQ(size, 1) + << "a slice read of an AOS must collapse to exactly one element"; + + std::vector shape; + std::vector time_val, ip_val; + EXPECT_EQ(al_contract::read_data(aos, kAosTime, 0, &shape, + &time_val) + .code, + 0); + EXPECT_EQ( + al_contract::read_data(aos, kIp, 0, &shape, &ip_val).code, + 0); + ASSERT_EQ(time_val.size(), 1u); + ASSERT_EQ(ip_val.size(), 1u); + *out_time = time_val[0]; + *out_ip = ip_val[0]; + + EXPECT_EQ(al_end_action(aos).code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + } + + static constexpr double kT0 = 1.0, kT1 = 2.0; + static constexpr double kV0 = 10.0, kV1 = 20.0; + + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/17, + /*run=*/0}; + int pctx_ = -1; +}; + +TEST_F(MdsplusStructAwareSliceInterpolation, ClosestPicksNearerElement) { + double t = -1.0, ip = -1.0; + slice_read_struct(1.4, CLOSEST_INTERP, &t, &ip); + EXPECT_EQ(t, kT0); + EXPECT_EQ(ip, kV0); + + slice_read_struct(1.6, CLOSEST_INTERP, &t, &ip); + EXPECT_EQ(t, kT1); + EXPECT_EQ(ip, kV1); +} + +TEST_F(MdsplusStructAwareSliceInterpolation, PreviousPicksLastElementAtOrBelow) { + double t = -1.0, ip = -1.0; + slice_read_struct(1.9, PREVIOUS_INTERP, &t, &ip); + EXPECT_EQ(t, kT0); + EXPECT_EQ(ip, kV0); +} + +// The struct-aware case: LINEAR_INTERP at the midpoint interpolates BOTH +// leaves of the element together ("time" and "global_quantities/ip"), from +// one al_begin_arraystruct_action call -- this is the behavior with no HDF5 +// analogue (HDF5's slice mode only ever interpolates one scalar leaf at a +// time, never a whole struct). +TEST_F(MdsplusStructAwareSliceInterpolation, LinearInterpolatesWholeStruct) { + double t = -1.0, ip = -1.0; + slice_read_struct(1.5, LINEAR_INTERP, &t, &ip); + EXPECT_EQ(t, (kT0 + kT1) / 2.0) << "the element's own timebase leaf must " + "interpolate, not just the signal"; + EXPECT_EQ(ip, (kV0 + kV1) / 2.0); +} + +// =========================================================================== +// Timerange resampling: no unique MDSplus surface -- refused unconditionally. +// =========================================================================== +TEST(MdsplusTimerangeResampling, RefusedWithNoUniqueSurface) { + AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); + al_contract::TempBase base; + PulseId pulse{"test", "3", 17, 0}; + base.make_legacy_tree(pulse); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + int op = -1; + double dtime = 0.0; + int dtime_shape = 0; + al_status_t s = al_begin_timerange_action(pctx, kIds, READ_OP, 0.0, 10.0, + &dtime, &dtime_shape, + UNDEFINED_INTERP, &op); + EXPECT_NE(s.code, 0) + << "supportsTimeRangeOperation() is unconditionally false for " + "MDSplus (mdsplus_backend.cpp:5444-5446) -- there is no MDSplus " + "timerange-resampling surface to characterize; time-dependent " + "reads go through slice mode instead, see " + "MdsplusStructAwareSliceInterpolation above"; + EXPECT_EQ(s.code, LOWLEVEL_ERR) + << "documented refusal is LOWLEVEL_ERR (al_lowlevel.cpp:1053), same " + "as every other non-HDF5 always-on backend"; + + al_close_pulse(pctx, CLOSE_PULSE); +} + +// =========================================================================== +// Segment-backed dynamic writes: incremental AOS appends all persist. +// =========================================================================== +// MDSplusBackend::beginWriteArraystruct dispatches to +// MDSplusBackend::writeDynamicApd (mdsplus_backend.cpp:2688) with its `append` +// parameter set to `true` specifically when the enclosing operation context +// is a SLICE_OP whose target is a real timebase-carrying AOS +// (mdsplus_backend.cpp:5257,5264,5317,5324 -- contrasted with the `false`/ +// whole-rewrite form the GLOBAL_OP writes at mdsplus_backend.cpp:5243,5245, +// 5292,5295 use, and the one this file's struct-aware-interpolation / +// timebase-cache tests above use). This is the ABI-observable manifestation +// of "segment-backed dynamic write": al_begin_slice_action(WRITE_OP, +// UNDEFINED_TIME) + al_begin_arraystruct_action, called once per new element, +// appends that element's serialized MDSplus::Apd as one more segment onto the +// AOS node (the same per-segment storage readDynamicApd/getApdSliceAt walk on +// read, mdsplus_backend.cpp:3158,3293) -- rather than the whole-container +// rewrite a GLOBAL write does. Confirmed below by appending kN elements one +// session at a time and reading every one of them back independently, in +// contrast to HDF5's scalar slice-append defect +// (Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior, +// test_capability_gated.cpp) where only the last write survives. +class MdsplusSegmentBackedWrites : public ::testing::Test { +protected: + static constexpr int kN = 5; + + void SetUp() override { AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); } + + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/17, + /*run=*/0}; +}; + +TEST_F(MdsplusSegmentBackedWrites, AppendedElementsAllPersist) { + base_.make_legacy_tree(pulse_); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + double t[kN], v[kN]; + for (int k = 0; k < kN; ++k) { + t[k] = 1.0 + k; + v[k] = 10.0 * (k + 1); + } + + // Element 0 establishes the AOS's very first segment via an ordinary + // GLOBAL write -- empirically, writeDynamicApd's append=true path (taken + // for a SLICE_OP write) throws "%TREE-E-NOSEGMENTS" the first time it + // must create a segment from nothing, the same first-segment fragility + // as MdsplusSliceWriteKnownDefects below hits for a bare scalar leaf. + // Once the AOS has its first segment, further SLICE_OP appends succeed. + { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + int size = 1; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {t[0]})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {v[0]})); + AL_ASSERT_OK(al_end_action(aos)); + AL_ASSERT_OK(al_end_action(op)); + } + + // Elements 1..kN-1 are appended one at a time -- WRITE_OP + UNDEFINED_TIME, + // one al_begin_slice_action/al_begin_arraystruct_action(size=1)/al_end_action + // set per element, never writing more than one element per session. + for (int k = 1; k < kN; ++k) { + int op = -1; + AL_ASSERT_OK(al_begin_slice_action(pctx, kIds, WRITE_OP, UNDEFINED_TIME, + UNDEFINED_INTERP, &op)); + int size = 1; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {t[k]})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {v[k]})); + AL_ASSERT_OK(al_end_action(aos)); + AL_ASSERT_OK(al_end_action(op)); + } + + // Every appended element must be independently retrievable by its own + // time -- unlike HDF5's scalar slice-append defect + // (Hdf5SliceAppend.OnlyLastAppendedSlicePersists_CurrentBehavior), MDSplus's + // segment-backed AOS storage keeps all kN, not just the last. + for (int k = 0; k < kN; ++k) { + int op = -1; + AL_ASSERT_OK(al_begin_slice_action(pctx, kIds, READ_OP, t[k], + CLOSEST_INTERP, &op)); + int size = 0; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos)); + ASSERT_EQ(size, 1); + std::vector shape; + std::vector value; + AL_EXPECT_OK(al_contract::read_data(aos, kIp, 0, &shape, &value)); + ASSERT_EQ(value.size(), 1u); + EXPECT_EQ(value[0], v[k]) << "element " << k << " (t=" << t[k] + << ") did not persist its own value"; + AL_EXPECT_OK(al_end_action(aos)); + AL_EXPECT_OK(al_end_action(op)); + } + + al_close_pulse(pctx, CLOSE_PULSE); +} + +// Backs the claim in AppendedElementsAllPersist's setup comment with its own +// assertion (D2: don't freeze an empirical finding as an unpinned comment) -- +// a divergence, not a defect: appending the very first element of a +// brand-new AOS via SLICE_OP has no existing segment to extend, and MDSplus +// refuses gracefully (a caught, returned error, not a crash) rather than +// silently doing something undefined. +TEST_F(MdsplusSegmentBackedWrites, FirstElementCannotBeAppendedViaSliceAction) { + base_.make_legacy_tree(pulse_); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + int op = -1; + AL_ASSERT_OK(al_begin_slice_action(pctx, kIds, WRITE_OP, UNDEFINED_TIME, + UNDEFINED_INTERP, &op)); + int size = 1; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {1.0})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {10.0})); + al_status_t s = al_end_action(aos); + EXPECT_NE(s.code, 0) + << "expected MDSplus to refuse a SLICE_OP append against a " + "brand-new, still-empty AOS (no prior segment to extend)"; + al_end_action(op); + al_close_pulse(pctx, CLOSE_PULSE); +} + +// --------------------------------------------------------------------------- +// KNOWN DEFECT (decision D2), discovered while characterizing segment-backed +// writes above: MDSplusBackend::writeData's SLICE_OP branch +// (mdsplus_backend.cpp:4586-4591) dereferences `size[0]` unconditionally -- +// case alconst::slice_op: +// if(size[0] > 1) writeTimedData(...); else writeSlice(...); +// -- but the C ABI passes `size == nullptr` for any dim=0 (scalar) write +// (al_lowlevel.h; al_contract::write_int_scalar documents this convention). +// Writing ANY scalar field inside a SLICE_OP write action therefore +// segfaults MDSplus, unconditionally -- confirmed empirically the first time +// this file tried the exact idiom Hdf5SliceAppend (test_capability_gated.cpp) +// uses to seed homogeneous_time inside its slice-write loop, which is fine on +// HDF5 but crashes on MDSplus. Not scoped to homogeneous_time specifically: +// any dim=0 field hits the same unconditional dereference. +// --------------------------------------------------------------------------- +al_status_t write_scalar_within_slice_action() { + al_contract::TempBase base; + PulseId pulse{"test", "3", 17, 0}; + base.make_legacy_tree(pulse); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx) + .code, + 0); + + int op = -1; + EXPECT_EQ(al_begin_slice_action(pctx, "magnetics", WRITE_OP, UNDEFINED_TIME, + UNDEFINED_INTERP, &op) + .code, + 0); + int ht = 1; + al_status_t s = al_write_data(op, "ids_properties/homogeneous_time", "", + &ht, INTEGER_DATA, 0, nullptr); + al_end_action(op); + al_close_pulse(pctx, CLOSE_PULSE); + return s; +} + +TEST(MdsplusSliceWriteKnownDefects, DISABLED_ScalarWriteWithinSliceActionSucceeds) { + AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); + EXPECT_EQ(write_scalar_within_slice_action().code, 0) + << "a dim=0 (scalar) al_write_data inside a SLICE_OP action must not " + "crash MDSplus"; +} + +TEST(MdsplusSliceWriteKnownDefectsDeath, + ScalarWriteWithinSliceActionCurrentlyCrashes) { + AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); + GTEST_FLAG_SET(death_test_style, "threadsafe"); + ASSERT_DEATH(write_scalar_within_slice_action(), ".*") + << "expected a scalar write inside a SLICE_OP action to crash " + "MDSplus (mdsplus_backend.cpp:4586-4591 dereferences size[0] " + "unconditionally, but the C ABI passes size=nullptr for dim=0). " + "If it no longer crashes, enable " + "MdsplusSliceWriteKnownDefects.DISABLED_ScalarWriteWithinSliceActionSucceeds"; +} + +// =========================================================================== +// Timebase cache: correctness across an invalidating write, not staleness. +// =========================================================================== +TEST(MdsplusTimebaseCache, SurvivesInterveningWritesWithoutStaleness) { + AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); + al_contract::TempBase base; + PulseId pulse{"test", "3", 17, 0}; + base.make_legacy_tree(pulse); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse); + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + + // --- seed: time_slice[0..1] = {(1.0, 10.0), (2.0, 20.0)} ---------------- + { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + int size = 2; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {1.0})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {10.0})); + AL_EXPECT_OK(al_iterate_over_arraystruct(aos, 1)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {2.0})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {20.0})); + AL_ASSERT_OK(al_end_action(aos)); + AL_ASSERT_OK(al_end_action(op)); + } + + auto interp_ip_at = [&](double time, int interp) { + int op = -1; + EXPECT_EQ( + al_begin_slice_action(pctx, kIds, READ_OP, time, interp, &op).code, + 0); + int size = 0; + int aos = -1; + EXPECT_EQ( + al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos).code, 0); + std::vector shape; + std::vector ip_val; + EXPECT_EQ( + al_contract::read_data(aos, kIp, 0, &shape, &ip_val).code, + 0); + EXPECT_EQ(al_end_action(aos).code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + return ip_val.empty() ? -1.0 : ip_val[0]; + }; + auto linear_interp_ip_at = [&](double time) { + return interp_ip_at(time, LINEAR_INTERP); + }; + auto closest_interp_ip_at = [&](double time) { + return interp_ip_at(time, CLOSEST_INTERP); + }; + + // --- warm the timebase/segment cache for the time_slice node ------------ + ASSERT_EQ(linear_interp_ip_at(1.5), 15.0); + + // --- an unrelated GLOBAL write elsewhere in the same pulse clears the + // whole-map cache unconditionally (MDSplusBackend::writeData, + // mdsplus_backend.cpp:4577-4578) ------------------------------------------ + { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data( + op, equilibrium_seed::kScalar, {}, {6.2})); + AL_ASSERT_OK(al_end_action(op)); + } + + // --- re-read the SAME interpolated point: must reproduce the same + // value, not a corrupted one, after the cache was invalidated and must + // rebuild from scratch -------------------------------------------------- + EXPECT_EQ(linear_interp_ip_at(1.5), 15.0) + << "an unrelated write must not corrupt a subsequent struct-aware " + "slice interpolation on an already-cached AOS node"; + + // --- append a THIRD element via SLICE_OP (the genuinely-additive path + // MdsplusSegmentBackedWrites above confirms -- a second GLOBAL write of a + // fresh N-element AOS does NOT replace what is already stored: writeDynamicApd + // computes its next slice index by walking the existing segments + // regardless of the `append` flag (mdsplus_backend.cpp:2746-2776), so it + // would instead produce a non-monotonic five-element timebase + // {1,2,1,2,3} and corrupt the interpolation oracle below for reasons + // unrelated to the cache). The append also clears the cache, via + // MDSplusBackend::beginWriteArraystruct (mdsplus_backend.cpp:4634) and + // again at the end of the AOS write (mdsplus_backend.cpp:5342) ---------- + { + int op = -1; + AL_ASSERT_OK(al_begin_slice_action(pctx, kIds, WRITE_OP, UNDEFINED_TIME, + UNDEFINED_INTERP, &op)); + int size = 1; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, kAosTime, {}, {3.0})); + AL_EXPECT_OK(al_contract::write_data(aos, kIp, {}, {30.0})); + AL_ASSERT_OK(al_end_action(aos)); + AL_ASSERT_OK(al_end_action(op)); + } + + // --- the freshly-rebuilt cache must reflect the NEW segment layout, not + // a stale two-element view ----------------------------------------------- + EXPECT_EQ(closest_interp_ip_at(3.0), 30.0) + << "a slice read after appending a new element must see it -- the " + "cache must not still reflect the pre-append segment layout"; + EXPECT_EQ(linear_interp_ip_at(1.5), 15.0) + << "the original interpolated point must be unaffected by the append"; + + al_close_pulse(pctx, CLOSE_PULSE); +} + +} // namespace + +#endif // AL_CONTRACT_HAVE_MDSPLUS From 21bc63460f10fed9aa2f99453ba2448131d3f93d Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:33:48 +0200 Subject: [PATCH 27/91] Close TRACEABILITY.md Part 4 and add non-blocking mdsplus-contract CI leg (issue #18) Closes the one capability with no MDSplus verdict at all (al_delete_data, not joined to DeleteMatrix) as an explicit terminal-gap row, threads "see Part 4" notes through the shared always-on rows in Part 1/2, and adds a progress-summary line atop Part 4. Adds mdsplus-contract.yml, mirroring the D7 sanitizer leg: continue-on-error, aarch64 runner, and a two-layer cache (toolchain image + baked DD-4.1.1 model tree). --- .github/workflows/mdsplus-contract.yml | 103 +++++++++++++++++++++++++ tests/contract/TRACEABILITY.md | 48 +++++++----- 2 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/mdsplus-contract.yml diff --git a/.github/workflows/mdsplus-contract.yml b/.github/workflows/mdsplus-contract.yml new file mode 100644 index 00000000..df95fab1 --- /dev/null +++ b/.github/workflows/mdsplus-contract.yml @@ -0,0 +1,103 @@ +name: MDSplus contract suite (non-blocking) + +# Task 2 issue #18 (parent #12): a dedicated, informational CI leg for the +# MDSplus tier (tests/contract/test_mdsplus*.cpp, TRACEABILITY.md Part 4), +# mirroring the D7 sanitizer leg (sanitizers.yml). continue-on-error: true — +# informational, not a merge gate, until the characterization baseline +# (issues #13-17) is triaged. Promotion to a blocking gate is deferred to +# Task 3. +# +# Runs on a GitHub-hosted arm64 runner: MDSplus publishes no arm64 packages +# for Ubuntu 22, only 24.04+ (docker/mdsplus/README.md's deviation note), and +# the characterization image itself is aarch64. +# +# Two-layer cache, so an ordinary push doesn't pay the full toolchain-install +# + DD-download cost every time: +# 1. the toolchain image (docker/mdsplus/Dockerfile) — keyed on its hash. +# A miss means re-running the MDSplus/Java/DD-toolchain package install. +# 2. the baked DD model tree (build-mdsplus/) — keyed on the DD version +# plus the model-build inputs (ALBuildDataDictionary.cmake, +# models/mdsplus/CMakeLists.txt). A hit restores the whole configured +# build directory (including the generated ids.xml and the model-tree +# files under build-mdsplus/models/mdsplus), so the configure step is +# skipped entirely on a hit — CMake's own dependency tracking then only +# rebuilds C++ sources that actually changed. + +on: + push: + pull_request: + +env: + DD_VERSION: "4.1.1" + IMAGE_TAG: imas-core-mdsplus:ci + +jobs: + mdsplus-contract: + name: MDSplus contract suite (aarch64, non-blocking) + runs-on: ubuntu-24.04-arm + continue-on-error: true # informational leg, not a gate — for now + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 # git describe needs full history for the version macro + + - name: Cache toolchain image (layer 1 — keyed on Dockerfile) + id: cache-image + uses: actions/cache@v4 + with: + path: /tmp/mdsplus-image.tar + key: mdsplus-image-${{ hashFiles('docker/mdsplus/Dockerfile') }} + + - name: Build toolchain image + if: steps.cache-image.outputs.cache-hit != 'true' + run: | + docker build -f docker/mdsplus/Dockerfile -t "${IMAGE_TAG}" docker/mdsplus + docker save "${IMAGE_TAG}" -o /tmp/mdsplus-image.tar + + - name: Load cached toolchain image + if: steps.cache-image.outputs.cache-hit == 'true' + run: docker load -i /tmp/mdsplus-image.tar + + - name: Cache baked DD model tree (layer 2 — keyed on DD version + model-build inputs) + id: cache-model + uses: actions/cache@v4 + with: + path: build-mdsplus + key: mdsplus-model-${{ env.DD_VERSION }}-${{ hashFiles('common/cmake/ALBuildDataDictionary.cmake', 'models/mdsplus/CMakeLists.txt') }} + + - name: Configure (bakes the DD model tree on a cache miss) + if: steps.cache-model.outputs.cache-hit != 'true' + run: > + docker run --rm --platform linux/arm64 + -v "$PWD":/workspace -w /workspace + "${IMAGE_TAG}" bash -c " + cmake -B build-mdsplus -G Ninja + -DAL_BACKEND_MDSPLUS=ON + -DAL_BUILD_MDSPLUS_MODELS=ON + -DAL_BUILD_TESTS=ON + -DDD_VERSION=${DD_VERSION} + -DCMAKE_BUILD_TYPE=Release + " + + - name: Build contract suite + run: > + docker run --rm --platform linux/arm64 + -v "$PWD":/workspace -w /workspace + "${IMAGE_TAG}" bash -c " + cmake --build build-mdsplus --target contract_tests -j\$(nproc) + " + + - name: Run MDSplus contract suite + run: > + docker run --rm --platform linux/arm64 + -v "$PWD":/workspace -w /workspace + -e MDSPLUS_MODELS_PATH=/workspace/build-mdsplus/models/mdsplus + "${IMAGE_TAG}" bash -c " + cd build-mdsplus/tests/contract && ctest -L mdsplus --output-on-failure + " + + - name: Note + if: always() + run: echo "MDSplus leg is informational (issue #12/#18). Red-by-design until the characterization baseline is triaged; promotion to a blocking gate is Task 3." diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index c70c04a4..35898811 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -93,7 +93,7 @@ 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.*` | 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 | covered | | ↳ ASCII's `CREATE_PULSE` has no existence guard — **silently overwrites/truncates an existing pulse** instead of refusing it | :90-107; src/ascii_backend.cpp:107-157 | ✓ | — | `ModeKnownDefects.DISABLED_AsciiCreatePulseFailsWhenAlreadyExists` + tripwire `ModeKnownDefects.AsciiCreatePulseCurrentlyOverwritesSilently` | **xfail** | | `al_close_pulse` closes (`CLOSE_PULSE`) / erases (`ERASE_PULSE`) | :109-116 | ✓ (Memory) | ✓ (HDF5, ASCII) | `CLOSE_PULSE` asserted `code==0` in every suite | covered (indirect) | | ↳ `ERASE_PULSE` is not implemented by any always-on backend — **behaves identically to `CLOSE_PULSE`** (none of HDF5/ASCII/Memory's `closePulse` reference their `mode` parameter); a later plain `OPEN_PULSE` on the "erased" pulse still succeeds | :109-116; src/hdf5/{hdf5_reader,hdf5_writer}.cpp closePulse, src/ascii_backend.cpp:165-169, src/memory_backend.h:505-509 | ✓ (Memory) | ✓ (HDF5, ASCII) | `ErasePulseKnownDefects.DISABLED_{Hdf5,Ascii,Memory}EraseMakesPulseUnopenable` + tripwires `ErasePulseKnownDefects.{Hdf5,Ascii,Memory}EraseCurrentlyLeavesPulseOpenable` | **xfail** | @@ -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) | 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` | 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 | 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 | | ↳ 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` | 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` | 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 | 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 | | ↳ 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` | 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 | covered | | ↳ Flexbuffers must-refuse column (serializer, not a pulse store): write accepted, read refused, every datatype × shape | :244-263 / D4 | — | ✓ | `…/Flexbuffers_*_r{0..7}` (paired-negative) | covered | | ↳ CHAR > 2-D refused (documented "not implemented") on HDF5·ASCII | :244-263 / D4 | — | ✓ | `…/{HDF5,ASCII}_CHAR_r{3..7}` (paired-negative) | covered | | ↳ CHAR scalar (dim 0) must round-trip — **HDF5 crashes** | :244-263 | — | ✓ | `RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips` + tripwire `RoundTripKnownDefectsDeath.Hdf5CharScalarCurrentlyCrashes` | **xfail** | | ↳ numeric at MAXDIM (rank 7) must round-trip — **ASCII corrupts (DOUBLE/COMPLEX) / aborts (INTEGER)** | :244-263 | — | ✓ | `RoundTripKnownDefects.DISABLED_Ascii{Integer,Double,Complex}MaxdimRoundTrips` + tripwires `RoundTripKnownDefectsDeath.AsciiIntegerMaxdimCurrentlyAborts`, `RoundTripKnownDefects.Ascii{Double,Complex}MaxdimCurrentlyCorrupts` | **xfail** | -| `al_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) | 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) | 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` | 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` | 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`) | 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 | ### 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}` | 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 | covered | ## Cluster 3 — Plugin management (`FUNCTIONALITY_INVENTORY.md:318-430`) @@ -218,17 +218,17 @@ and points at the same test. | Cluster / Capability | Inventory ref | Unit | Integ. | Test(s) — and residual | Status | |---|---|:--:|:--:|---|---| -| **A** `initBackend` factory selects the backend from the URI | :496-514 | ✓ (Memory) | ✓ (HDF5, ASCII) | every always-on backend instantiated via `build_uri` in `RoundTripMatrix`. Residual: the unrecognized-ID throw path (`al_backend.cpp:82-86`) is straightforward by inspection (a plain `else` throwing `ALBackendException`) but untested — low-value relative to the rest of this row, left as a residual rather than a separate row | covered (indirect) | -| **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 | gap | +| **A** `initBackend` factory selects the backend from the URI | :496-514 | ✓ (Memory) | ✓ (HDF5, ASCII) | every always-on backend instantiated via `build_uri` in `RoundTripMatrix`. Residual: the unrecognized-ID throw path (`al_backend.cpp:82-86`) is straightforward by inspection (a plain `else` throwing `ALBackendException`) but untested — low-value relative to the rest of this row, left as a residual rather than a separate row. MDSplus is instantiated too, via its own URI in every Part 4 fixture — see Part 4 | covered (indirect) | +| **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) | covered | -| **C** `deleteData` | :591-599 | ✓ (Memory) | ✓ (HDF5) | via `al_delete_data` — see Cluster 2 row above and its three xfail rows | covered (indirect) | -| **C** `beginArraystructAction` | :601-606 | ✓ (Memory) | ✓ (HDF5) | via `al_begin_arraystruct_action` — see Cluster 2 row above | covered (indirect) | -| **D** `get_occurrences` | :612-616 | — | ✓ | via `al_get_occurrences` — `Occurrences.*` | 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 | 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 | 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 | gap | +| **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 | +| **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 | --- @@ -281,6 +281,15 @@ whoever fixed it to enable the paired `DISABLED_` correct-contract test: # Part 4 — MDSplus backend (compile-guarded tier) (issue #12) +**Progress summary (issue #18): zero `gap` rows remain in Part 4 — every one +of its 18 rows is terminal (12 `covered`/`covered (indirect)`, 3 +`divergence`, 1 `xfail`, 2 `terminal-gap`).** The one capability with no +MDSplus row at all as of issue #17 — `al_delete_data` — is closed here as an +explicit `terminal-gap` row (see the `DeleteMatrix` row below) rather than +left silently absent; every other row already carried a terminal verdict. +The corresponding shared always-on rows in Part 1/Part 2 above now each +carry a one-line "MDSplus: see Part 4" note. + **Progress: the MDSplus-unique surface landed (issue #17), closing Part 4's last deferred row, on top of the version-drift check (issue #16), the real-DD-path datatype × rank breadth (issue #15), the parity sweep (issue @@ -395,6 +404,7 @@ explicit open items), not assumed: | Parity: `Occurrences` — real implementation, `/` naming convention (see characterization facts above) | `Occurrences.MdsplusListsWrittenOccurrences` | covered | | Parity: `AosMatrix` (top-level + nested write→iterate→read) — **divergence, not a defect**: MDSplus resolves every path against its real DD-baked model tree, so this fixture's synthetic field names (`"elements"`/`"val"`, `"outer"`/`"inner"`) have no corresponding node; `al_begin_arraystruct_action` and the per-element writes report success (buffered), but the flush at `al_end_action` throws `%TREE-W-NNF, Node Not Found`. Real DD-conformant AOS paths round-trip through the identical sequence (see characterization facts above) — the fixture's synthetic-path design simply cannot transfer to a model-tree-backed backend, the same reason `RoundTripMatrix` already excludes MDSplus (issue #12 Q5) | `Backends/AosMatrix.{TopLevelWriteIterateRead,NestedWriteIterateRead}/Mdsplus` (both `GTEST_SKIP()`, `AosExpect::Divergence`) | divergence | | Parity: `EquilibriumSeedMatrix` (composite scalar + timebase-carrying 2-D array + `constraints` AOS, hash oracle) — **divergence, not a defect**: the scalar sub-shape round-trips (already proven by the tracer bullet above), but the seed's flat-tensorized `profiles_1d/psi` write and its generic `constraints`/`{measured,weight}` AOS shape don't match the real equilibrium DD layout MDSplus enforces (real `profiles_1d` is a genuine dynamic AOS, not a flat dataset at that path; real `constraints` is a fixed container of specific constraint sub-objects, not a generic AOS) — both throw `%TREE-W-NNF, Node Not Found`. MDSplus **is** instantiated in `kSeedBackends[]` (mirroring `AosMatrix` above), and the case is skipped in-place via `GTEST_SKIP()` rather than run and fail | `Backends/EquilibriumSeedMatrix.RoundTripHashMatches/Mdsplus` (`GTEST_SKIP()`) | divergence | +| Parity: `DeleteMatrix` (leaf / structure / DATAOBJECT-root granularity) — **terminal gap, closed here as an explicit row rather than left silently absent (issue #18)**: MDSplus is not instantiated in `kDeleteBackends[]` at all. Joining it would hit the same wall as `AosMatrix`/`RoundTripMatrix` above — the fixture's synthetic paths (`leaf_a`, `block`, top-level `""`) have no corresponding model-tree node, so even the divergence-and-skip pattern used for `AosMatrix`/`EquilibriumSeedMatrix` doesn't cleanly apply (unlike those, `MDSplusBackend::deleteData` — `mdsplus_backend.cpp:2136` — genuinely implements path-scoped leaf/structure delete, unlike HDF5's ignore-`path` defect, so a real characterization would be informative, not a structural no-op). A dedicated real-DD-path delete test mirroring `MdsplusRealPathMatrix` (issue #15) was not built in this closing pass — left as a deliberate, explained terminal gap for a future step, not a defect and not a silent omission | none — see reasoning | terminal-gap | ### Real-DD-path datatype × rank breadth (issue #15) From 82e03aa0cb2f851ccd2dba37627f2b61b75d508d 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 28/91] 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). --- 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 e22a6064..3fc9e82c 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*:MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*:MdsplusStructAwareSliceInterpolation*:MdsplusTimerangeResampling*:MdsplusSegmentBackedWrites*:MdsplusSliceWriteKnownDefects*:MdsplusSliceWriteKnownDefectsDeath*:MdsplusTimebaseCache*" + TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*: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 7e0ee7ccd9b27c82d77a174cac5ffe53a04e488f 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 29/91] 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. --- 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 edb19383f8af3ba4522b8a102e4ef935bd61de65 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 30/91] 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. --- 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 3fc9e82c..148d510d 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*:MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*:MdsplusStructAwareSliceInterpolation*:MdsplusTimerangeResampling*:MdsplusSegmentBackedWrites*:MdsplusSliceWriteKnownDefects*:MdsplusSliceWriteKnownDefectsDeath*:MdsplusTimebaseCache*:UdaSmokeRoundTrip*" + TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*: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 8e990fa4..28615795 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 2314b2a58b7971e95e411d04b905bba34a2ffb6f 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 31/91] 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). --- 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 148d510d..446f4a0a 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*:MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*:MdsplusStructAwareSliceInterpolation*:MdsplusTimerangeResampling*:MdsplusSegmentBackedWrites*:MdsplusSliceWriteKnownDefects*:MdsplusSliceWriteKnownDefectsDeath*:MdsplusTimebaseCache*:UdaSmokeRoundTrip*:UdaEquilibriumSeedParity*" + TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*: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 28615795..a567a607 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 b4282ffcec1a356f3b33e5aa032ffd37596d28de 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 32/91] 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. --- 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 a567a607..ffa4aacc 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 ef1e9735dbeaf6ed41d8afe605653b407e8907c5 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 33/91] 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. --- 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 446f4a0a..46fb7a63 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*: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*: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 ffa4aacc..8e7fcceb 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 623cf177069e0d398e2a953bcc0fec22371b27e9 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 34/91] 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. --- 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 8e7fcceb..3e042d19 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 c00552d7e63b60bddf2b43d33224ea71b1db0112 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 35/91] 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 #18). Added those notes plus a closing progress-summary paragraph on Part 5, mirroring Part 4's own issue-#18 closure paragraph. --- 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 3e042d19..82c687eb 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`, `RoundTripKnownDefects.Ascii{Double,Complex}MaxdimCurrentlyCorrupts` | **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 d73f13476d7e03ee136ce4b4a7de96ac14403f37 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 36/91] 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. --- .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 2612c1d2d71074bcde6dd95b9bc7c805fdb693ac 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 37/91] Characterize UDA pulse lifecycle modes --- 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 82c687eb..c564bef2 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 e4911ec7c841dd401b7538ab6a67c7ea86980a57 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 38/91] Pin the UDA-Plugins source commit --- .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 c564bef2..5841ec04 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 5165a137cc730641606d7055f12cb4ba1e1701c5 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 39/91] Pin exact UDA complex AOS defects --- 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 46fb7a63..3a686e41 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*: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*: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 5841ec04..9dfc4ccf 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 8d8a284261b3658fbc1dbab28f54edd4f435465c 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 40/91] Correct UDA traceability closure counts --- 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 9dfc4ccf..a2e0f4db 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 7f5e5f992377706d6703e8d9395d24091ab668bf Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:25:36 +0200 Subject: [PATCH 41/91] Update AGENTS.md --- AGENTS.md | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..dc04c24a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,115 @@ +# AGENTS.md - IMAS-Core + +Low-level IMAS Access Layer (AL core): a stable C ABI over a C++17 core with +pluggable storage backends and Cython/Python bindings. It reads/writes IMAS IDS +data addressed by Data Dictionary (DD) paths. **The active strategic effort is +the DD-version-agnostic migration — read `NORTH_STAR.md` before making +architectural changes.** A structural overview with diagrams lives in +`IMAS_CORE_STRUCTURE_MINDMAP.md`. + +## Agent skills + +### Issue tracker + +GitHub Issues on the `origin` fork `yohannmarguier/IMAS-Core` (via the `gh` CLI); external PRs are not a triage surface. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Canonical vocabulary, no overrides: `needs-triage` / `needs-info` / `ready-for-agent` / `ready-for-human` / `wontfix`, plus `bug` / `enhancement`. See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context: `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`. + +## Key architecture facts (audited 2026-07, develop @ 24bd5bc) + +- **DD paths are opaque strings through the whole C ABI** (`al_read_data` / + `al_write_data`, `include/al_lowlevel.h:487-489`). The caller (HLI) drives + tree traversal node-by-node and owns all DD semantics. The core has no + per-DD-version logic. +- **The compile-time DD version is deprecated on purpose**: + `include/al_defs.h.in:57` sets `DD_VERSION "!!DEPRECATED!!"` and + `python/tests/test_imasdef.py` asserts it stays that way. Do not reintroduce + compile-time DD coupling anywhere. +- **Backend coupling status:** HDF5, memory, ASCII, and flexbuffers are fully + DD-agnostic. Only two backends are DD-coupled: + - **MDSplus** — consumes a binary model tree baked from one `IDSDef.xml` at + build time (`models/mdsplus/`, `common/cmake/ALBuildDataDictionary.cmake`), + selected at runtime via `MDSPLUS_MODELS_PATH`; DD version stored in tree + node `VERSION:DATA_DICT` (`src/mdsplus/mdsplus_backend.cpp:5377-5389`). + - **UDA** — loads `IDSDef.xml` at runtime from `$IDSDEF_PATH` and walks it + for types/ranks/timebases (`src/uda/uda_xml.cpp:73-280`). +- The DD version of stored data lives only in the ordinary string field + `ids_properties/version_put/data_dictionary`, written by the HLI; no backend + except UDA interprets any DD version. +- **HDF5 layout:** master file + per-IDS files; one "tensorized" dataset per + leaf DD path (`/` → `&` in names), AOS flattened with a leading index + dimension plus `_SHAPE` companion datasets. HDF5 stores a backend-format + version (1.0), not the DD version. +- **Performance shape:** the per-node `Backend` contract is the bottleneck; no + general subtree/bulk read exists (`al_begin_global_action`'s `datapath` arg + is ignored by HDF5, MDSplus, Memory, ASCII, and Flexbuffers). **UDA is the + one living exception**: in remote mode with `cache_mode=ids`, `UDABackend` + reads `datapath` to scope which paths `populate_cache()` requests from the + server (`src/uda/uda_backend.cpp:1016-1031`, characterized in + `tests/contract/test_uda_unique_surface.cpp`, issue #26). Slice mode + disables HDF5 buffering + (`src/hdf5/hdf5_dataset_handler.cpp:733`). Every read/write calls + `getenv("IMAS_AL_ENABLE_PLUGINS")` (`src/al_lowlevel.cpp:108`). Python reads + are zero-copy; Python writes copy twice (`_al_lowlevel.pyx:615-631`). +- **No benchmarks exist in-repo**; C++ tests are frameworkless executables in + `tests/`, Python tests are pytest in `python/tests` (configured in + `pyproject.toml`). + +## DD version landscape (from the imas-dd tool suite) + +- 35 DD versions in circulation, 3.22.0 → 4.1.1. Within-major changes are + almost purely additive (4.0.0→4.1.1: zero breaking); the 3→4 major boundary + is the cliff (894 breaking changes, 81 IDSs, COCOS 11→17). +- Equilibrium example: 585 paths (DD3) → 641 (DD4); the 3→4 transition has 143 + rename chains (`j_tor→j_phi`, `bpol_probe→b_field_pol_probe`, + `psi_axis→psi_magnetic_axis`, …) **and 32 COCOS sign flips** — so + cross-version mapping requires value transforms, not just path renames. + Getting a sign flip wrong silently corrupts physics; strict mapping is the + default in the north star design. + +## Operating patterns for agents + +- **Use the `imas-dd` MCP tools** for anything DD-related; never guess paths + or version behaviour. Most useful: `get_dd_versions`, + `get_ids_summary(ids, dd_version=3|4)`, + `get_dd_migration_guide(from, to, ids_filter=..., summary_only=True)` (full + output can be ~300 k chars — start with `summary_only` and grep the saved + overflow file), `get_dd_version_context(change_type_filter='path_renamed', + follow_rename_chains=True)` for rename lineages, `check_dd_paths` to + validate paths. +- `src/uda/` line counts are misleading: it is mostly vendored pugixml. + Vendored code also in `src/flatbuffers/`. Effective own-code base is ~28 k + LOC. +- Build: CMake ≥3.21 (`cmake -B build && cmake --build build`); deps via vcpkg + or system (HDF5, Boost::filesystem; MDSplus/UDA backends optional, OFF by + default). Python wheel via scikit-build-core (`pip install .`); MDSplus + models build (`AL_BUILD_MDSPLUS_MODELS=ON`) is the only build path that + downloads the DD. +- Tests: `ctest` in the build dir for C++; `pytest` (rooted at `python/tests`) + for Python. +- Default branch and PR target is `develop`. + +## North star migration — where to contribute + +Phased plan, effort, and decided design points are in `NORTH_STAR.md` §9/§11. +Standing decisions to respect: keep the C ABI stable and additive; store data +as-written and convert on read; version knowledge ships as data artifacts +(schema pack + map artifacts), never compiled in; equilibrium is the pilot +IDS; TDD — every phase starts with its tests (characterization tests and the +benchmark harness are Phase 0 and gate everything else). + +## graphify + +This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. + +Rules: +- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. +- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). From 6a9b8e49dcd948a152fedf5246b51bbb88196e05 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 42/91] Skip UDA contracts when the server is unreachable --- 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 09bb450fd98d7f4ec443102cf562e21a44e8f6cd 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 43/91] Read every reported UDA occurrence --- 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 cf123d6b384e6f8766e67afd96467c1fd1bbba8a 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 44/91] Pin UDA characterization defects accurately --- 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 a2e0f4db..3cf8cd37 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 f6bc1811fce7e8b4a6eb24bbb3d274308ee8d00c 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 45/91] Centralize UDA test addressing and assert cleanup --- 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)); From b19bd27984988348a6dc1489fd400a5393ec1df6 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:59:59 +0200 Subject: [PATCH 46/91] Fix backend Note steps truncated by YAML comments and add workflow lint In a plain YAML scalar, ' #' starts a YAML comment even inside shell-looking quotes, so both backend Note steps shipped as an unterminated 'echo "...' and failed with 'unexpected EOF while looking for matching quote', turning the jobs red even when their test steps passed. Switch both Note steps to block scalars so the full command (including the issue references) reaches the shell. Add an actionlint workflow gated on .github/workflows/** changes; actionlint's shellcheck pass catches this truncation class before it lands. Closes #43 --- .github/workflows/mdsplus-contract.yml | 6 +++++- .github/workflows/uda-contract.yml | 6 +++++- .github/workflows/workflow-lint.yml | 27 ++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/workflow-lint.yml diff --git a/.github/workflows/mdsplus-contract.yml b/.github/workflows/mdsplus-contract.yml index df95fab1..60e5fef8 100644 --- a/.github/workflows/mdsplus-contract.yml +++ b/.github/workflows/mdsplus-contract.yml @@ -100,4 +100,8 @@ jobs: - name: Note if: always() - run: echo "MDSplus leg is informational (issue #12/#18). Red-by-design until the characterization baseline is triaged; promotion to a blocking gate is Task 3." + # Block scalar, not a plain scalar: in a plain scalar ` #` starts a YAML + # comment even inside shell-looking quotes, truncating the command into + # an unterminated string (issue #43). + run: | + echo "MDSplus leg is informational (issue #12/#18). Red-by-design until the characterization baseline is triaged; promotion to a blocking gate is Task 3." diff --git a/.github/workflows/uda-contract.yml b/.github/workflows/uda-contract.yml index adfbc9d2..a75b2b6a 100644 --- a/.github/workflows/uda-contract.yml +++ b/.github/workflows/uda-contract.yml @@ -83,4 +83,8 @@ jobs: - 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." + # Block scalar, not a plain scalar: in a plain scalar ` #` starts a YAML + # comment even inside shell-looking quotes, truncating the command into + # an unterminated string (issue #43). + run: | + echo "UDA leg is informational (issue #21/#29). Red-by-design only for un-pinned defects; TRACEABILITY.md Part 5 (issue #28) records the pinned baseline this leg should stay green against." diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml new file mode 100644 index 00000000..db546a13 --- /dev/null +++ b/.github/workflows/workflow-lint.yml @@ -0,0 +1,27 @@ +name: Workflow lint + +# Issue #43: a plain YAML scalar in a `run:` line silently turns ` #` into a +# YAML comment, truncating the shell command (both backend Note steps shipped +# that way). actionlint runs shellcheck over every run: script, so this class +# of truncation fails before push lands instead of turning jobs red at runtime. + +on: + push: + paths: + - ".github/workflows/**" + pull_request: + paths: + - ".github/workflows/**" + +jobs: + actionlint: + name: actionlint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Run actionlint + run: | + bash <(curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + ./actionlint -color From 54db010e357c76a3aae652a38e8e640e93f5fc45 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:00:46 +0200 Subject: [PATCH 47/91] Build the MDSplus DD model before running contract_tests contract_tests opens pulses against the baked model tree, but only depended on al; the mdsplus-contract workflow builds just --target contract_tests, so al-mdsplus-model (an independent ALL custom target) was never built and the whole MDSplus tier failed with %TREE-E-FOPENR while opening the pulse/model tree. Make the dependency part of the build graph: when AL_BACKEND_MDSPLUS and AL_BUILD_MDSPLUS_MODELS are both on, contract_tests depends on al-mdsplus-model (declared at the top level, after both targets exist, since tests/ is added before models/mdsplus/). Also verify the three ids_model.* files exist in the workflow before CTest, covering both the cache-hit and cache-miss paths. Closes #42 --- .github/workflows/mdsplus-contract.yml | 11 +++++++++++ CMakeLists.txt | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/.github/workflows/mdsplus-contract.yml b/.github/workflows/mdsplus-contract.yml index 60e5fef8..50f85c2f 100644 --- a/.github/workflows/mdsplus-contract.yml +++ b/.github/workflows/mdsplus-contract.yml @@ -89,6 +89,17 @@ jobs: cmake --build build-mdsplus --target contract_tests -j\$(nproc) " + - name: Verify baked DD model tree is present (cache-hit and cache-miss paths) + run: | + set -euo pipefail + for f in ids_model.tree ids_model.datafile ids_model.characteristics; do + if [ ! -s "build-mdsplus/models/mdsplus/${f}" ]; then + echo "FATAL: missing/empty model file build-mdsplus/models/mdsplus/${f} — the MDSplus tier would fail with %TREE-E-FOPENR (issue #42)" + exit 1 + fi + done + echo "Baked DD model tree complete under build-mdsplus/models/mdsplus." + - name: Run MDSplus contract suite run: > docker run --rm --platform linux/arm64 diff --git a/CMakeLists.txt b/CMakeLists.txt index be0d4da9..7735eaf4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -322,4 +322,14 @@ endif() if(AL_BUILD_MDSPLUS_MODELS) add_subdirectory(models/mdsplus) + + # The MDSplus contract tier opens pulses against the baked model tree, so + # `cmake --build . --target contract_tests` must build the model too — a + # workflow that builds only contract_tests otherwise runs against an absent + # model and fails with %TREE-E-FOPENR (issue #42). Wired here, after both + # targets exist: tests/ is added before models/mdsplus/, so the dependency + # cannot be declared from tests/contract/CMakeLists.txt. + if(AL_BACKEND_MDSPLUS AND TARGET contract_tests) + add_dependencies(contract_tests al-mdsplus-model) + endif() endif() From 297a946b70ae49f6a8c5a23a86134bb9c2c7ee0f Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:02:22 +0200 Subject: [PATCH 48/91] Fail the dedicated UDA tier when the reference server does not start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker/uda/run.sh retried the TCP listener but never checked whether the loop succeeded, printed 'server listening' unconditionally, and downgraded a failed uda_cli ping to a warning. Combined with the contract suite's server-unreachable GTEST_SKIP(), a total bootstrap failure could skip every UDA case while CTest exits 0 — a green job that tested nothing. - Track listener success and exit nonzero after the timeout. - Make the independent uda_cli help::help() ping fatal. - Add a dedicated-CI mode (AL_UDA_DEDICATED_CI=1, set by uda-contract.yml): run CTest with --output-junit and fail unless a nonzero number of UDA tests actually executed (guards both zero selected and all skipped). - Developer builds keep the compile-gate/runtime-skip behavior: without the flag nothing changes. Closes #41 --- .github/workflows/uda-contract.yml | 6 +++++ docker/uda/run.sh | 41 +++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/.github/workflows/uda-contract.yml b/.github/workflows/uda-contract.yml index a75b2b6a..3f0942bf 100644 --- a/.github/workflows/uda-contract.yml +++ b/.github/workflows/uda-contract.yml @@ -75,10 +75,16 @@ jobs: grep -q "\*\*${dd_version}\*\*" "${header}" echo "Image stack identifiers match ${header}'s Part 5 pinned-reference-stack table." + # AL_UDA_DEDICATED_CI=1: inside the dedicated reference-stack job a + # server bootstrap failure must fail the job, not skip every test — + # uda-run.sh makes the listener wait and the uda_cli ping fatal, and + # fails unless a nonzero number of UDA tests actually executed + # (issue #41). Developer builds without the flag keep skip semantics. - name: Run the UDA contract tier run: > docker run --rm --platform linux/arm64 -v "$PWD":/workspace -w /workspace + -e AL_UDA_DEDICATED_CI=1 "${IMAGE_TAG}" uda-run.sh - name: Note diff --git a/docker/uda/run.sh b/docker/uda/run.sh index baba1493..c9f1fc47 100644 --- a/docker/uda/run.sh +++ b/docker/uda/run.sh @@ -89,20 +89,32 @@ fi 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. +# Wait for the listener before running the client; a server that never comes +# up is a bootstrap failure, not something to paper over — the contract tier +# would GTEST_SKIP() every case and CTest would exit 0 having tested nothing +# (issue #41). +listening=0 for _ in $(seq 1 30); do if bash -c "exec 3<>/dev/tcp/localhost/56565" 2>/dev/null; then exec 3>&- 2>/dev/null || true + listening=1 break fi sleep 0.5 done +if [ "${listening}" != 1 ]; then + echo "FATAL: UDA server never started listening on localhost:56565 (xinetd pid ${XINETD_PID})" + exit 1 +fi echo "UDA server listening on localhost:56565 (xinetd pid ${XINETD_PID})" # Sanity ping through UDA's own CLI (independent of IMAS-Core) so a server-side -# failure is distinguishable from a client-side one. +# failure is distinguishable from a client-side one. Fatal: a stack whose own +# CLI cannot talk to the server must not proceed to a CTest run whose UDA cases +# would all skip-and-pass (issue #41). UDA_HOST=localhost UDA_PORT=56565 "${PREFIX}/bin/uda_cli" \ --host localhost --port 56565 --request "help::help()" >/dev/null 2>&1 \ - && echo "uda_cli help::help() OK" || echo "WARN: uda_cli ping failed (continuing)" + || { echo "FATAL: uda_cli help::help() ping failed — server unusable"; exit 1; } +echo "uda_cli help::help() OK" echo "==> [4/4] Running the UDA contract tier" export UDA_HOST=localhost UDA_PORT=56565 @@ -110,4 +122,25 @@ if [ "$#" -gt 0 ]; then exec "$@" fi cd "${BUILD_DIR}/tests/contract" -ctest -L uda --output-on-failure + +# Dedicated-CI mode (AL_UDA_DEDICATED_CI=1, set by uda-contract.yml): inside +# the reference-stack job, "server unreachable -> skip" is not a valid green. +# Parse CTest's machine-readable JUnit output and fail unless a nonzero number +# of UDA tests actually executed (issue #41). Ordinary developer builds keep +# the compile-gate/runtime-skip behavior: without the flag nothing changes. +if [ "${AL_UDA_DEDICATED_CI:-0}" = "1" ]; then + results="${BUILD_DIR}/uda-ctest-results.xml" + ctest -L uda --output-on-failure --output-junit "${results}" + total=$(sed -n 's/.*]*[[:space:]]tests="\([0-9]*\)".*/\1/p' "${results}" | head -1) + skipped=$(sed -n 's/.*]*[[:space:]]skipped="\([0-9]*\)".*/\1/p' "${results}" | head -1) + total=${total:-0} + skipped=${skipped:-0} + executed=$((total - skipped)) + echo "Dedicated-CI check: ${total} UDA tests selected, ${skipped} skipped, ${executed} executed." + if [ "${executed}" -le 0 ]; then + echo "FATAL: dedicated UDA tier executed zero tests (all skipped or none selected) — the reference stack verified nothing (issue #41)." + exit 1 + fi +else + ctest -L uda --output-on-failure +fi From 7ba5c541138279355a110ee7737b91c05973766e Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:12:47 +0200 Subject: [PATCH 49/91] Prevent cold-matrix vcpkg download stampedes on Windows wheels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five Windows wheel jobs previously restored the same mutable win-deps cache key and then independently ran vcpkg against the same upstream source hosts; a cold matrix made five concurrent fetches of the same archives, and gitlab.dkrz.de rate-limited libaec with HTTP 429, failing the whole blocking wheel workflow. The five always-save steps also raced on the same immutable cache key. Restructure per issue #44's preferred design: - New windows_deps job prepares the pinned x64-windows dependency closure once, caching both C:\vcpkg\installed and the content-addressed C:\vcpkg\downloads under a key derived from the vcpkg manifest, the install script, the triplet, the UDA ref, and the runner's vcpkg baseline commit. It is the only job that ever saves the cache. - Windows wheel builds move to a dedicated build_wheels_windows job (needs: windows_deps) restoring that cache read-only with fail-on-cache-miss, so Linux/macOS legs are not delayed and no matrix job writes a cache key. - ci/wheels/cibw_before_build_win.sh reuses the UDA source archive from the cached downloads directory and skips the UDA build when the restored tree already provides uda-cpp.pc, so warm wheel jobs make no upstream fetches at all. - UDA_REF is pinned explicitly to 2.8.1 for Windows — the script's default, which Windows builds have always used (matrix.UDA_REF was never exported to the Windows build environment). - Both publish jobs now also depend on build_wheels_windows. Closes #44 --- .github/workflows/wheels.yml | 263 +++++++++++++++-------------- ci/wheels/cibw_before_build_win.sh | 21 ++- 2 files changed, 155 insertions(+), 129 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index c64d447c..fd09699d 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -198,154 +198,32 @@ jobs: AL_BACKEND_UDA: AL_BACKEND_UDA=ON UDA_REF: "2.9.3" - - os: windows-2022 - triplet: x64-windows - python: 310 - platform_id: win_amd64 - cibw_platform: windows - cibw_archs: AMD64 - manylinux_image: windows - AL_BACKEND_HDF5: AL_BACKEND_HDF5=ON - AL_BACKEND_MDSPLUS: AL_BACKEND_MDSPLUS=OFF - AL_BACKEND_UDA: AL_BACKEND_UDA=OFF - UDA_REF: "2.9.3" - FMT_REF: "11.1.4" - - - os: windows-2022 - triplet: x64-windows - python: 311 - platform_id: win_amd64 - cibw_platform: windows - cibw_archs: AMD64 - manylinux_image: windows - AL_BACKEND_HDF5: AL_BACKEND_HDF5=ON - AL_BACKEND_MDSPLUS: AL_BACKEND_MDSPLUS=OFF - AL_BACKEND_UDA: AL_BACKEND_UDA=OFF - UDA_REF: "2.9.3" - FMT_REF: "11.1.4" - - - os: windows-2022 - triplet: x64-windows - python: 312 - platform_id: win_amd64 - cibw_platform: windows - cibw_archs: AMD64 - manylinux_image: windows - AL_BACKEND_HDF5: AL_BACKEND_HDF5=ON - AL_BACKEND_MDSPLUS: AL_BACKEND_MDSPLUS=OFF - AL_BACKEND_UDA: AL_BACKEND_UDA=OFF - UDA_REF: "2.9.3" - FMT_REF: "11.1.4" - - - os: windows-2022 - triplet: x64-windows - python: 313 - platform_id: win_amd64 - cibw_platform: windows - cibw_archs: AMD64 - manylinux_image: windows - AL_BACKEND_HDF5: AL_BACKEND_HDF5=ON - AL_BACKEND_MDSPLUS: AL_BACKEND_MDSPLUS=OFF - AL_BACKEND_UDA: AL_BACKEND_UDA=OFF - UDA_REF: "2.9.3" - FMT_REF: "11.1.4" - - - os: windows-2022 - triplet: x64-windows - python: 314 - platform_id: win_amd64 - cibw_platform: windows - cibw_archs: AMD64 - manylinux_image: windows - AL_BACKEND_HDF5: AL_BACKEND_HDF5=ON - AL_BACKEND_MDSPLUS: AL_BACKEND_MDSPLUS=OFF - AL_BACKEND_UDA: AL_BACKEND_UDA=OFF - UDA_REF: "2.9.3" - FMT_REF: "11.1.4" + # Windows wheels are built by the dedicated build_wheels_windows job + # below: they share a vcpkg dependency tree prepared once by the + # windows_deps job (issue #44), and keeping them in this matrix would + # force `needs: windows_deps` onto the Linux/macOS legs too. steps: - uses: actions/checkout@v7 with: fetch-depth: 0 # unshallow for setuptools-scm.get_version to discover tags - # Recent changes in GitHub Actions Cache's API have rendered the x-gha binary caching provider useless. - # See https://github.com/microsoft/vcpkg-tool/pull/1662 - # - uses: actions/github-script@v7 - # if: startsWith(matrix.os, 'windows') - # # requirement for vcpkg binary caching https://learn.microsoft.com/en-us/vcpkg/consume/binary-caching-github-actions-cache - # with: - # script: | - # core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); - # core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '') - - uses: actions/setup-python@v6 with: # python installation used when to start cibuildwheel python-version: "3.12" cache: 'pip' - - name: Install cibuildwheel for windows - if: startsWith(matrix.os, 'windows') - run: | - python -m pip install cibuildwheel==3.1.0 - - name: Restore cibuildwheel cache id: cache uses: actions/cache@v6 with: path: | - ~\AppData\Local\pypa\cibuildwheel\Cache ~/Library/Caches/cibuildwheel ~/.cache/cibuildwheel # cf. CIBW_CACHE_PATH key: "cibuildwheel-${{ runner.os }}-${{ runner.arch }}-cp${{ matrix.python }}-${{ matrix.platform_id }}" - - name: Cache restore windows deps - if: startsWith(matrix.os, 'windows') - id: cache-win-deps - uses: actions/cache/restore@v6 - env: - cache-name: win-deps - with: - path: C:\vcpkg\installed - key: ${{ matrix.os }}-${{ env.cache-name }}-b # this key is never hit (-a is). - restore-keys: | - ${{ matrix.os }}-${{ env.cache-name }}-a - ${{ matrix.os }}-${{ env.cache-name }}- - - - name: Build wheels for windows - if: startsWith(matrix.os, 'windows') - run: | - python -c "import sys, pprint, cibuildwheel; pprint.pp(sys.path); pprint.pp(cibuildwheel)" - python -m cibuildwheel --output-dir wheelhouse --config-file pyproject.toml - env: - CIBW_BUILD: cp${{ matrix.python }}-${{ matrix.platform_id }} - CIBW_PLATFORM: ${{ matrix.cibw_platform }} - CIBW_ARCHS: ${{ matrix.cibw_archs }} - CIBW_BUILD_VERBOSITY: 1 - CIBW_SKIP: "cp31?t-*" # Skip free-threading builds (not supported yet) - CIBW_CONFIG_SETTINGS: > - cmake.define.${{ matrix.AL_BACKEND_HDF5 }} - cmake.define.${{ matrix.AL_BACKEND_MDSPLUS }} - cmake.define.${{ matrix.AL_BACKEND_UDA }} - # cmake.args="--debug-find;--debug-output" - - - name: Cache store windows deps - # always(): also if previous step failed - # and also if steps.cache-win-deps.outputs.cache-hit == 'true' - # It is difficult to predict when the cache will be there (gha erases in time) and/or up to date (changes in the build), - # making it difficult to manually bump the version of the cache. So, instead, we will override the cache always, even if - # it was completely up to date and it sped up the Build wheels for windows job (above). The performance hit of storing - # the cache redundantly doesn't compare to the time it takes to rebuild vcpkg packages. - if: ${{ always() && startsWith(matrix.os, 'windows') }} - id: cache-win-deps-save - uses: actions/cache/save@v6 - env: - cache-name: win-deps - with: - path: C:\vcpkg\installed - key: ${{ matrix.os }}-${{ env.cache-name }}-a - - uses: pypa/cibuildwheel@v4.1.0 if: startsWith(matrix.os, 'ubuntu-') env: @@ -450,10 +328,139 @@ jobs: path: wheelhouse/*.whl retention-days: 1 + # Issue #44: prepare the pinned x64-windows vcpkg dependency closure ONCE, + # then let every Windows wheel job restore it read-only. Previously all five + # cold matrix jobs independently ran vcpkg against the same upstream source + # hosts and the same mutable cache key: a rate-limited host (HTTP 429 from + # gitlab.dkrz.de for libaec) failed the whole blocking workflow, and the five + # always-save steps raced on one key. Now a cold workflow downloads each + # source archive at most once (both C:\vcpkg\installed and the + # content-addressed C:\vcpkg\downloads are cached), and only this job ever + # saves the cache. + windows_deps: + name: Prepare Windows vcpkg dependencies + runs-on: windows-2022 + env: + # The before-build script's default (matrix.UDA_REF was never exported to + # the Windows build environment, so Windows has always built this + # version); pinned explicitly here because it is part of the cache key. + UDA_REF: "2.8.1" + VCPKG_ROOT: C:/vcpkg + VCPKG_TARGET_TRIPLET: x64-windows + outputs: + cache-key: ${{ steps.key.outputs.key }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + + - name: Compute content-addressed cache key + id: key + # Key covers the manifest (vcpkg.json), the dependency-install script, + # the triplet, the UDA ref, and the runner's vcpkg baseline commit — + # any of these changing invalidates the prepared tree. restore-keys + # allow warm partial reuse; the exact key is what wheel jobs consume. + run: | + vcpkg_commit=$(git -C /c/vcpkg rev-parse HEAD 2>/dev/null || echo unknown) + echo "key=win-deps-${VCPKG_TARGET_TRIPLET}-uda${UDA_REF}-${{ hashFiles('vcpkg.json', 'ci/wheels/cibw_before_build_win.sh') }}-vcpkg-${vcpkg_commit}" >> "$GITHUB_OUTPUT" + + - name: Cache vcpkg installed tree and downloads + id: cache + uses: actions/cache@v6 + with: + path: | + C:\vcpkg\installed + C:\vcpkg\downloads + key: ${{ steps.key.outputs.key }} + restore-keys: | + win-deps-${{ env.VCPKG_TARGET_TRIPLET }}-uda${{ env.UDA_REF }}- + + - name: Install the pinned dependency closure (cold cache only) + if: steps.cache.outputs.cache-hit != 'true' + # The same script every wheel job runs as CIBW before-build; running it + # here keeps a single source of truth for the closure. It is idempotent + # against a partially restored tree: vcpkg skips installed packages and + # the UDA build is skipped when uda-cpp.pc is already installed. + run: bash ./ci/wheels/cibw_before_build_win.sh + + build_wheels_windows: + name: build_wheels (Python ${{ matrix.python }}, win_amd64) + runs-on: windows-2022 + needs: [windows_deps] + strategy: + # Ensure that a wheel builder finishes even if another fails + fail-fast: false + matrix: + python: [310, 311, 312, 313, 314] + + # Recent changes in GitHub Actions Cache's API have rendered the x-gha + # binary caching provider useless. + # See https://github.com/microsoft/vcpkg-tool/pull/1662 — vcpkg reuse is + # instead provided by the windows_deps job's prepared-tree cache above. + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # unshallow for setuptools-scm.get_version to discover tags + + - uses: actions/setup-python@v6 + with: + # python installation used when to start cibuildwheel + python-version: "3.12" + cache: 'pip' + + - name: Install cibuildwheel for windows + run: | + python -m pip install cibuildwheel==3.1.0 + + - name: Restore cibuildwheel cache + id: cache + uses: actions/cache@v6 + with: + path: | + ~\AppData\Local\pypa\cibuildwheel\Cache + # cf. CIBW_CACHE_PATH + key: "cibuildwheel-${{ runner.os }}-${{ runner.arch }}-cp${{ matrix.python }}-win_amd64" + + - name: Restore prepared windows deps (read-only) + uses: actions/cache/restore@v6 + with: + path: | + C:\vcpkg\installed + C:\vcpkg\downloads + key: ${{ needs.windows_deps.outputs.cache-key }} + # The key was saved (or verified) by windows_deps in this same run; a + # miss means the prepared tree is unavailable and this job would fall + # back to a cold upstream fetch — the stampede issue #44 removes. + fail-on-cache-miss: true + + - name: Build wheels for windows + run: | + python -c "import sys, pprint, cibuildwheel; pprint.pp(sys.path); pprint.pp(cibuildwheel)" + python -m cibuildwheel --output-dir wheelhouse --config-file pyproject.toml + env: + UDA_REF: "2.8.1" # keep in sync with windows_deps.env.UDA_REF + CIBW_BUILD: cp${{ matrix.python }}-win_amd64 + CIBW_PLATFORM: windows + CIBW_ARCHS: AMD64 + CIBW_BUILD_VERBOSITY: 1 + CIBW_SKIP: "cp31?t-*" # Skip free-threading builds (not supported yet) + CIBW_CONFIG_SETTINGS: > + cmake.define.AL_BACKEND_HDF5=ON + cmake.define.AL_BACKEND_MDSPLUS=OFF + cmake.define.AL_BACKEND_UDA=OFF + # cmake.args="--debug-find;--debug-output" + + - uses: actions/upload-artifact@v7 + with: + name: cibw-wheels-cp${{ matrix.python }}-win_amd64 + path: wheelhouse/*.whl + retention-days: 1 + publish_wheels_testpypi: # only publish to TestPyPI on develop pushes and not from forks if: github.ref=='refs/heads/develop' && github.repository_owner == 'iterorganization' - needs: [build_wheels] + needs: [build_wheels, build_wheels_windows] runs-on: ubuntu-24.04 permissions: # IMPORTANT: this permission is mandatory for Trusted Publishing @@ -480,7 +487,7 @@ jobs: publish_wheels_pypi: # only publish to PyPI on tag pushes and not from forks if: startsWith(github.ref, 'refs/tags/') && github.repository_owner == 'iterorganization' - needs: [build_wheels] + needs: [build_wheels, build_wheels_windows] runs-on: ubuntu-24.04 permissions: # IMPORTANT: this permission is mandatory for Trusted Publishing diff --git a/ci/wheels/cibw_before_build_win.sh b/ci/wheels/cibw_before_build_win.sh index 595523fc..5c6821dd 100644 --- a/ci/wheels/cibw_before_build_win.sh +++ b/ci/wheels/cibw_before_build_win.sh @@ -69,8 +69,26 @@ vcpkg install --classic ${UDA_DEPS[@]} # N.B. all the deps in vcpkgs.json will be installed (if missing) via vcpkg's cmake toolchain file +# Skip the UDA fetch+build entirely when a restored dependency cache already +# provides the installed client (uda-cpp.pc is the last artifact the install +# produces); the windows_deps CI job's cache key includes UDA_REF and this +# script's hash, so a version bump still rebuilds from a cold cache (issue #44). +if test -f "${USEFUL_CMAKE_INSTALL_PREFIX}/lib/pkgconfig/uda-cpp.pc" ;then + echo "UDA already installed under ${USEFUL_CMAKE_INSTALL_PREFIX} (restored cache) - skipping UDA build" +else +# Reuse the UDA source archive from vcpkg's downloads directory when present: +# the windows_deps CI job caches C:\vcpkg\downloads, so each wheel job restores +# the archive instead of re-fetching it from GitHub (issue #44). A fresh +# download is copied back there so the cache saved by that job includes it. +UDA_TARBALL_CACHE="${VCPKG_ROOT}/downloads/UDA-${UDA_REF}.tar.gz" if ! test -f ${UDA_REF}.tar.gz ;then -curl -LO https://github.com/ukaea/UDA/archive/refs/tags/${UDA_REF}.tar.gz + if test -f "${UDA_TARBALL_CACHE}" ;then + cp "${UDA_TARBALL_CACHE}" ${UDA_REF}.tar.gz + else + curl -LO https://github.com/ukaea/UDA/archive/refs/tags/${UDA_REF}.tar.gz + mkdir -p "${VCPKG_ROOT}/downloads" + cp ${UDA_REF}.tar.gz "${UDA_TARBALL_CACHE}" + fi fi if ! test -d UDA-${UDA_REF} ;then tar zxf ${UDA_REF}.tar.gz @@ -96,5 +114,6 @@ if test -d UDA-${UDA_REF} ;then cmake --install build --config Release ) fi +fi # delvewheel is the equivalent of delocate/auditwheel for windows. python -m pip install delvewheel wheel \ No newline at end of file From 72747281f346c48f22d9eb2b17b9c4f90733fa76 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:19:22 +0200 Subject: [PATCH 50/91] Replace iterator argvalues before pytest 10 itertools.product returns a one-shot iterator; passing it directly as parametrize argvalues is deprecated (PytestRemovedIn10Warning) and becomes a collection error in pytest 10. Materialize it with list(). Make PytestRemovedIn10Warning an error in [tool.pytest.ini_options] filterwarnings so every pytest run (local and CI) fails on removed-in-10 usage before an unbounded pytest upgrade does. Verified: python -m pytest python/tests -W error::pytest.PytestRemovedIn10Warning passes (15 passed, 5 skipped), all six test_ALException cases still collect, and the old iterator pattern now fails collection under the new filter. Closes #48 --- pyproject.toml | 5 +++++ python/tests/test_exception.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 68165362..622f5ebc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,6 +116,11 @@ local_scheme = "no-local-version" [tool.pytest.ini_options] testpaths = ["python/tests"] +filterwarnings = [ + # pytest-10 deprecations are errors so removed-in-10 usage (e.g. iterator + # argvalues, issue #48) fails here before an unbounded pytest upgrade does + "error::pytest.PytestRemovedIn10Warning", +] [tool.coverage.run] branch = true diff --git a/python/tests/test_exception.py b/python/tests/test_exception.py index 9b86433d..b3c0a400 100644 --- a/python/tests/test_exception.py +++ b/python/tests/test_exception.py @@ -7,7 +7,8 @@ @pytest.mark.parametrize( "message, status", - itertools.product(["an exception", "another exception"], [None, -3, 44]), + # materialized: pytest 10 rejects non-Collection iterables as argvalues + list(itertools.product(["an exception", "another exception"], [None, -3, 44])), ) def test_ALException(message, status): match = message From c6c4d38316f982e863b464a47c20251e7220e69f Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:47:15 +0200 Subject: [PATCH 51/91] Restore unit/integration CTest labels on supported CMake versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-pass TEST_FILTER partition was broken at both ends of the supported CMake range: gtest_discover_tests' PROPERTIES forwarding collapses a multi-element LABELS list to its first token on CMake 3.28 (so ctest -L unit selected nothing), TEST_FILTER does not exist on the declared 3.21 floor (which fell back to a single all-'contract' pass), and even where multi-label forwarding worked (4.3.2) the unit and integration selections overlapped by two tests (ReadbackPlugins.BindReadbackPluginsAutoRegistersAndBindsFromStoredMetadata, UriOwnership.CallerFreesBuiltUri). Replace the four discovery passes with ONE pass (no TEST_FILTER, single-element LABELS — both fine on 3.21) plus post-discovery label assignment: assign_contract_labels.cmake, appended to the directory TEST_INCLUDE_FILES property, runs after the discovery include has registered every test, parses the generated registration file (handling the version-dependent add_test name quoting: bracket, quoted, bare) and classifies each test into exactly one of unit / integration / mdsplus / uda with a plain set_tests_properties list. The partition is disjoint by construction. Add tests/contract/check_label_partition.sh — fails on zero-selection, overlap, or a non-hermetic unit tier — and run it in the blocking functional workflow before the test run, since an empty 'ctest -L unit' exits 0 and is otherwise invisible. Verified on CMake 3.21.4 (Linux container), 3.28.4, and 4.3.2 (macOS): identical selections everywhere — 94 unit, 173 integration, 267 total; unit and integration disjoint; the two previously double-labeled tests now carry only 'unit'; unit tier and full suite pass (87/87, 247/247 executed). Closes #31 --- .github/workflows/contract-tests.yml | 5 + tests/contract/CMakeLists.txt | 100 ++++++------------ .../contract/assign_contract_labels.cmake.in | 82 ++++++++++++++ tests/contract/check_label_partition.sh | 63 +++++++++++ 4 files changed, 182 insertions(+), 68 deletions(-) create mode 100644 tests/contract/assign_contract_labels.cmake.in create mode 100755 tests/contract/check_label_partition.sh diff --git a/.github/workflows/contract-tests.yml b/.github/workflows/contract-tests.yml index e1c6de59..8b858679 100644 --- a/.github/workflows/contract-tests.yml +++ b/.github/workflows/contract-tests.yml @@ -39,6 +39,11 @@ jobs: - name: Build run: cmake --build build + # Issue #31: an empty `ctest -L unit` exits 0, so a broken label + # partition is invisible to the test run below — check it explicitly. + - name: Verify unit/integration label partition + run: bash tests/contract/check_label_partition.sh build/tests/contract + - name: Run the whole test suite working-directory: build run: ctest --output-on-failure diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 3a686e41..b5efb969 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -198,74 +198,38 @@ endif() # the Memory backend — no on-disk backend), and the URI # builder ownership check (UriOwnership.*, pure string). # integration = everything else (HDF5 / ASCII / Flexbuffers on disk). -# Two disjoint discovery passes over the one binary; the TEST_FILTERs partition -# the cases so each registers exactly once. TEST_FILTER needs CMake >= 3.22; on -# the declared 3.21 floor we fall back to the single "contract" label. - -# The escaped ";" keeps LABELS a two-element list through gtest_discover_tests' -# re-emission (an unescaped ";" flattens to two malformed args). The integration -# TEST_FILTER uses gtest's one-"-"-then-colon-separated negative syntax -# ("*:-a:b:c", NOT "*:-a:-b:-c") so the two passes stay disjoint — otherwise the -# second pass re-registers the unit cases and CTest sees duplicates. -# MdsplusTracerBullet* is carved out of the integration pass into its own -# third pass (issue #13: new CTest label "mdsplus") so `ctest -L mdsplus` -# selects it without also pulling in every other on-disk backend case. -# Issue #14 joins MDSplus onto four more fixtures (DataEntryModes, -# CapabilityMatrix, AosMatrix, Occurrences) as a "Mdsplus"-named parameter/ -# case; "*/Mdsplus" and "Occurrences.Mdsplus*" carve those into the same -# mdsplus pass so `ctest -L mdsplus` covers the whole parity set, not just -# the tracer bullet. Issue #15 adds the real-DD-path datatype x rank breadth -# matrix (test_mdsplus_real_paths.cpp); "Mdsplus/MdsplusRealPathMatrix.*" -# carves its instances into the same mdsplus pass. Issue #16 adds the -# version-drift check (test_mdsplus_version_drift.cpp); "MdsplusVersionDrift*" -# carves its two cases into the same mdsplus pass. Issue #17 adds the -# MDSplus-unique surface (test_mdsplus_unique_surface.cpp): -# "MdsplusStructAwareSliceInterpolation*", "MdsplusTimerangeResampling*", -# "MdsplusSegmentBackedWrites*", "MdsplusSliceWriteKnownDefects*", -# "MdsplusSliceWriteKnownDefectsDeath*", and "MdsplusTimebaseCache*" carve its -# cases into the same mdsplus pass. -# -# Empirically discovered running this against a real build (issue #13, -# CMake 3.28 + Ninja): gtest_discover_tests' PROPERTIES forwarding does NOT -# actually preserve a multi-element LABELS list through its escaped-";" -# encoding -- it collapses to just the first token ("contract"), silently -# dropping "unit"/"integration" as bogus trailing property names with no -# value. That pre-existing defect (present in the two passes below -# unmodified by this issue) means `ctest -L unit`/`-L integration` currently -# select nothing; it is out of this issue's scope to fix. The new mdsplus -# pass sidesteps it by using a single-element LABELS list, which has no -# separator to lose. -if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") - gtest_discover_tests(contract_tests - TEST_FILTER "Introspection.*:*Memory*:KnownDefects*:Plugin*:ReadbackPlugins.*:UriOwnership.*" - PROPERTIES LABELS "contract\;unit") - gtest_discover_tests(contract_tests - TEST_FILTER "*:-Introspection.*:*Memory*:KnownDefects*:Plugin*:MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*:MdsplusStructAwareSliceInterpolation*:MdsplusTimerangeResampling*:MdsplusSegmentBackedWrites*:MdsplusSliceWriteKnownDefects*:MdsplusSliceWriteKnownDefectsDeath*:MdsplusTimebaseCache*:UdaSmokeRoundTrip*:UdaEquilibriumSeedParity*:Uda/UdaRealPathMatrix.*:Uda/UdaRealPathMatrixKnownDefects.*:UdaBreadthTest*:UdaAosKnownDefects*:UdaSliceAndTimeRange*:UdaUniqueSurfaceTest*" - PROPERTIES LABELS "contract\;integration") - gtest_discover_tests(contract_tests - TEST_FILTER "MdsplusTracerBullet*:*/Mdsplus:Occurrences.Mdsplus*:Mdsplus/MdsplusRealPathMatrix.*:MdsplusVersionDrift*:MdsplusStructAwareSliceInterpolation*:MdsplusTimerangeResampling*:MdsplusSegmentBackedWrites*:MdsplusSliceWriteKnownDefects*:MdsplusSliceWriteKnownDefectsDeath*:MdsplusTimebaseCache*" - PROPERTIES LABELS "mdsplus") - # Issue #23: the UDA tier gets its own single-element "uda" label pass (same - # sidestep of the multi-label-drop defect the mdsplus pass uses), carved out - # of the integration pass above, so `ctest -L uda` selects exactly the UDA - # characterization suite without pulling in every other on-disk backend case. - # Issue #24 joins the read-only equilibrium-seed parity fixture - # (UdaEquilibriumSeedParity*) to the same pass. Issue #25 (parity breadth) - # joins the real-DD-path datatype x rank matrix (test_uda_real_paths.cpp, - # Uda/UdaRealPathMatrix.*) and the AOS/occurrences/list_filled_paths/pulse- - # modes/errors/slice/timerange breadth suite (test_uda_breadth.cpp, - # UdaBreadthTest.*, UdaSliceAndTimeRange.*). The exact COMPLEX r1/r3/r5 - # known-defect pairs live in Uda/UdaRealPathMatrixKnownDefects.*. - # Issue #26 (unique-surface, first half) joins UdaUniqueSurfaceTest* to the - # same pass: cache modes, runtime DD loading, datapath, and capability - # negotiation (test_uda_unique_surface.cpp). - gtest_discover_tests(contract_tests - TEST_FILTER "UdaSmokeRoundTrip*:UdaEquilibriumSeedParity*:Uda/UdaRealPathMatrix.*:Uda/UdaRealPathMatrixKnownDefects.*:UdaBreadthTest*:UdaAosKnownDefects*:UdaSliceAndTimeRange*:UdaUniqueSurfaceTest*" - PROPERTIES LABELS "uda") -else() - gtest_discover_tests(contract_tests - PROPERTIES LABELS "contract") -endif() +# One discovery pass over the one binary; assign_contract_labels.cmake then +# classifies every discovered test into exactly one tier (disjoint by +# construction — see the note at the registration block below). + +# Issue #31: labels are assigned POST-discovery, not through +# gtest_discover_tests. Two version-dependent defects ruled the old multi-pass +# TEST_FILTER partition out across the supported CMake range: +# - gtest_discover_tests' PROPERTIES forwarding does not preserve a +# multi-element LABELS list on every supported version (empirically +# CMake 3.28 collapses "contract\;unit" to just "contract", silently +# dropping the rest), so `ctest -L unit` selected nothing there; and +# - TEST_FILTER needs CMake >= 3.22, so the declared 3.21 floor fell back +# to a single all-"contract" pass with no unit/integration split at all. +# Instead there is ONE discovery pass (no TEST_FILTER, single-element LABELS — +# both fine on 3.21), and assign_contract_labels.cmake runs afterwards via the +# directory TEST_INCLUDE_FILES property: ctest processes it after the +# discovery include has registered every test, and it re-labels each test by +# name with a plain set_tests_properties list — a mechanism with no +# version-dependent forwarding in the middle. The tier definitions (which +# suites are unit / integration / mdsplus / uda, per issues #13-#17 and +# #23-#26) are documented and maintained in assign_contract_labels.cmake.in. +gtest_discover_tests(contract_tests + PROPERTIES LABELS "contract") + +# Bake the generated registration file's path into the labeler. A single +# discovery pass for contract_tests means GoogleTest writes +# "contract_tests[1]_tests.cmake". +set(_AL_CONTRACT_DISCOVERED_TESTS_FILE + "${CMAKE_CURRENT_BINARY_DIR}/contract_tests[1]_tests.cmake") +configure_file(assign_contract_labels.cmake.in assign_contract_labels.cmake @ONLY) +set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES + "${CMAKE_CURRENT_BINARY_DIR}/assign_contract_labels.cmake") ######################################################################################################################## # Install (mirrors the smoke-test targets). diff --git a/tests/contract/assign_contract_labels.cmake.in b/tests/contract/assign_contract_labels.cmake.in new file mode 100644 index 00000000..555aba38 --- /dev/null +++ b/tests/contract/assign_contract_labels.cmake.in @@ -0,0 +1,82 @@ +# assign_contract_labels.cmake — post-discovery CTest label assignment +# (issue #31). +# +# Processed by ctest via the directory TEST_INCLUDE_FILES property, AFTER the +# gtest_discover_tests include has registered every test. It re-reads the +# generated registration file, classifies each test name, and assigns LABELS +# with a plain set_tests_properties call — the one mechanism that carries a +# multi-element label list identically across the supported CMake range. +# gtest_discover_tests' own PROPERTIES forwarding collapses a multi-element +# LABELS list to its first token on some CMake versions (empirically 3.28), +# and TEST_FILTER (the old multi-pass partition) does not exist on the +# declared 3.21 floor at all; parsing + relabeling depends on neither. +# +# The classification below mirrors the tier definitions documented in +# tests/contract/CMakeLists.txt: +# unit — pure lookups, in-RAM Memory backend instances, plugin ABI +# death tests, plugin lifecycle suites, URI ownership. +# mdsplus — the compile-gated MDSplus tier (single "mdsplus" label). +# uda — the compile-gated UDA tier (single "uda" label). +# integration — everything else (HDF5 / ASCII / Flexbuffers on disk). +# The if/elseif chain makes the partition disjoint by construction: exactly +# one branch fires per test. +# +# This file parses exactly the single discovery pass's output; if another +# gtest_discover_tests call is ever added for contract_tests, add its +# generated "_tests.cmake" file here. + +set(_al_contract_tests_file "@_AL_CONTRACT_DISCOVERED_TESTS_FILE@") + +if(EXISTS "${_al_contract_tests_file}") + file(STRINGS "${_al_contract_tests_file}" _al_add_test_lines REGEX "^add_test") + foreach(_al_line IN LISTS _al_add_test_lines) + # The generated add_test name quoting is version-dependent: bracket + # arguments ("add_test([=[Name]=] ...", CMake >= ~3.22), double quotes for + # names with special characters on older versions, bare words otherwise + # ("add_test( Name ...", 3.21). + set(_al_name "") + if(_al_line MATCHES "^add_test\\( *\\[=+\\[([^]]+)\\]") + set(_al_name "${CMAKE_MATCH_1}") + elseif(_al_line MATCHES "^add_test\\( *\"([^\"]+)\"") + set(_al_name "${CMAKE_MATCH_1}") + elseif(_al_line MATCHES "^add_test\\( *([^ ]+) ") + set(_al_name "${CMAKE_MATCH_1}") + endif() + if(NOT _al_name STREQUAL "") + if(_al_name MATCHES "^Introspection\\." OR + _al_name MATCHES "Memory" OR + _al_name MATCHES "^KnownDefects" OR + _al_name MATCHES "^Plugin" OR + _al_name MATCHES "^ReadbackPlugins\\." OR + _al_name MATCHES "^UriOwnership\\.") + set_tests_properties("${_al_name}" PROPERTIES LABELS "contract;unit") + elseif(_al_name MATCHES "^MdsplusTracerBullet" OR + # "/Mdsplus" as the whole instance suffix: pretty names end + # "/Mdsplus" on newer CMake but "/Mdsplus#GetParam()=..." on 3.21. + _al_name MATCHES "/Mdsplus(#|$)" OR + _al_name MATCHES "^Occurrences\\.Mdsplus" OR + _al_name MATCHES "^Mdsplus/MdsplusRealPathMatrix\\." OR + _al_name MATCHES "^MdsplusVersionDrift" OR + _al_name MATCHES "^MdsplusStructAwareSliceInterpolation" OR + _al_name MATCHES "^MdsplusTimerangeResampling" OR + _al_name MATCHES "^MdsplusSegmentBackedWrites" OR + _al_name MATCHES "^MdsplusSliceWriteKnownDefects" OR + _al_name MATCHES "^MdsplusTimebaseCache") + set_tests_properties("${_al_name}" PROPERTIES LABELS "mdsplus") + elseif(_al_name MATCHES "^UdaSmokeRoundTrip" OR + _al_name MATCHES "^UdaEquilibriumSeedParity" OR + _al_name MATCHES "^Uda/UdaRealPathMatrix\\." OR + _al_name MATCHES "^Uda/UdaRealPathMatrixKnownDefects\\." OR + _al_name MATCHES "^UdaBreadthTest" OR + _al_name MATCHES "^UdaAosKnownDefects" OR + _al_name MATCHES "^UdaSliceAndTimeRange" OR + _al_name MATCHES "^UdaUniqueSurfaceTest") + set_tests_properties("${_al_name}" PROPERTIES LABELS "uda") + else() + set_tests_properties("${_al_name}" PROPERTIES LABELS "contract;integration") + endif() + endif() + endforeach() + unset(_al_add_test_lines) +endif() +unset(_al_contract_tests_file) diff --git a/tests/contract/check_label_partition.sh b/tests/contract/check_label_partition.sh new file mode 100755 index 00000000..d1fe3bb9 --- /dev/null +++ b/tests/contract/check_label_partition.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# check_label_partition.sh — CI smoke check for the contract-suite CTest +# labels (issue #31). +# +# An empty `ctest -L unit` exits 0, so a label regression (version-dependent +# property forwarding, filter drift, a renamed suite) is silent unless +# something checks the selection itself. This verifies, machine-readably: +# 1. `ctest -N -L unit` and `ctest -N -L integration` both select a nonzero +# number of tests; +# 2. the two selections are disjoint (no test carries both labels); +# 3. the unit selection is hermetic — no filesystem-backed backend case. +# +# Usage: check_label_partition.sh +# e.g. check_label_partition.sh build/tests/contract +set -euo pipefail + +build_dir=${1:?usage: check_label_partition.sh } + +list_names() { + # `ctest -N` right-aligns test numbers ("Test #9:" vs "Test #99:"), so the + # whitespace before '#' is variable; strip the "(Disabled)" suffix too. + ctest --test-dir "${build_dir}" -N -L "$1" \ + | sed -n 's/^ *Test *#[0-9]*: //p' | sed 's/ (Disabled)$//' +} + +unit=$(list_names '^unit$') +integration=$(list_names '^integration$') +unit_count=$(printf '%s' "${unit}" | grep -c . || true) +integration_count=$(printf '%s' "${integration}" | grep -c . || true) +echo "unit: ${unit_count} tests, integration: ${integration_count} tests" + +fail=0 +if [ "${unit_count}" -eq 0 ]; then + echo "FAIL: 'ctest -L unit' selects zero tests — the fast hermetic tier is empty (issue #31)." + fail=1 +fi +if [ "${integration_count}" -eq 0 ]; then + echo "FAIL: 'ctest -L integration' selects zero tests — the on-disk tier is empty (issue #31)." + fail=1 +fi + +overlap=$(comm -12 <(printf '%s\n' "${unit}" | sort -u) \ + <(printf '%s\n' "${integration}" | sort -u) | grep . || true) +if [ -n "${overlap}" ]; then + echo "FAIL: tests carry both 'unit' and 'integration' labels:" + printf '%s\n' "${overlap}" + fail=1 +fi + +# -i: gtest pretty names spell backends differently per CMake version +# ("(HDF5, ..." on new CMake, "#GetParam()=ASCII" on 3.21, "Hdf5..." in +# suite names). +nonhermetic=$(printf '%s\n' "${unit}" | grep -iE 'hdf5|ascii|flexbuffers|mdsplus|uda' || true) +if [ -n "${nonhermetic}" ]; then + echo "FAIL: the unit selection contains filesystem-/server-backed cases:" + printf '%s\n' "${nonhermetic}" + fail=1 +fi + +if [ "${fail}" -ne 0 ]; then + exit 1 +fi +echo "OK: unit/integration labels are nonempty, disjoint, and the unit tier is hermetic." From 21c5570c9a794a1d66fd46749acee24ceb5d020c Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:56:43 +0200 Subject: [PATCH 52/91] Cover unknown backend IDs at the C-ABI factory boundary TRACEABILITY Part 2 marked initBackend's unrecognized-ID throw path covered only 'by inspection'. Add test_backend_factory.cpp (BackendFactory.*, hermetic unit tier) pinning the refusal through the public surface: - an unknown backend name in an otherwise valid data-entry URI returns UNKNOWN_ERR from al_begin_dataentry_action across all four open modes, with the stable 'Unable to identify a backend' diagnostic, no context handed out (sentinel untouched) and no filesystem artifact; - an empty backend name is likewise refused; - an unknown positive numeric ID via al_build_uri_from_legacy_parameters returns LOWLEVEL_ERR and synthesizes no URI. A regression that defaults an unrecognized identifier to a real backend makes these calls succeed and the tests fail. Verified clean under ASan/UBSan (macOS) and ASan+LSan (Linux container, detect_leaks=1). TRACEABILITY Part 2 row A now points at the active tests instead of the inspection-only residual. Closes #47 --- tests/contract/CMakeLists.txt | 1 + tests/contract/TRACEABILITY.md | 2 +- .../contract/assign_contract_labels.cmake.in | 5 +- tests/contract/test_backend_factory.cpp | 104 ++++++++++++++++++ 4 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 tests/contract/test_backend_factory.cpp diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index b5efb969..4b50277e 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -68,6 +68,7 @@ add_executable(contract_tests test_capability_gated.cpp test_known_defects.cpp test_plugins.cpp + test_backend_factory.cpp test_structured_data.cpp test_pulse_lifecycle.cpp test_mdsplus.cpp diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 3cf8cd37..622fe355 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -218,7 +218,7 @@ and points at the same test. | Cluster / Capability | Inventory ref | Unit | Integ. | Test(s) — and residual | Status | |---|---|:--:|:--:|---|---| -| **A** `initBackend` factory selects the backend from the URI | :496-514 | ✓ (Memory) | ✓ (HDF5, ASCII) | every always-on backend instantiated via `build_uri` in `RoundTripMatrix`. Residual: the unrecognized-ID throw path (`al_backend.cpp:82-86`) is straightforward by inspection (a plain `else` throwing `ALBackendException`) but untested — low-value relative to the rest of this row, left as a residual rather than a separate row. MDSplus is instantiated too, via its own URI in every Part 4 fixture — see Part 4 | covered (indirect) | +| **A** `initBackend` factory selects the backend from the URI | :496-514 | ✓ (Memory) | ✓ (HDF5, ASCII) | every always-on backend instantiated via `build_uri` in `RoundTripMatrix`. The unrecognized-backend refusal has an active verdict (issue #47): `BackendFactory.*` (test_backend_factory.cpp) pins the exact C-ABI status for an unknown backend name in a URI (`UNKNOWN_ERR`, all four open modes, no context handed out, no filesystem artifact) and for an unknown positive legacy numeric ID via `al_build_uri_from_legacy_parameters` (`LOWLEVEL_ERR`, no URI synthesized). MDSplus is instantiated too, via its own URI in every Part 4 fixture — see Part 4 | covered | | **B** `getVersion` (installed vs stored, drift check) | :520-540 | — | — | 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) | diff --git a/tests/contract/assign_contract_labels.cmake.in b/tests/contract/assign_contract_labels.cmake.in index 555aba38..560b885f 100644 --- a/tests/contract/assign_contract_labels.cmake.in +++ b/tests/contract/assign_contract_labels.cmake.in @@ -48,7 +48,10 @@ if(EXISTS "${_al_contract_tests_file}") _al_name MATCHES "^KnownDefects" OR _al_name MATCHES "^Plugin" OR _al_name MATCHES "^ReadbackPlugins\\." OR - _al_name MATCHES "^UriOwnership\\.") + _al_name MATCHES "^UriOwnership\\." OR + # issue #47: unknown-backend refusals at the factory boundary — no + # backend substrate is ever constructed, so the tier is hermetic + _al_name MATCHES "^BackendFactory\\.") set_tests_properties("${_al_name}" PROPERTIES LABELS "contract;unit") elseif(_al_name MATCHES "^MdsplusTracerBullet" OR # "/Mdsplus" as the whole instance suffix: pretty names end diff --git a/tests/contract/test_backend_factory.cpp b/tests/contract/test_backend_factory.cpp new file mode 100644 index 00000000..5be97b25 --- /dev/null +++ b/tests/contract/test_backend_factory.cpp @@ -0,0 +1,104 @@ +// Unknown-backend-ID coverage at the C-ABI factory boundary (issue #47). +// +// TRACEABILITY.md Part 2 marked Backend::initBackend's unrecognized-ID throw +// path (src/al_backend.cpp) covered only "by inspection". These tests give the +// refusal a real verdict through the public surface, at the two spots an +// unknown backend identifier can arrive from a caller: +// +// - al_begin_dataentry_action with a URI naming an unknown (or empty) +// backend: DataEntryContext::setBackendID (src/al_context.cpp) is the +// factory-boundary gate that refuses it before initBackend runs. A +// regression that "defaults" an unrecognized name to a real backend makes +// the calls below succeed and the tests fail. +// - al_build_uri_from_legacy_parameters with an unknown positive numeric +// backend ID: DataEntryContext::getURIBackend refuses to synthesize a URI. +// +// Everything asserted here is hermetic (no backend substrate is ever +// constructed), so the suite name is classified into the `unit` tier by +// assign_contract_labels.cmake.in. + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include + +namespace { + +constexpr int kUntouchedCtx = -424242; // sentinel: must survive failed calls + +bool dir_is_empty(const std::string& p) { + namespace fs = std::filesystem; + return fs::exists(p) && fs::is_directory(p) && + fs::begin(fs::directory_iterator(p)) == fs::end(fs::directory_iterator(p)); +} + +// =========================================================================== +// URI surface: unknown / empty backend names. +// =========================================================================== + +TEST(BackendFactory, UnknownBackendNameInUriReturnsCleanError) { + al_contract::TempBase tmp; + int ctx = kUntouchedCtx; + const std::string uri = "imas:nosuchbackend?path=" + tmp.str() + "/pulse"; + + al_status_t s = al_begin_dataentry_action(uri.c_str(), + alconst::force_create_pulse, &ctx); + + EXPECT_EQ(s.code, alerror::unknown_err) << "message: " << s.message; + EXPECT_NE(std::string(s.message).find("Unable to identify a backend"), + std::string::npos) + << "actual message: " << s.message; + // No context may be handed out and no artifact left on disk. + EXPECT_EQ(ctx, kUntouchedCtx); + EXPECT_TRUE(dir_is_empty(tmp.str())); +} + +TEST(BackendFactory, EmptyBackendNameInUriReturnsCleanError) { + al_contract::TempBase tmp; + int ctx = kUntouchedCtx; + const std::string uri = "imas:?path=" + tmp.str() + "/pulse"; + + al_status_t s = al_begin_dataentry_action(uri.c_str(), + alconst::force_create_pulse, &ctx); + + EXPECT_NE(s.code, 0); + EXPECT_EQ(ctx, kUntouchedCtx); + EXPECT_TRUE(dir_is_empty(tmp.str())); +} + +TEST(BackendFactory, UnknownBackendNameIsRefusedForEveryOpenMode) { + al_contract::TempBase tmp; + const int modes[] = {alconst::open_pulse, alconst::force_open_pulse, + alconst::create_pulse, alconst::force_create_pulse}; + const std::string uri = "imas:nosuchbackend?path=" + tmp.str() + "/pulse"; + for (int mode : modes) { + int ctx = kUntouchedCtx; + al_status_t s = al_begin_dataentry_action(uri.c_str(), mode, &ctx); + EXPECT_EQ(s.code, alerror::unknown_err) << "mode=" << mode; + EXPECT_EQ(ctx, kUntouchedCtx) << "mode=" << mode; + } + EXPECT_TRUE(dir_is_empty(tmp.str())); +} + +// =========================================================================== +// Legacy surface: unknown positive numeric backend ID. +// =========================================================================== + +TEST(BackendFactory, UnknownPositiveLegacyBackendIdIsRefused) { + std::string uri; + al_status_t s = al_build_uri_from_legacy_parameters( + /*backendID=*/424242, /*pulse=*/1, /*run=*/1, "user", "test", "3", + /*options=*/"", uri); + + EXPECT_EQ(s.code, alerror::lowlevel_err) << "message: " << s.message; + EXPECT_NE(std::string(s.message).find("getURIBackend"), std::string::npos) + << "actual message: " << s.message; + EXPECT_TRUE(uri.empty()) << "no URI may be synthesized, got: " << uri; +} + +} // namespace From 240330d2bae7550870ca7618f85bd1676649880c Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:03:11 +0200 Subject: [PATCH 53/91] Make the dlopen-failure pin safe in assert-enabled builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PluginTest.RegisterUnloadableSharedLibSwallowsAssert_CurrentBehavior called al_register_plugin in-process; in a Debug build the product assertion in registerPlugin fires on the failed dlopen and aborted the whole test runner (exit 134) instead of producing a GoogleTest verdict. Split the current-behavior pin by build mode (issue #32): - assert-enabled builds: a death test (RegisterUnloadableSharedLibAbortsOnAssert_CurrentBehavior) pins SIGABRT + the assertion text in a child process. The fixture directory is created by the child but removed by the PARENT; since threadsafe-style death tests re-execute the whole test in the child, the parent's path is handed down via an env var (overwrite=0 so the re-executed child keeps the inherited value) — verified no temp directory is left behind on macOS and Linux. - NDEBUG builds: the existing swallowed-assert tripwire is kept unchanged under #ifdef NDEBUG. - The DISABLED_ correct-contract test (nonzero status attributing the actual dlopen error) stays build-mode independent; fixing registerPlugin makes the active pin of that mode fail and the correct-contract test ready to enable. Run both build modes in CI: the blocking functional workflow now builds a {RelWithDebInfo, Debug} matrix, so each branch of the pin executes on every PR. Verified: full contract suite passes 251/251 in Debug and Release on macOS, and in Debug on Linux (container); the previously-crashing gtest_filter repro now returns [PASSED] in Debug. Closes #32 --- .github/workflows/contract-tests.yml | 12 ++++- tests/contract/test_plugins.cpp | 73 +++++++++++++++++++++++----- 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/.github/workflows/contract-tests.yml b/.github/workflows/contract-tests.yml index 8b858679..1787dfdd 100644 --- a/.github/workflows/contract-tests.yml +++ b/.github/workflows/contract-tests.yml @@ -15,8 +15,16 @@ on: jobs: contract-functional: - name: Contract tests (Linux, blocking) + name: Contract tests (Linux, ${{ matrix.build_type }}, blocking) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Both build modes (issue #32): the dlopen-failure plugin pin is + # build-mode dependent — assert-abort death test in Debug, swallowed + # assert tripwire under NDEBUG (RelWithDebInfo) — so each branch needs + # its own leg or one of them never runs anywhere. + build_type: [RelWithDebInfo, Debug] steps: - name: Checkout code @@ -34,7 +42,7 @@ jobs: cmake -S . -B build -G Ninja -DAL_BUILD_TESTS=ON -DAL_PYTHON_BINDINGS=OFF - -DCMAKE_BUILD_TYPE=RelWithDebInfo + -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} - name: Build run: cmake --build build diff --git a/tests/contract/test_plugins.cpp b/tests/contract/test_plugins.cpp index 205f9108..4d32795c 100644 --- a/tests/contract/test_plugins.cpp +++ b/tests/contract/test_plugins.cpp @@ -30,6 +30,7 @@ #include +#include #include #include #include @@ -351,15 +352,22 @@ TEST_F(PluginTest, UnregisterNeverBoundPluginLeavesItRegistered_CurrentBehavior) // =========================================================================== // DEFECT (D2): dlopen failure in registerPlugin is guarded only by an assert. // FUNCTIONALITY_INVENTORY.md:355-360, src/al_lowlevel.cpp:350-357: a failed -// dlopen() is printf'd and asserted; under -DNDEBUG (this build: -// RelWithDebInfo/Release) the assert is stripped, so the null handle flows on. +// dlopen() is printf'd and asserted, so current behavior is BUILD-MODE +// DEPENDENT and the pins below are too (issue #32): // -// Empirically characterized on this platform: the null handle reaches dlsym, -// whose own null-check throws — so registerPlugin returns LOWLEVEL_ERR with the -// *misleading* "Cannot load symbol create" message rather than reporting the -// dlopen failure, and does NOT crash. (In an assert-enabled build the stripped -// assert would instead fire -> SIGABRT; that death only exists there, which is -// why this pair is a plain xfail, not a death test.) +// - assert-enabled builds (Debug, no NDEBUG): the assert fires -> SIGABRT. +// Pinned with a death test so the abort happens in a child process and +// the suite itself survives. +// - NDEBUG builds (Release/RelWithDebInfo): the assert is stripped and the +// null handle flows on to dlsym, whose own null-check throws — so +// registerPlugin returns LOWLEVEL_ERR with the *misleading* "Cannot load +// symbol create" message rather than reporting the dlopen failure, and +// does NOT crash. +// +// Either way the correct contract is the same (a nonzero status attributing +// the actual dlopen error) — the DISABLED_ test below stays build-mode +// independent and becomes enableable once registerPlugin handles the failed +// dlopen instead of asserting on it. // =========================================================================== // Points IMAS_AL_PLUGINS at a temp dir holding a garbage _plugin.so that @@ -403,11 +411,12 @@ TEST_F(PluginTest, DISABLED_RegisterUnloadableSharedLibReportsDlopenFailure) { fs::remove_all(dir); } -// CURRENT-BEHAVIOR tripwire: pins that the returned status does NOT mention the -// dlopen failure — the NDEBUG-stripped assert lets the null handle flow past -// dlopen, so the failure is swallowed and only a downstream symptom is reported. -// If the status starts naming dlopen, the handling was fixed — enable the -// DISABLED_ test above. +#ifdef NDEBUG +// CURRENT-BEHAVIOR tripwire (NDEBUG builds): pins that the returned status does +// NOT mention the dlopen failure — the NDEBUG-stripped assert lets the null +// handle flow past dlopen, so the failure is swallowed and only a downstream +// symptom is reported. If the status starts naming dlopen, the handling was +// fixed — enable the DISABLED_ test above. TEST_F(PluginTest, RegisterUnloadableSharedLibSwallowsAssert_CurrentBehavior) { namespace fs = std::filesystem; const fs::path dir = fs::temp_directory_path() / @@ -423,6 +432,44 @@ TEST_F(PluginTest, RegisterUnloadableSharedLibSwallowsAssert_CurrentBehavior) { "PluginTest.RegisterUnloadableSharedLibReportsDlopenFailure."; fs::remove_all(dir); } +#else +// CURRENT-BEHAVIOR tripwire (assert-enabled builds): the product assertion at +// src/al_lowlevel.cpp fires on the failed dlopen and aborts. Run the call in a +// death-test child so the runner survives; pin SIGABRT + the assertion text +// specifically, so an unrelated crash is not mistaken for this defect. The +// fixture artifacts are created and removed by the PARENT — the child dying +// must not leak the temp dir (issue #32). +TEST_F(PluginTest, RegisterUnloadableSharedLibAbortsOnAssert_CurrentBehavior) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + namespace fs = std::filesystem; + const fs::path dir = fs::temp_directory_path() / + ("al_contract_badplugin_" + + std::to_string(::getpid()) + "_b"); + // threadsafe-style death tests RE-EXECUTE the whole test in the child, + // which would recompute `dir` with the child's own pid and leave that + // directory behind; hand the parent's path down via the environment so + // parent and child agree on the one directory the parent removes below. + // overwrite=0: the re-executed child runs this line too and must NOT + // replace the inherited parent value with its own pid-derived path. + setenv("AL_CONTRACT_BADPLUGIN_DIR", dir.string().c_str(), /*overwrite=*/0); + const std::filesystem::path used_dir = + std::getenv("AL_CONTRACT_BADPLUGIN_DIR"); + ASSERT_EXIT( + { + const char* d = std::getenv("AL_CONTRACT_BADPLUGIN_DIR"); + register_unloadable(d != nullptr ? fs::path(d) : dir, + "unloadable_b"); + }, + ::testing::KilledBySignal(SIGABRT), "[Aa]ssert") + << "expected the registerPlugin dlopen assert to abort in an " + "assert-enabled build (src/al_lowlevel.cpp, " + "FUNCTIONALITY_INVENTORY.md:355-360). If this no longer dies, the " + "handling was fixed — enable " + "PluginTest.RegisterUnloadableSharedLibReportsDlopenFailure."; + unsetenv("AL_CONTRACT_BADPLUGIN_DIR"); + fs::remove_all(used_dir); +} +#endif // =========================================================================== // al_plugin_* low-level reentry (FUNCTIONALITY_INVENTORY.md:854-895, Part 3 From 29e51361b66bca51f27eed093fbd3595cbfd3bcf Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:07:12 +0200 Subject: [PATCH 54/91] Back off and retry the Windows dependency-prep install on upstream 429s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First run of the windows_deps job (issue #44) confirmed the fix shape — exactly one job fetched upstream and the five wheel jobs skipped instead of failing five ways — but gitlab.dkrz.de rate-limited even the single libaec fetch, and vcpkg's own retries are seconds apart. Retry the closure install up to 5 times with growing (90s..450s) backoff; the script is idempotent, so each attempt resumes where the last stopped. Also quote $GITHUB_OUTPUT in docs.yml — the pre-existing shellcheck SC2086 that made the first actionlint run (issue #43) red. --- .github/workflows/docs.yml | 2 +- .github/workflows/wheels.yml | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d1621e0b..9e1396f7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -52,7 +52,7 @@ jobs: else VERSION=dev fi - echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - name: Build documentation env: diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index fd09699d..f3860ff0 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -381,8 +381,21 @@ jobs: # The same script every wheel job runs as CIBW before-build; running it # here keeps a single source of truth for the closure. It is idempotent # against a partially restored tree: vcpkg skips installed packages and - # the UDA build is skipped when uda-cpp.pc is already installed. - run: bash ./ci/wheels/cibw_before_build_win.sh + # the UDA build is skipped when uda-cpp.pc is already installed — which + # also makes the retry loop below resume where the last attempt left + # off. The backoff exists because vcpkg's own retries are seconds + # apart: a rate-limiting upstream (HTTP 429 from gitlab.dkrz.de for + # libaec, issue #44) needs minutes for its window to reset. + run: | + for attempt in 1 2 3 4 5; do + if bash ./ci/wheels/cibw_before_build_win.sh; then + exit 0 + fi + echo "Dependency install attempt ${attempt} failed (upstream rate limit?); backing off $((attempt * 90))s" + sleep $((attempt * 90)) + done + echo "FATAL: dependency closure install failed after 5 attempts" + exit 1 build_wheels_windows: name: build_wheels (Python ${{ matrix.python }}, win_amd64) From 8322cfe645e279ae891295ad8af2e8d97bdb969e Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:10:02 +0200 Subject: [PATCH 55/91] Parse CTest's real JUnit shape in the dedicated UDA check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first dedicated-CI run proved the zero-executed guard works end to end — and that it caught its own parsing defect: CTest writes the attributes one per line, so the single-line sed extraction saw tests=0/skipped=0 and (correctly) failed the tier even though all 71 UDA tests had passed. Flatten the file and extract tests=/skipped= from the suite header only. Verified against a real 'ctest --output-junit' file (98 tests, 1 skipped -> 97 executed). Part of issue #41. --- docker/uda/run.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docker/uda/run.sh b/docker/uda/run.sh index c9f1fc47..0953e259 100644 --- a/docker/uda/run.sh +++ b/docker/uda/run.sh @@ -131,8 +131,12 @@ cd "${BUILD_DIR}/tests/contract" if [ "${AL_UDA_DEDICATED_CI:-0}" = "1" ]; then results="${BUILD_DIR}/uda-ctest-results.xml" ctest -L uda --output-on-failure --output-junit "${results}" - total=$(sed -n 's/.*]*[[:space:]]tests="\([0-9]*\)".*/\1/p' "${results}" | head -1) - skipped=$(sed -n 's/.*]*[[:space:]]skipped="\([0-9]*\)".*/\1/p' "${results}" | head -1) + # CTest writes the attributes one per line — flatten the file + # before extracting them, and take only the suite header (the + # elements carry no tests=/skipped= attributes). + suite_header=$(tr '\n\t' ' ' < "${results}" | grep -o ']*>' | head -1) + total=$(printf '%s' "${suite_header}" | grep -o 'tests="[0-9]*"' | head -1 | grep -o '[0-9]*') + skipped=$(printf '%s' "${suite_header}" | grep -o 'skipped="[0-9]*"' | head -1 | grep -o '[0-9]*') total=${total:-0} skipped=${skipped:-0} executed=$((total - skipped)) From 5dbc56636a55d1f5cd25ee4d94b72d91b27bfc75 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:24:00 +0200 Subject: [PATCH 56/91] Add a stored-backend-version drift fixture for HDF5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRACEABILITY Part 2 row B declined to test the always-on HDF5 stored-version drift check because forcing a mismatch needs out-of-band HDF5 attribute surgery. Add that infrastructure per issue #36's producer/observer split: - al-hdf5-fixture-tool: a small standalone target (the only place the HDF5 C API appears) that rewrites master.h5's HDF5_BACKEND_VERSION attribute; contract_tests only learns its path. - Hdf5VersionDrift.* (integration tier, default HDF5-enabled suite, so it runs on every PR): matching stored version opens; stored 999.0 (major) and 1.999 (the minor comparison boundary) are refused with an exact status/message assertion; contract_tests stays public-C-ABI-only. Empirical characterization the pins record: for HDF5 the generic LOWLEVEL_ERR drift comparison in al_lowlevel.cpp is SHADOWED — HDF5BackendFactory accepts exactly "1.0" and openPulse throws BACKEND_ERR ('No backend writer with version: ') for any other stored value before the generic check runs, so every non-compiled stored version is refused (stricter than the generic rule). Part 2 row B goes from 'gap' to 'covered' and documents the shadowing. Verified locally: 3/3 new tests pass; full suite 254/254; label partition intact (98 unit / 176 integration). Closes #36 --- tests/contract/CMakeLists.txt | 48 +++++++-- tests/contract/TRACEABILITY.md | 2 +- tests/contract/hdf5_fixture_tool.cpp | 71 +++++++++++++ tests/contract/test_hdf5_version_drift.cpp | 118 +++++++++++++++++++++ 4 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 tests/contract/hdf5_fixture_tool.cpp create mode 100644 tests/contract/test_hdf5_version_drift.cpp diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 4b50277e..58db1915 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -69,6 +69,7 @@ add_executable(contract_tests test_known_defects.cpp test_plugins.cpp test_backend_factory.cpp + test_hdf5_version_drift.cpp test_structured_data.cpp test_pulse_lifecycle.cpp test_mdsplus.cpp @@ -85,6 +86,27 @@ target_link_libraries(contract_tests PRIVATE al GTest::gtest GTest::gtest_main) add_dependencies(contract_tests al) target_compile_features(contract_tests PRIVATE cxx_std_17) +######################################################################################################################## +# HDF5 drift-fixture producer (issue #36). +# +# The stored-backend-version drift check is asserted through the public C ABI +# (test_hdf5_version_drift.cpp), but preparing a MISMATCHED pulse requires +# rewriting master.h5's HDF5_BACKEND_VERSION attribute — out-of-band HDF5 C +# API surgery that must NOT leak into contract_tests. It lives in a separate +# tool the tests invoke as a subprocess; contract_tests only learns the tool's +# path (and gates the test file's compilation on it). +if(AL_BACKEND_HDF5) + find_package(HDF5 COMPONENTS C REQUIRED) + add_executable(al-hdf5-fixture-tool hdf5_fixture_tool.cpp) + target_include_directories(al-hdf5-fixture-tool PRIVATE ${HDF5_C_INCLUDE_DIRS}) + target_link_libraries(al-hdf5-fixture-tool PRIVATE ${HDF5_C_LIBRARIES}) + target_compile_definitions(al-hdf5-fixture-tool PRIVATE ${HDF5_C_DEFINITIONS}) + target_compile_features(al-hdf5-fixture-tool PRIVATE cxx_std_17) + add_dependencies(contract_tests al-hdf5-fixture-tool) + target_compile_definitions(contract_tests PRIVATE + AL_CONTRACT_HDF5_FIXTURE_TOOL="$") +endif() + ######################################################################################################################## # MDSplus build gate (issue #13 / TEST_STRATEGY.md D4 "compile-guarded tier"). # @@ -97,17 +119,23 @@ target_compile_features(contract_tests PRIVATE cxx_std_17) if(AL_BACKEND_MDSPLUS) target_compile_definitions(contract_tests PRIVATE AL_CONTRACT_HAVE_MDSPLUS) - # test_mdsplus_version_drift.cpp (issue #16) forces the version-mismatch - # case by writing directly into the pulse's VERSION:BACK_MAJOR tree node - # through the raw MDSplus C++ API () -- something reachable - # only outside the public C ABI. `al`'s own src/mdsplus/CMakeLists.txt - # already links MDSplus, but PRIVATEly, so it doesn't propagate here; - # find_package(MDSplus) is repeated (idempotent) to wire the same - # include dir / libs into contract_tests, test-only and build-gated behind - # the same AL_BACKEND_MDSPLUS option as the backend itself. + # MDSplus drift-fixture producer (issue #35). test_mdsplus_version_drift.cpp + # (issue #16) forces the version-mismatch case by overwriting the pulse's + # VERSION:BACK_MAJOR tree node — reachable only through the raw MDSplus C++ + # API (), i.e. outside the public C ABI that is the contract + # suite's one seam. That mutation is fixture PREPARATION, so it lives in a + # separate build-gated tool the test invokes as a subprocess; contract_tests + # itself has no MDSplus include or link dependency, and a rewrite satisfying + # the C ABI is judged by exactly the C ABI. find_package(MDSplus) is + # repeated (idempotent) for the tool only. find_package(MDSplus REQUIRED) - target_include_directories(contract_tests PRIVATE ${MDSPLUS_INCLUDES}) - target_link_libraries(contract_tests PRIVATE ${MDSPLUS_LIBRARIES}) + add_executable(al-mdsplus-fixture-tool mdsplus_fixture_tool.cpp) + target_include_directories(al-mdsplus-fixture-tool PRIVATE ${MDSPLUS_INCLUDES}) + target_link_libraries(al-mdsplus-fixture-tool PRIVATE ${MDSPLUS_LIBRARIES}) + target_compile_features(al-mdsplus-fixture-tool PRIVATE cxx_std_17) + add_dependencies(contract_tests al-mdsplus-fixture-tool) + target_compile_definitions(contract_tests PRIVATE + AL_CONTRACT_MDSPLUS_FIXTURE_TOOL="$") endif() ######################################################################################################################## diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 622fe355..e3aaa8a0 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -219,7 +219,7 @@ and points at the same test. | Cluster / Capability | Inventory ref | Unit | Integ. | Test(s) — and residual | Status | |---|---|:--:|:--:|---|---| | **A** `initBackend` factory selects the backend from the URI | :496-514 | ✓ (Memory) | ✓ (HDF5, ASCII) | every always-on backend instantiated via `build_uri` in `RoundTripMatrix`. The unrecognized-backend refusal has an active verdict (issue #47): `BackendFactory.*` (test_backend_factory.cpp) pins the exact C-ABI status for an unknown backend name in a URI (`UNKNOWN_ERR`, all four open modes, no context handed out, no filesystem artifact) and for an unknown positive legacy numeric ID via `al_build_uri_from_legacy_parameters` (`LOWLEVEL_ERR`, no URI synthesized). MDSplus is instantiated too, via its own URI in every Part 4 fixture — see Part 4 | covered | -| **B** `getVersion` (installed vs stored, drift check) | :520-540 | — | — | 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** `getVersion` (installed vs stored, drift check) | :520-540 | — | ✓ (HDF5) | Tested (issue #36): `Hdf5VersionDrift.*` (test_hdf5_version_drift.cpp) — matching stored version opens; stored `999.0` (major) and `1.999` (minor boundary) are refused with the exact observed status. The mismatched fixture is produced out-of-band by the isolated `hdf5_fixture_tool` (rewrites master.h5's `HDF5_BACKEND_VERSION`); `contract_tests` itself stays HDF5-API-free and asserts only through the C ABI. Empirical nuance the pins record: for HDF5 the generic `LOWLEVEL_ERR` comparison (src/al_lowlevel.cpp) is **shadowed** — `HDF5BackendFactory` accepts exactly "1.0" and `openPulse` throws `BACKEND_ERR` ("No backend writer with version: …") for any other stored value, before the generic check runs; every non-compiled stored version is thereby refused (stricter than the generic rule). **MDSplus: also closed** — see Part 4's version-drift check (issue #16, reworked by #35 to keep the raw-MDSplus mutation in a fixture tool) | covered | | **B** `openPulse` / `closePulse` | :542-552 | ✓ (Memory) | ✓ (HDF5, ASCII) | via `al_begin_dataentry_action` / `al_close_pulse` in all suites | covered (indirect) | | **C** `beginAction` / `endAction` | :558-574 | ✓ (Memory) | ✓ (HDF5, ASCII) | via `al_begin_global_action` / `al_end_action` | covered (indirect) | | **C** `writeData` / `readData` (0=not-found vs 1=success inner convention) | :576-589 | ✓ (Memory) | ✓ (HDF5, ASCII) | via `al_write_data` / `al_read_data` — `RoundTripMatrix` (+ the CHAR-scalar / ASCII-maxdim xfails above). MDSplus: see Part 4's real-DD-path breadth matrix (issue #15) instead of `RoundTripMatrix`. UDA: same — see Part 5's real-DD-path breadth matrix instead of `RoundTripMatrix` | covered | diff --git a/tests/contract/hdf5_fixture_tool.cpp b/tests/contract/hdf5_fixture_tool.cpp new file mode 100644 index 00000000..ede6cd67 --- /dev/null +++ b/tests/contract/hdf5_fixture_tool.cpp @@ -0,0 +1,71 @@ +// hdf5_fixture_tool — out-of-band stored-backend-version mutation for the +// HDF5 drift fixtures (issues #36 and #39). +// +// The contract suite asserts version-drift behavior exclusively through the +// public C ABI; preparing a MISMATCHED pulse, however, requires rewriting the +// HDF5_BACKEND_VERSION attribute the backend wrote into the master file — an +// operation no public API offers (by design). This standalone tool owns that +// single out-of-band step, so `contract_tests` itself never includes or links +// the HDF5 C API: the same producer/observer split the MDSplus drift tier +// uses (mdsplus_fixture_tool.cpp, issue #35). +// +// Usage: hdf5_fixture_tool +// directory holding an existing pulse's master.h5 +// (created beforehand through the C ABI by the caller) +// value to store, e.g. "999.0" or "1.999" +// +// Fixtures are always regenerated from the current backend at test run time — +// nothing opaque or host-specific is ever committed. + +#include + +#include +#include + +int main(int argc, char** argv) { + if (argc != 3) { + std::fprintf(stderr, + "usage: %s \n", argv[0]); + return 2; + } + const std::string master = std::string(argv[1]) + "/master.h5"; + const std::string version = argv[2]; + const char* attr_name = "HDF5_BACKEND_VERSION"; + + hid_t file_id = H5Fopen(master.c_str(), H5F_ACC_RDWR, H5P_DEFAULT); + if (file_id < 0) { + std::fprintf(stderr, "cannot open %s for writing\n", master.c_str()); + return 1; + } + + // The stored attribute's size is baked into its datatype, so replace the + // attribute wholesale instead of writing into the old one. + if (H5Aexists(file_id, attr_name) > 0 && + H5Adelete(file_id, attr_name) < 0) { + std::fprintf(stderr, "cannot delete existing %s\n", attr_name); + H5Fclose(file_id); + return 1; + } + + hid_t dataspace_id = H5Screate(H5S_SCALAR); + hid_t dtype_id = H5Tcopy(H5T_C_S1); + H5Tset_size(dtype_id, version.length() + 1); + H5Tset_cset(dtype_id, H5T_CSET_UTF8); + hid_t att_id = H5Acreate2(file_id, attr_name, dtype_id, dataspace_id, + H5P_DEFAULT, H5P_DEFAULT); + int rc = 0; + if (att_id < 0 || H5Awrite(att_id, dtype_id, version.c_str()) < 0) { + std::fprintf(stderr, "cannot write %s\n", attr_name); + rc = 1; + } + if (att_id >= 0) H5Aclose(att_id); + H5Tclose(dtype_id); + H5Sclose(dataspace_id); + H5Fclose(file_id); + + if (rc == 0) { + std::printf("stored %s=\"%s\" in %s\n", attr_name, version.c_str(), + master.c_str()); + } + return rc; +} diff --git a/tests/contract/test_hdf5_version_drift.cpp b/tests/contract/test_hdf5_version_drift.cpp new file mode 100644 index 00000000..f3000b6b --- /dev/null +++ b/tests/contract/test_hdf5_version_drift.cpp @@ -0,0 +1,118 @@ +// HDF5 stored-backend-version drift check (issue #36, TRACEABILITY.md Part 2 +// row B). +// +// al_begin_dataentry_action (src/al_lowlevel.cpp) compares the backend's +// compiled version (`getVersion(NULL)`, HDF5: {1,0}) against the version +// stored in the pulse being opened (`getVersion(pctx)`, HDF5: the +// HDF5_BACKEND_VERSION attribute of master.h5) on OPEN_PULSE/FORCE_OPEN_PULSE +// and throws LOWLEVEL_ERR on a mismatch. Part 2 row B previously declared +// this reachable check a terminal `gap` because forcing a mismatch needs +// out-of-band HDF5 attribute surgery; that surgery now lives in the isolated +// fixture producer hdf5_fixture_tool.cpp (same producer/observer split as the +// MDSplus drift tier), so this file asserts the contract exclusively through +// the public C ABI — no HDF5 include or link enters contract_tests. +// +// Empirically characterized: for HDF5 the generic LOWLEVEL_ERR drift branch +// is SHADOWED — HDF5Backend::openPulse constructs its reader/writer through +// HDF5BackendFactory, which accepts exactly "1.0" +// (src/hdf5/hdf5_backend_factory.cpp) and throws for any other stored value, +// so an incompatible pulse is refused with BACKEND_ERR ("No backend writer +// with version: ") BEFORE al_lowlevel.cpp's comparison ever runs. +// Every stored version other than the compiled one is thereby rejected — +// stricter than the generic check (which would tolerate an older minor). +// These pins assert that exact current refusal. +#ifdef AL_CONTRACT_HDF5_FIXTURE_TOOL + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include + +namespace { + +class Hdf5VersionDrift : public ::testing::Test { +protected: + // Creates a genuine pulse through the public C ABI and returns its + // directory (the URI "path" query parameter). + std::string create_pulse() { + const std::string dir = base_.str() + "/pulse"; + const std::string uri = "imas:hdf5?path=" + dir; + int ctx = -1; + EXPECT_EQ( + al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &ctx) + .code, + 0); + EXPECT_EQ(al_close_pulse(ctx, CLOSE_PULSE).code, 0); + return dir; + } + + // Out-of-band fixture mutation: rewrite the pulse's stored + // HDF5_BACKEND_VERSION via the isolated setup tool. Fixture producer + // only — nothing asserted here is part of the contract. + void set_stored_version(const std::string& dir, + const std::string& version) { + const std::string cmd = std::string("\"") + + AL_CONTRACT_HDF5_FIXTURE_TOOL + "\" \"" + dir + + "\" \"" + version + "\""; + ASSERT_EQ(std::system(cmd.c_str()), 0) + << "fixture tool failed: " << cmd; + } + + al_status_t open_pulse(const std::string& dir, int* ctx) { + const std::string uri = "imas:hdf5?path=" + dir; + return al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, ctx); + } + + al_contract::TempBase base_; +}; + +// Baseline: a pulse created and reopened by the same compiled backend carries +// a matching stored version, so the drift check is a no-op and OPEN succeeds. +TEST_F(Hdf5VersionDrift, MatchingStoredVersionOpensCleanly) { + const std::string dir = create_pulse(); + int ctx = -1; + al_status_t s = open_pulse(dir, &ctx); + EXPECT_EQ(s.code, 0) << "message: " << s.message; + if (s.code == 0) { + EXPECT_EQ(al_close_pulse(ctx, CLOSE_PULSE).code, 0); + } +} + +// A stored major the compiled backend (1.x) can never match is refused — +// never a silent open of incompatible data. Exact observed status: the +// factory's BACKEND_ERR, which fires before the generic LOWLEVEL_ERR drift +// comparison (see the header note). +TEST_F(Hdf5VersionDrift, HigherStoredMajorRefusesOpen) { + const std::string dir = create_pulse(); + set_stored_version(dir, "999.0"); + int ctx = -1; + al_status_t s = open_pulse(dir, &ctx); + EXPECT_EQ(s.code, alerror::backend_err) << "message: " << s.message; + EXPECT_NE(std::string(s.message).find("No backend writer with version"), + std::string::npos) + << "message: " << s.message; + EXPECT_NE(std::string(s.message).find("999.0"), std::string::npos) + << "the refusal must name the stored version; message: " << s.message; +} + +// The minor comparison boundary: a stored minor NEWER than the compiled one +// is likewise refused (the factory accepts exactly "1.0", so this is refused +// by the same shadowing BACKEND_ERR, not the generic minor comparison). +TEST_F(Hdf5VersionDrift, NewerStoredMinorRefusesOpen) { + const std::string dir = create_pulse(); + set_stored_version(dir, "1.999"); + int ctx = -1; + al_status_t s = open_pulse(dir, &ctx); + EXPECT_EQ(s.code, alerror::backend_err) << "message: " << s.message; + EXPECT_NE(std::string(s.message).find("1.999"), std::string::npos) + << "message: " << s.message; +} + +} // namespace + +#endif // AL_CONTRACT_HDF5_FIXTURE_TOOL From b26d7e040d76c1610f9dc6c2b7ddd490fbc6c917 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:31:05 +0200 Subject: [PATCH 57/91] Make the UDA version-drift tripwire use a mismatched stored fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old tripwire opened a fresh, matching HDF5 pulse — a future correct drift implementation would also accept it, so it discriminated nothing. Rework per issue #39: the pulse's stored HDF5_BACKEND_VERSION is rewritten to 999.0 out-of-band by hdf5_fixture_tool (issue #36's isolated producer); every asserted behavior stays on the public C ABI. Empirically characterized against the reference stack (which corrects the issue's assumption that the mismatched fixture opens today): the mismatched pulse is REFUSED, but by the SERVER-side HDF5 open — the server plugin's own IMAS-Core hits HDF5BackendFactory's 'No backend writer with version: 999.0' — forwarded to the client as UNKNOWN_ERR. The client-side check stays inert (placeholder {0,0} comparison); its own LOWLEVEL_ERR 'Compatibility …' refusal never appears. The pins encode that split: - baseline MatchingStoredVersionOpensThroughUda: matching fixture opens; - active tripwire VersionDriftCheckCurrentlyDefersToServerSideRefusal: exact UNKNOWN_ERR + server-forwarded message, and asserts the client-side 'Compatibility' diagnostic is absent — implementing real stored-version retrieval makes it fail; - DISABLED_OpenRefusesMismatchedStoredBackendVersion (correct contract): the client's own LOWLEVEL_ERR 'Compatibility' refusal. TRACEABILITY Part 5 documents the actual mismatched fixture and the exact observed status. Verified against the local aarch64 reference stack: full UDA tier 72/72 passed; dedicated-CI check reports 65 executed. Closes #39 --- tests/contract/TRACEABILITY.md | 60 ++++++----- tests/contract/test_uda_unique_surface.cpp | 111 ++++++++++++++++----- 2 files changed, 121 insertions(+), 50 deletions(-) diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index e3aaa8a0..89438b23 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -315,20 +315,16 @@ one genuine `xfail` crash defect it surfaced), and the timebase cache for HDF5.** Part 2 row B left the version-drift check a `gap` because forcing the mismatch needs writing a different backend-version value into the opened pulse after creation — for HDF5 that means poking its on-disk backend-version -attribute directly via the HDF5 C API, which that suite has no include/link -wiring for (out of scope there). MDSplus hits the identical shape of problem — -the mismatch needs overwriting the pulse's stored `VERSION:BACK_MAJOR` tree -node after creation, also unreachable through the C ABI — but the MDSplus tier -is already a separate, compile-guarded, Docker-characterized target -(`tests/contract/CMakeLists.txt`'s `AL_BACKEND_MDSPLUS` gate), so adding the -raw MDSplus C++ API (``) as a test-only, build-gated dependency -of `contract_tests` was a small, low-blast-radius addition — unlike wiring the -HDF5 C API into the always-on suite. So the gap is closed for MDSplus -specifically because of that pre-existing gate, not because the underlying -problem (a version-drift mismatch cannot be forced through the C ABI alone) is -actually different between the two backends. **Part 2 row B itself is left -unchanged** (HDF5 remains a terminal gap); this is a Part 4 finding about *why* -the two backends diverged, not a fix to Part 2. +attribute directly via the HDF5 C API, which that suite originally had no +include/link wiring for. MDSplus hits the identical shape of problem — the +mismatch needs overwriting the pulse's stored `VERSION:BACK_MAJOR` tree node +after creation, also unreachable through the C ABI. Both are now solved with +the same producer/observer split: all raw-API fixture mutation lives in +standalone, build-gated setup tools (`mdsplus_fixture_tool` for the tree +node, issue #35; `hdf5_fixture_tool` for the HDF5 attribute, issue #36) that +the tests invoke as subprocesses, so `contract_tests` itself carries no +MDSplus or HDF5 API dependency and every asserted behavior stays on the +public C ABI. **Part 2 row B is closed for HDF5 too** (see Part 2). Build-gated by `AL_CONTRACT_HAVE_MDSPLUS` (`tests/contract/CMakeLists.txt`, defined only when `AL_BACKEND_MDSPLUS=ON`), so every MDSplus case in @@ -575,12 +571,15 @@ MDSplus, the stored value is `VERSION:BACK_MAJOR`/`BACK_MINOR` (`src/mdsplus/mdsplus_backend.cpp`'s `saveVersion`, called once at pulse creation from the compiled `MDSPLUS_BACKEND_MAJOR`/`MINOR` constants, currently 1/1). Forcing the mismatch case needs a value in the pulse different from -what today's build would write — done here by opening the pulse's "ids" tree -directly through the raw MDSplus C++ API (``, not the C ABI) and -overwriting `VERSION:BACK_MAJOR` after creation, mirroring the -`ids_path`/`setDataEnv` environment-variable dance the backend itself uses -internally. See the finding above on why this was achievable for MDSplus but -stays a `gap` for HDF5 (Part 2 row B). +what today's build would write — done by the standalone, build-gated +`mdsplus_fixture_tool` (issue #35), which opens the pulse's "ids" tree +through the raw MDSplus C++ API (``) and overwrites +`VERSION:BACK_MAJOR` after creation, mirroring the `ids_path`/`setDataEnv` +environment-variable dance the backend itself uses internally. The test +invokes the tool as a subprocess and asserts exclusively through `al_*` +APIs: `contract_tests` has no direct MDSplus include or link dependency, so +a rewrite satisfying the C ABI is judged by exactly the C ABI. The identical +split now also closes the HDF5 side (Part 2 row B, issue #36). | Cluster / Capability | Test(s) | Status | |---|---|---| @@ -944,10 +943,21 @@ recorded here alongside the header's existing stack identifiers. `uda_backend.h`'s `UDA_BACKEND_VERSION_MAJOR/MINOR` and the "temporary placeholder" non-null-ctx branch), so `(0!=0)||(0<0)` is always false regardless of what is genuinely stored remotely. An unavailable stored - version must not be treated as verified compatibility. Pinned: - `UdaUniqueSurfaceTest.DISABLED_OpenRefusesWhenStoredBackendVersionCannotBeVerified` - + tripwire - `UdaUniqueSurfaceTest.VersionDriftCheckCurrentlyNeverFiresRegardlessOfStoredPulse`. + version must not be treated as verified compatibility. Pinned with a + genuinely MISMATCHED stored fixture (issue #39): the pulse's + `HDF5_BACKEND_VERSION` is rewritten to `999.0` out-of-band by + `hdf5_fixture_tool` (issue #36's isolated producer; fixture preparation + only — every asserted behavior stays on the public C ABI). Observed against + the reference stack: the mismatched pulse is REFUSED, but by the + SERVER-side HDF5 open (the server plugin's own IMAS-Core hits + `HDF5BackendFactory`'s "No backend writer with version: 999.0"), forwarded + to the client as exactly `UNKNOWN_ERR` (-1); the client-side check itself + stays inert — its own `LOWLEVEL_ERR` "Compatibility …" refusal never + appears, which is what the active tripwire pins. Pinned: + `UdaUniqueSurfaceTest.DISABLED_OpenRefusesMismatchedStoredBackendVersion` + (client-side refusal, correct contract) + tripwire + `UdaUniqueSurfaceTest.VersionDriftCheckCurrentlyDefersToServerSideRefusal`, + with baseline `UdaUniqueSurfaceTest.MatchingStoredVersionOpensThroughUda`. - **`supportsTimeRangeOperation()` capability negotiation confirmed against the reference server**: the reference plugin reports `1.8.0` (issue #23), so `1.8.0 > 1.4.0` grants the capability — `al_begin_timerange_action` must @@ -1020,7 +1030,7 @@ recorded here alongside the header's existing stack identifiers. | Runtime DD loading: present baseline and absent DD (both failure messages) | `UdaUniqueSurfaceTest.{DdPresentLoadsAndOpenSucceeds,DdAbsentNeitherEnvVarSetFailsWithClearMessage,DdAbsentFileMissingAtIdsDefPathFailsWithClearMessage}` | covered | | ↳ wrong-version DD loads silently with no semantic cross-version check | `UdaUniqueSurfaceTest.DISABLED_DdWrongVersionIsRejected` + tripwire `UdaUniqueSurfaceTest.DdWrongVersionCurrentlyLoadsSilentlyWithNoCrossVersionCheck` | **xfail** | | `datapath` partial-get via `cache_mode=ids`: in-scope field round-trips, out-of-scope field silently reads as absent | `UdaUniqueSurfaceTest.DatapathScopesCachePopulationFieldOutsideScopeReadsAsAbsent` | covered | -| Version-drift check inertness: open treats an unavailable stored version as compatible because both sides are hardcoded placeholders | `UdaUniqueSurfaceTest.DISABLED_OpenRefusesWhenStoredBackendVersionCannotBeVerified` + tripwire `UdaUniqueSurfaceTest.VersionDriftCheckCurrentlyNeverFiresRegardlessOfStoredPulse` | **xfail** | +| Version-drift check inertness: the client's own drift check never fires (both sides hardcoded placeholders); a pulse whose stored backend version (`999.0`, rewritten out-of-band by `hdf5_fixture_tool`) can never match is refused only by the forwarded SERVER-side HDF5 error (`UNKNOWN_ERR`, "No backend writer with version: 999.0") | `UdaUniqueSurfaceTest.DISABLED_OpenRefusesMismatchedStoredBackendVersion` + tripwire `UdaUniqueSurfaceTest.VersionDriftCheckCurrentlyDefersToServerSideRefusal` (baseline: `MatchingStoredVersionOpensThroughUda`) | **xfail** | | Server-version-gated `supportsTimeRangeOperation()`: reference plugin 1.8.0 > 1.4.0 grants the capability | `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 | diff --git a/tests/contract/test_uda_unique_surface.cpp b/tests/contract/test_uda_unique_surface.cpp index ee14b1fd..0a86b5d2 100644 --- a/tests/contract/test_uda_unique_surface.cpp +++ b/tests/contract/test_uda_unique_surface.cpp @@ -799,7 +799,8 @@ TEST_F(UdaUniqueSurfaceTest, DatapathScopesCachePopulationFieldOutsideScopeReads } // =========================================================================== -// Area 5: version-drift check inertness. +// Area 5: version-drift check inertness (reworked by issue #39: the tripwire +// now uses a genuinely MISMATCHED stored fixture). // // al_begin_dataentry_action (src/al_lowlevel.cpp:868-883) compares // backend->getVersion(NULL) (the "compiled" version) against @@ -811,46 +812,106 @@ TEST_F(UdaUniqueSurfaceTest, DatapathScopesCachePopulationFieldOutsideScopeReads // 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 Date: Thu, 16 Jul 2026 16:01:39 +0200 Subject: [PATCH 58/91] Accept the legitimately empty ids_model.datafile in the model verify step First real run of the issue #42 verify step failed on a healthy build: a baked MDSplus model tree carries all structure in ids_model.{tree,characteristics} and its ids_model.datafile is legitimately 0 bytes, so the -s (nonempty) check rejected it and the tier was skipped (masked by continue-on-error). Check tree and characteristics for nonzero size, datafile for existence only. Verified in the local aarch64 container: a clean '--build --target contract_tests' bakes all three files and 'ctest -L mdsplus' passes 44/44. Part of issue #42. --- .github/workflows/mdsplus-contract.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mdsplus-contract.yml b/.github/workflows/mdsplus-contract.yml index 50f85c2f..8222b82c 100644 --- a/.github/workflows/mdsplus-contract.yml +++ b/.github/workflows/mdsplus-contract.yml @@ -92,12 +92,19 @@ jobs: - name: Verify baked DD model tree is present (cache-hit and cache-miss paths) run: | set -euo pipefail - for f in ids_model.tree ids_model.datafile ids_model.characteristics; do + # ids_model.datafile is legitimately 0 bytes for a model tree (all + # structure lives in .tree/.characteristics; a model carries no + # data), so it is checked for existence only. + for f in ids_model.tree ids_model.characteristics; do if [ ! -s "build-mdsplus/models/mdsplus/${f}" ]; then echo "FATAL: missing/empty model file build-mdsplus/models/mdsplus/${f} — the MDSplus tier would fail with %TREE-E-FOPENR (issue #42)" exit 1 fi done + if [ ! -e "build-mdsplus/models/mdsplus/ids_model.datafile" ]; then + echo "FATAL: missing model file build-mdsplus/models/mdsplus/ids_model.datafile (issue #42)" + exit 1 + fi echo "Baked DD model tree complete under build-mdsplus/models/mdsplus." - name: Run MDSplus contract suite From c90b8e330d7f00852818a2df63e1763f5630dcd3 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:01:39 +0200 Subject: [PATCH 59/91] Keep the MDSplus version-drift contract test on the public C ABI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_mdsplus_version_drift.cpp included and mutated VERSION:BACK_MAJOR through the raw MDSplus C++ API, linking the MDSplus libraries into contract_tests — a second behavioral seam: a rewrite could satisfy the C ABI while the test still required the legacy API and storage layout. Separate fixture mutation from contract observation (issue #35): - al-mdsplus-fixture-tool (build-gated with the backend) owns ALL direct MDSplus API access: it reproduces the ids_path/setDataEnv dance and overwrites VERSION:BACK_MAJOR. Fixtures are regenerated from the pinned DD/model inputs at test run time; nothing opaque or host-specific is committed. - test_mdsplus_version_drift.cpp now includes only AL/test headers and calls only al_* APIs; it invokes the tool as a subprocess for the mismatch. contract_tests has no MDSplus include or link left (verified: no MDSplus NEEDED entry in the binary; the tool links libMdsObjectsCppShr). - The single discovery pass no longer sets a 'contract' label: ctest MERGES labels across set_tests_properties calls, so a discovery-time label would leak onto the mdsplus/uda tiers and change what 'ctest -L contract' (sanitizers.yml) selects. The labeler assigns each test its complete final list; verified in the container: contract=285 (=unit 98 + integration 187), mdsplus=45, disjoint. Verified in the local aarch64 container (compile-gated tier, reproducible): 'ctest -L mdsplus' 44/44 passed, both drift fixtures (matching + mismatched via the tool) prove the check; always-on suite 254/254 locally. Closes #35 --- tests/contract/CMakeLists.txt | 8 +- tests/contract/mdsplus_fixture_tool.cpp | 68 ++++++++++++++ tests/contract/test_mdsplus_version_drift.cpp | 91 ++++++------------- 3 files changed, 104 insertions(+), 63 deletions(-) create mode 100644 tests/contract/mdsplus_fixture_tool.cpp diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 58db1915..35b23f3e 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -248,8 +248,12 @@ endif() # version-dependent forwarding in the middle. The tier definitions (which # suites are unit / integration / mdsplus / uda, per issues #13-#17 and # #23-#26) are documented and maintained in assign_contract_labels.cmake.in. -gtest_discover_tests(contract_tests - PROPERTIES LABELS "contract") +# No LABELS here: ctest MERGES labels across set_tests_properties calls +# (append semantics), so a discovery-time "contract" could not be removed by +# the labeler and would leak onto the mdsplus/uda tiers, changing what +# `ctest -L contract` (sanitizers.yml) selects. assign_contract_labels.cmake +# assigns each test its complete, final label list instead. +gtest_discover_tests(contract_tests) # Bake the generated registration file's path into the labeler. A single # discovery pass for contract_tests means GoogleTest writes diff --git a/tests/contract/mdsplus_fixture_tool.cpp b/tests/contract/mdsplus_fixture_tool.cpp new file mode 100644 index 00000000..78d80539 --- /dev/null +++ b/tests/contract/mdsplus_fixture_tool.cpp @@ -0,0 +1,68 @@ +// mdsplus_fixture_tool — out-of-band stored-backend-version mutation for the +// MDSplus drift fixture (issue #35). +// +// The contract suite's seam is the public C ABI (issues #1/#12); preparing a +// MISMATCHED pulse, however, requires overwriting the VERSION:BACK_MAJOR node +// the backend wrote into the tree at pulse creation — reachable only through +// the raw MDSplus C++ API (). This standalone, build-gated tool +// owns ALL direct MDSplus API access, so `contract_tests` itself neither +// includes nor links the MDSplus C++ libraries: it is a +// fixture producer, not part of the asserted contract. +// +// Usage: mdsplus_fixture_tool +// directory holding the pulse's "ids" tree files (created +// beforehand through the C ABI by the caller; shot 1) +// integer to store into VERSION:BACK_MAJOR, e.g. 99 +// +// MDSPLUS_MODELS_PATH must point at the baked model tree, exactly as for the +// backend itself. Fixtures are regenerated from pinned DD/model inputs at +// test run time — nothing opaque or host-specific is ever committed. + +#include + +#include +#include +#include + +int main(int argc, char** argv) { + if (argc != 3) { + std::fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + const std::string pulse_dir = argv[1]; + const int back_major = std::atoi(argv[2]); + + const char* models_path = std::getenv("MDSPLUS_MODELS_PATH"); + if (models_path == nullptr || *models_path == '\0') { + std::fprintf(stderr, "MDSPLUS_MODELS_PATH is not set\n"); + return 2; + } + + // Reproduces MDSplusBackend::setDataEnv's "ids_path" env-var dance + // (mdsplus_backend.cpp) well enough to open the same tree the backend + // itself opened through the C ABI. + const std::string ids_path = pulse_dir + ";" + models_path; + if (setenv("ids_path", ids_path.c_str(), 1) != 0) { + std::fprintf(stderr, "cannot set ids_path\n"); + return 1; + } + + try { + MDSplus::Tree tree("ids", /*shot=*/1, "NORMAL"); + MDSplus::TreeNode* node = tree.getNode("VERSION:BACK_MAJOR"); + MDSplus::Int32* value = new MDSplus::Int32(back_major); + node->putData(value); + MDSplus::deleteData(value); + delete node; + } catch (MDSplus::MdsException& exc) { + std::fprintf(stderr, + "could not poke VERSION:BACK_MAJOR via the MDSplus tree " + "API: %s\n", + exc.what()); + return 1; + } + + std::printf("stored VERSION:BACK_MAJOR=%d in %s\n", back_major, + pulse_dir.c_str()); + return 0; +} diff --git a/tests/contract/test_mdsplus_version_drift.cpp b/tests/contract/test_mdsplus_version_drift.cpp index c463b3a9..59636ee5 100644 --- a/tests/contract/test_mdsplus_version_drift.cpp +++ b/tests/contract/test_mdsplus_version_drift.cpp @@ -1,36 +1,27 @@ -// MDSplus version-drift check (issue #16, TRACEABILITY.md Part 4). +// MDSplus version-drift check (issue #16, reworked by issue #35; +// TRACEABILITY.md Part 4). // -// al_lowlevel.cpp's al_begin_dataentry_action (src/al_lowlevel.cpp:868-883) -// compares the backend's compiled version (`getVersion(NULL)`) against the -// version stored in the pulse being opened (`getVersion(pctx)`) on -// OPEN_PULSE/FORCE_OPEN_PULSE and throws LOWLEVEL_ERR on a mismatch. This is -// generic al_lowlevel.cpp logic shared by every backend, but MDSplus is the -// one backend where `getVersion(pctx)` reads a value a pulse can genuinely -// carry different from today's build: `VERSION:BACK_MAJOR`/`BACK_MINOR`, -// written into the tree once at pulse creation -// (`mdsplus_backend.cpp`'s `saveVersion`, called from `createPulse`) from the -// compiled `MDSPLUS_BACKEND_MAJOR`/`MINOR` constants (currently 1/1) and read -// back unchanged forever after. Forcing the mismatch case therefore needs -// writing a different value into that node after the fact -- through the raw -// MDSplus C++ API (``), not the public C ABI, exactly the kind -// of out-of-band access issue #12's Part 2 row B ("gap", see TRACEABILITY.md) -// declined for HDF5 because that suite has no HDF5 include/link wiring. This -// file adds that wiring for MDSplus only (tests/contract/CMakeLists.txt, -// gated the same way as AL_CONTRACT_HAVE_MDSPLUS): the MDSplus tier already -// links libMDSplus into `al` PRIVATE-ly for the backend itself, and doing the -// same for `contract_tests` is a test-only, build-gated addition -- so this -// finding *is* closeable for MDSplus even though it stayed a terminal gap for -// HDF5 (whose always-on suite deliberately doesn't carry an HDF5 C API -// dependency at all). -#ifdef AL_CONTRACT_HAVE_MDSPLUS +// al_lowlevel.cpp's al_begin_dataentry_action compares the backend's compiled +// version (`getVersion(NULL)`) against the version stored in the pulse being +// opened (`getVersion(pctx)`) on OPEN_PULSE/FORCE_OPEN_PULSE and throws +// LOWLEVEL_ERR on a mismatch. MDSplus stores that version in the tree's +// VERSION:BACK_MAJOR/BACK_MINOR nodes, written once at pulse creation from +// the compiled constants (currently 1/1) and read back unchanged forever. +// +// Forcing the mismatch needs out-of-band tree surgery — but that mutation is +// FIXTURE PREPARATION, not contract: it lives in the separate build-gated +// producer mdsplus_fixture_tool.cpp (issue #35), which owns all direct +// MDSplus C++ API access. This file includes only AL/test headers and calls +// only `al_*` APIs, so a rewrite satisfying the C ABI is judged by exactly +// the C ABI — no second behavioral seam through or the legacy +// storage layout leaks into the asserted contract. +#if defined(AL_CONTRACT_HAVE_MDSPLUS) && defined(AL_CONTRACT_MDSPLUS_FIXTURE_TOOL) #include "al_contract.h" #include #include -#include - #include #include @@ -63,37 +54,16 @@ class MdsplusVersionDrift : public ::testing::Test { std::to_string(pulse_.run); } - // Reproduces MDSplusBackend::setDataEnv/resetIdsPath's "ids_path" env-var - // dance (mdsplus_backend.cpp:1458-1517) well enough to open the same tree - // the backend itself opened through the C ABI -- this test has no access - // to the backend's private state, only the public MDSplus tree API. + // Out-of-band fixture mutation via the isolated setup tool + // (mdsplus_fixture_tool.cpp) — the producer of the mismatched fixture, + // not part of the asserted contract. void PokeStoredBackendMajorVersion(int mismatched_major) { - const char* models_path = std::getenv("MDSPLUS_MODELS_PATH"); - ASSERT_TRUE(models_path && *models_path); - const std::string original_ids_path = - std::getenv("ids_path") ? std::getenv("ids_path") : ""; - - const std::string new_ids_path = pulse_dir() + ";" + models_path; - ASSERT_EQ(setenv("ids_path", new_ids_path.c_str(), 1), 0); - - try { - MDSplus::Tree tree("ids", /*shot=*/1, "NORMAL"); - MDSplus::TreeNode* node = tree.getNode("VERSION:BACK_MAJOR"); - MDSplus::Int32* value = new MDSplus::Int32(mismatched_major); - node->putData(value); - MDSplus::deleteData(value); - delete node; - } catch (MDSplus::MdsException& exc) { - FAIL() << "Could not poke VERSION:BACK_MAJOR directly via the " - "MDSplus tree API: " - << exc.what(); - } - - if (!original_ids_path.empty()) { - setenv("ids_path", original_ids_path.c_str(), 1); - } else { - unsetenv("ids_path"); - } + const std::string cmd = std::string("\"") + + AL_CONTRACT_MDSPLUS_FIXTURE_TOOL + "\" \"" + + pulse_dir() + "\" " + + std::to_string(mismatched_major); + ASSERT_EQ(std::system(cmd.c_str()), 0) + << "fixture tool failed: " << cmd; } }; @@ -120,9 +90,9 @@ TEST_F(MdsplusVersionDrift, MatchingVersionOpensCleanly) { } } -// Mismatching case: after creation, VERSION:BACK_MAJOR is overwritten to a -// value the compiled backend (MDSPLUS_BACKEND_MAJOR == 1) can never match. -// Reopening through the C ABI must hit al_lowlevel.cpp's +// Mismatching case: after creation, VERSION:BACK_MAJOR is overwritten (by the +// fixture tool) to a value the compiled backend (MDSPLUS_BACKEND_MAJOR == 1) +// can never match. Reopening through the C ABI must hit al_lowlevel.cpp's // `(ver.first != sver.first)` branch and fail with LOWLEVEL_ERR, never a // silent open of stale/incompatible data. TEST_F(MdsplusVersionDrift, MismatchedBackendVersionRefusesOpen) { @@ -145,7 +115,6 @@ TEST_F(MdsplusVersionDrift, MismatchedBackendVersionRefusesOpen) { << "expected the version-drift check to refuse opening a pulse whose " "stored VERSION:BACK_MAJOR (" << kMismatchedMajor << ") doesn't match the compiled backend's"; - ASSERT_TRUE(status.message != nullptr); EXPECT_NE(std::string(status.message).find("Compatibility"), std::string::npos) << "status.message=" << status.message; @@ -153,4 +122,4 @@ TEST_F(MdsplusVersionDrift, MismatchedBackendVersionRefusesOpen) { } // namespace -#endif // AL_CONTRACT_HAVE_MDSPLUS +#endif // AL_CONTRACT_HAVE_MDSPLUS && AL_CONTRACT_MDSPLUS_FIXTURE_TOOL From 8506879e241ccde31cd3cca88a72ea8ee26fdc53 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:27:41 +0200 Subject: [PATCH 60/91] Share the MDSplus/UDA real-DD path catalog (#46) The MDSplus (#15) and UDA (#25) real-path breadth suites each defined their own copy of the DD-4.1.1 datatype/rank path table, the terminal-gap DD facts, and the case-name generator. A path correction or a new (type, rank) cell needed coordinated edits in two large files, and the tables could silently diverge -- undermining #12/#21's promise that the two backend verdicts are comparable over the same curated set. Extract the single source of truth into test-only real_dd_path_catalog.{h,cpp}: the validated DD version, the datatype/rank key, the IDS + struct_array chain + leaf, and the terminal-gap reason (all DD facts, identical for both backends). Backend-specific verdicts stay local -- MDSplus's STR_0D dimension divergence and UDA's fixture-backend divergence + dynamic-leaf-inside-AOS known defects are attached per suite by (dtype, rank) key, never duplicated into the catalog. Both matrices now derive their parametrization 1:1 from catalog(), so a cell cannot be dropped or duplicated silently: - RealDdPathCatalog.* (always-on): the catalog has exactly one entry per (dtype, rank) cell, no duplicate, path/terminal-gap mutually exclusive. - {Mdsplus,Uda}RealPathCatalog.CoversEveryCatalogKeyOnce (per gated tier): the matrix key set equals the catalog key set exactly. Case names ('CHAR_r0' ...) and INSTANTIATE_TEST_SUITE_P prefixes are preserved, so existing test names and TRACEABILITY references stay stable. Verified: MDSplus tier 18 pass / 14 skip (unchanged) + meta green; UDA tier 15 pass / 17 skip + 3 known-defect tripwires green + meta green (one Protocol-11 server flake confirmed transient on re-run); always-on catalog tests 2/2; label partition disjoint and hermetic (unit 98->100, mdsplus 45->46, integration unchanged) across MDSplus-enabled and backends-off builds. --- tests/contract/CMakeLists.txt | 2 + tests/contract/TRACEABILITY.md | 62 ++-- .../contract/assign_contract_labels.cmake.in | 9 +- tests/contract/real_dd_path_catalog.cpp | 85 +++++ tests/contract/real_dd_path_catalog.h | 90 +++++ tests/contract/test_mdsplus_real_paths.cpp | 185 +++++------ tests/contract/test_real_dd_path_catalog.cpp | 82 +++++ tests/contract/test_uda_real_paths.cpp | 309 +++++++++--------- 8 files changed, 532 insertions(+), 292 deletions(-) create mode 100644 tests/contract/real_dd_path_catalog.cpp create mode 100644 tests/contract/real_dd_path_catalog.h create mode 100644 tests/contract/test_real_dd_path_catalog.cpp diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 35b23f3e..d6be748a 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -70,6 +70,8 @@ add_executable(contract_tests test_plugins.cpp test_backend_factory.cpp test_hdf5_version_drift.cpp + real_dd_path_catalog.cpp + test_real_dd_path_catalog.cpp test_structured_data.cpp test_pulse_lifecycle.cpp test_mdsplus.cpp diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 89438b23..f51e181c 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -408,7 +408,13 @@ Where the rows above join MDSplus to the *existing* fixtures' synthetic shapes, this matrix instead curates one **real** DD-4.1.1 path per `{CHAR, INTEGER, DOUBLE, COMPLEX} x rank {0..7}` cell — the axis `RoundTripMatrix` already sweeps on the always-on tier, but against opaque -paths MDSplus cannot resolve (issue #12 Q2/Q5). Path curation method: the +paths MDSplus cannot resolve (issue #12 Q2/Q5). The curated set is the shared +`al_contract::real_dd::catalog()` (`real_dd_path_catalog.{h,cpp}`, issue #46): +the IDS, struct_array chain, leaf, and terminal-gap DD facts are identical for +MDSplus and UDA and live in exactly one place, so the two matrices cannot +silently diverge; only each backend's own storage-model verdicts stay local +(`RealDdPathCatalog.*` plus the per-backend `*RealPathCatalog.CoversEveryCatalogKeyOnce` +meta-tests fail if a matrix drops a cell). Path curation method: the `imas-dd` MCP tool CLAUDE.md names was not present in this session's MCP configuration, so the same question it would answer was resolved by walking the actual baked-from artifact directly — @@ -661,12 +667,15 @@ not fixed — both explained in full below. ## Real-DD-path datatype × rank breadth (issue #25, reusing issue #15's set) `test_uda_real_paths.cpp`, `Uda/UdaRealPathMatrix.*` — the UDA analogue of -`test_mdsplus_real_paths.cpp`: **the identical curated path/rank/aos_chain set -reused as-is** (same 32 cells, same 10 IDSs), adapted to seed(HDF5)-then- -reopen(UDA) instead of MDSplus's direct write→read. `terminal_gap_reason` -carries over unchanged (a DD-4.1.1 fact, independent of backend); a UDA-only -`divergence_reason` and `known_defect_reason` were added where the two -backends diverge for different reasons on the same cell. +`test_mdsplus_real_paths.cpp`: **the identical curated path/rank/aos_chain set, +now the shared `al_contract::real_dd::catalog()`** (`real_dd_path_catalog.{h,cpp}`, +issue #46 — one source of truth for both matrices, same 32 cells, same 10 +IDSs), adapted to seed(HDF5)-then-reopen(UDA) instead of MDSplus's direct +write→read. `terminal_gap_reason` lives in the shared catalog (a DD-4.1.1 fact, +independent of backend); UDA-only `divergence_reason`/`known_defect_reason` +verdicts stay local to `test_uda_real_paths.cpp` where the two backends diverge +for different reasons on the same cell. `UdaRealPathCatalog.CoversEveryCatalogKeyOnce` +fails if the UDA matrix ever drops or duplicates a catalog cell. **Characterization-discovered facts:** @@ -714,8 +723,8 @@ backends diverge for different reasons on the same cell. 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. + `UdaAosKnownDefects` pair remains an independent value-transport pin, not a + substitute for these exact matrix cases or the active traversal proof below. | Cluster / Capability | Test(s) | Status | |---|---|---| @@ -734,20 +743,25 @@ the lifecycle cases drive the remote UDA URI directly through the C ABI. **Characterization-discovered facts:** -- **AOS traversal (top-level) hits the same dynamic-leaf-inside-AOS defect - described above**: `equilibrium/time_slice` (a real DD struct_array, - `timebasepath="time"`) correctly reports its written size (3) through - `al_begin_arraystruct_action`, and iteration genuinely advances (the request - trace shows three distinct `time_slice[0..2]/global_quantities/ip` - directives), but every element's `global_quantities/ip` value comes back as - the HDF5 absent-scalar sentinel (`-9.0e40`) instead of what was written. - Pinned as the canonical instance of the defect: `UdaAosKnownDefects. - DISABLED_DynamicLeafInsideAosRoundTrips` + tripwire `UdaAosKnownDefects. - DynamicLeafInsideAosCurrentlyReturnsSentinel`. Nested (multi-level) AOS - traversal is exercised separately and successfully by the real-path - matrix's *static* AOS-nested cells above (INTEGER r3, DOUBLE r6) — the - traversal mechanism itself (begin/iterate/end across nesting) works; only - a *dynamic* leaf's value fails to come back correctly. +- **AOS traversal (top-level) has a discriminating active C-ABI proof**: + `temporary/constant_integer3d` is a real DD-4.1.1 struct_array with a static + `value` leaf known to round-trip through UDA. Three elements with distinct, + order-sensitive values are seeded through HDF5 in physical index order + 0→2→1 (so setup does not mirror the asserted traversal), then read in order + 0→1→2 around successive `al_iterate_over_arraystruct(..., 1)` calls after + reopening through UDA. + `UdaBreadthTest.AosTraversalAdvancesAcrossDistinctStaticElements` asserts the + exact sequence, so a successful no-op, repeated element, or reordered + element fails. It also ends the AOS action and verifies through the same + public function that the invalid context returns an error status. +- **The dynamic-leaf-inside-AOS value defect remains separately pinned**: + `equilibrium/time_slice` correctly reports its written size (3), but every + element's `global_quantities/ip` value comes back as the HDF5 absent-scalar + sentinel (`-9.0e40`) instead of what was written. The canonical D2 pair is + `UdaAosKnownDefects.DISABLED_DynamicLeafInsideAosRoundTrips` + tripwire + `UdaAosKnownDefects.DynamicLeafInsideAosCurrentlyReturnsSentinel`. Nested + (multi-level) traversal is also exercised by the real-path matrix's static + AOS cells (INTEGER r3, DOUBLE r6). - **`al_get_occurrences` is real through UDA remote mode** (the reference `IMAS` server plugin implements `getOccurrences`, confirmed via the PRD's static finding): occurrence 0 and occurrence 2 (HDF5's own `_` @@ -823,7 +837,7 @@ the lifecycle cases drive the remote UDA URI directly through the C ABI. | 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) | +| AOS traversal, top-level (reported size + exact three-element forward sequence; invalid ended context returns an error; nested traversal proven separately by the real-path matrix's static AOS cells) | `UdaBreadthTest.AosTraversalAdvancesAcrossDistinctStaticElements` | covered | | ↳ a *dynamic* leaf nested inside a struct_array returns the HDF5 absent-scalar sentinel instead of what was written | `UdaAosKnownDefects.DISABLED_DynamicLeafInsideAosRoundTrips` + tripwire `UdaAosKnownDefects.DynamicLeafInsideAosCurrentlyReturnsSentinel` | **xfail** | | `al_get_occurrences` real through UDA remote mode, HDF5's `_` storage naming and UDA's `/` public naming pinned; every reported occurrence is reopened and read | `UdaBreadthTest.OccurrencesListsWrittenOccurrencesThroughReopen` | covered | | `al_list_filled_paths` real through UDA remote mode (`backend=hdf5` only), ownership pinned | `UdaBreadthTest.ListFilledPathsThroughReopen` | covered | diff --git a/tests/contract/assign_contract_labels.cmake.in b/tests/contract/assign_contract_labels.cmake.in index 560b885f..8cbd84a5 100644 --- a/tests/contract/assign_contract_labels.cmake.in +++ b/tests/contract/assign_contract_labels.cmake.in @@ -51,7 +51,10 @@ if(EXISTS "${_al_contract_tests_file}") _al_name MATCHES "^UriOwnership\\." OR # issue #47: unknown-backend refusals at the factory boundary — no # backend substrate is ever constructed, so the tier is hermetic - _al_name MATCHES "^BackendFactory\\.") + _al_name MATCHES "^BackendFactory\\." OR + # issue #46: the shared real-DD-path catalog's own invariants — pure + # in-RAM table checks, no backend substrate, always compiled + _al_name MATCHES "^RealDdPathCatalog\\.") set_tests_properties("${_al_name}" PROPERTIES LABELS "contract;unit") elseif(_al_name MATCHES "^MdsplusTracerBullet" OR # "/Mdsplus" as the whole instance suffix: pretty names end @@ -59,6 +62,8 @@ if(EXISTS "${_al_contract_tests_file}") _al_name MATCHES "/Mdsplus(#|$)" OR _al_name MATCHES "^Occurrences\\.Mdsplus" OR _al_name MATCHES "^Mdsplus/MdsplusRealPathMatrix\\." OR + # issue #46: MDSplus half of the shared-catalog coverage meta-test + _al_name MATCHES "^MdsplusRealPathCatalog\\." OR _al_name MATCHES "^MdsplusVersionDrift" OR _al_name MATCHES "^MdsplusStructAwareSliceInterpolation" OR _al_name MATCHES "^MdsplusTimerangeResampling" OR @@ -70,6 +75,8 @@ if(EXISTS "${_al_contract_tests_file}") _al_name MATCHES "^UdaEquilibriumSeedParity" OR _al_name MATCHES "^Uda/UdaRealPathMatrix\\." OR _al_name MATCHES "^Uda/UdaRealPathMatrixKnownDefects\\." OR + # issue #46: UDA half of the shared-catalog coverage meta-test + _al_name MATCHES "^UdaRealPathCatalog\\." OR _al_name MATCHES "^UdaBreadthTest" OR _al_name MATCHES "^UdaAosKnownDefects" OR _al_name MATCHES "^UdaSliceAndTimeRange" OR diff --git a/tests/contract/real_dd_path_catalog.cpp b/tests/contract/real_dd_path_catalog.cpp new file mode 100644 index 00000000..a87ae80d --- /dev/null +++ b/tests/contract/real_dd_path_catalog.cpp @@ -0,0 +1,85 @@ +// The shared real-DD-path catalog (issue #46). See real_dd_path_catalog.h for +// what belongs here (curated DD paths + terminal-gap DD facts) and what does +// not (backend-specific verdicts). Curated against DD 4.1.1 +// (build-mdsplus/_deps/data-dictionary-src/IDSDef.xml). + +#include "real_dd_path_catalog.h" + +namespace al_contract { +namespace real_dd { + +const std::vector& catalog() { + // Exactly one entry per (dt, rank) cell, in (dt, rank) order. The + // CoversEveryCellExactlyOnce meta-test (test_real_dd_path_catalog.cpp) is + // the guard that this stays a complete, duplicate-free kNumCells table. + static const std::vector kCatalog = { + // --- CHAR ----------------------------------------------------------- + {DType::Char, 0, "equilibrium", {}, "code/name", nullptr}, + {DType::Char, 1, "equilibrium", {}, "code/name", nullptr}, + {DType::Char, 2, "b_field_non_axisymmetric", {}, "control_surface_names", + nullptr}, + {DType::Char, 3, nullptr, {}, nullptr, + "DD 4.1.1 has no STR_2D+ at all (only STR_0D/STR_1D exist -- there is " + "no DD concept a CHAR dim>=3 could model)"}, + {DType::Char, 4, nullptr, {}, nullptr, "see rank 3"}, + {DType::Char, 5, nullptr, {}, nullptr, "see rank 3"}, + {DType::Char, 6, nullptr, {}, nullptr, "see rank 3"}, + {DType::Char, 7, nullptr, {}, nullptr, "see rank 3"}, + + // --- INTEGER -------------------------------------------------------- + {DType::Int, 0, "amns_data", {}, "z_n", nullptr}, + {DType::Int, 1, "magnetics", {}, "code/output_flag", nullptr}, + {DType::Int, 2, "magnetics", {}, "b_field_pol_probe_equivalent", + nullptr}, + {DType::Int, 3, "temporary", {"constant_integer3d"}, "value", nullptr}, + {DType::Int, 4, nullptr, {}, nullptr, + "DD 4.1.1 has no INT_4D+ anywhere (max is INT_3D, itself only inside a " + "struct_array)"}, + {DType::Int, 5, nullptr, {}, nullptr, "see rank 4"}, + {DType::Int, 6, nullptr, {}, nullptr, "see rank 4"}, + {DType::Int, 7, nullptr, {}, nullptr, "see rank 4"}, + + // --- DOUBLE --------------------------------------------------------- + {DType::Double, 0, "amns_data", {}, "a", nullptr}, + {DType::Double, 1, "balance_of_plant", {}, "gain_plant", nullptr}, + {DType::Double, 2, "bolometer", {}, "grid/volume_element", nullptr}, + {DType::Double, 3, "bolometer", {}, "power_density/data", nullptr}, + {DType::Double, 4, "gyrokinetics_local", {}, + "non_linear/fluxes_4d/particles_phi_potential", nullptr}, + {DType::Double, 5, "gyrokinetics_local", {}, + "non_linear/fluxes_5d/particles_phi_potential", nullptr}, + {DType::Double, 6, "temporary", {"constant_float6d"}, "value", nullptr}, + {DType::Double, 7, nullptr, {}, nullptr, + "DD 4.1.1 has no FLT_7D (the DD's own array-rank ceiling for DOUBLE is " + "6, one short of MAXDIM)"}, + + // --- COMPLEX -------------------------------------------------------- + {DType::Complex, 0, nullptr, {}, nullptr, + "DD 4.1.1 has no CPX_0D -- no scalar complex type exists at all"}, + {DType::Complex, 1, "waves", + {"coherent_wave", "full_wave", "e_field/plus"}, "values", nullptr}, + {DType::Complex, 2, "gyrokinetics_local", {}, + "non_linear/fields_zonal_2d/phi_potential_perturbed_norm", nullptr}, + {DType::Complex, 3, "runaway_electrons", {"distribution/markers"}, + "orbit_integrals_instant/values", nullptr}, + {DType::Complex, 4, "gyrokinetics_local", {}, + "non_linear/fields_4d/phi_potential_perturbed_norm", nullptr}, + {DType::Complex, 5, "runaway_electrons", {"distribution/markers"}, + "orbit_integrals/values", nullptr}, + {DType::Complex, 6, nullptr, {}, nullptr, + "DD 4.1.1 has no CPX_6D anywhere"}, + {DType::Complex, 7, nullptr, {}, nullptr, + "DD 4.1.1 has no CPX_7D anywhere"}, + }; + return kCatalog; +} + +std::string case_name(DType dt, int rank) { + static const char* const kNames[] = {"CHAR", "INTEGER", "DOUBLE", + "COMPLEX"}; + return std::string(kNames[static_cast(dt)]) + "_r" + + std::to_string(rank); +} + +} // namespace real_dd +} // namespace al_contract diff --git a/tests/contract/real_dd_path_catalog.h b/tests/contract/real_dd_path_catalog.h new file mode 100644 index 00000000..7d03bfd9 --- /dev/null +++ b/tests/contract/real_dd_path_catalog.h @@ -0,0 +1,90 @@ +// Shared real-DD-path catalog for the MDSplus (#15) and UDA (#25) breadth +// matrices (issue #46, TRACEABILITY.md Parts 4/5). +// +// Both real-path suites sweep the same {CHAR, INTEGER, DOUBLE, COMPLEX} x rank +// {0 .. MAXDIM} space against curated *real* DD-4.1.1 paths (a made-up field +// name has no node in the schema either backend validates against). The path +// for each (datatype, rank) cell -- the IDS, the struct_array chain to enter, +// the leaf, and whether DD 4.1.1 even has such a field -- is a fact about the +// DD, identical for both backends. This header is the single source of truth +// for that catalog so a path correction, a terminal-gap rationale, or a new +// (type, rank) cell is edited in exactly one place and the two matrices cannot +// silently diverge (issue #46). +// +// What stays OUT of the catalog, on purpose: backend-specific expected +// outcomes. A cell can be a plain pass on one backend and a storage-model +// divergence or a known xfail on the other (e.g. MDSplus's STR_0D dimension +// divergence vs UDA's dynamic-leaf-inside-AOS defect). Those verdicts live +// local to each backend suite; only the shared curated path lives here. +// +// Path curation method (carried over from test_mdsplus_real_paths.cpp's +// header): resolved by walking the actual baked-from artifact, +// build-mdsplus/_deps/data-dictionary-src/IDSDef.xml (the DD 4.1.1 source +// ALBuildDataDictionary.cmake downloads and the model tree is compiled from), +// to find, per (type, rank) cell, a real field of that data_type together with +// its struct_array ancestor chain -- empirical characterization against the +// real shipped artifact (TEST_STRATEGY D2). imas-dd MCP validation is used for +// catalog changes. +#ifndef AL_CONTRACT_REAL_DD_PATH_CATALOG_H +#define AL_CONTRACT_REAL_DD_PATH_CATALOG_H + +#include +#include + +namespace al_contract { +namespace real_dd { + +// The four C-ABI scalar datatypes the breadth matrices sweep. +enum class DType { Char, Int, Double, Complex }; + +// The DD version every curated path in this catalog was validated against. +inline constexpr const char* kCatalogDdVersion = "4.1.1"; + +// The swept space: kNumDTypes datatypes x ranks [kMinRank, kMaxRank]. +inline constexpr int kNumDTypes = 4; +inline constexpr int kMinRank = 0; +inline constexpr int kMaxRank = 7; // MAXDIM +inline constexpr int kNumRanks = kMaxRank - kMinRank + 1; +inline constexpr int kNumCells = kNumDTypes * kNumRanks; + +// One curated (datatype, rank) cell -- the single source of truth for the DD +// path a backend breadth matrix stores at that cell. +// +// `terminal_gap_reason` non-null means DD kCatalogDdVersion has no field of +// this (dt, rank) at any nesting depth (a fact about the DD, shared by every +// backend); `ids`/`leaf` are then null and `aos_chain` empty. Otherwise the +// path fields name a real DD-conformant field: `aos_chain` lists the +// struct_array field(s) to enter in order (each may itself be a multi-segment +// path through intervening plain structures, e.g. "e_field/plus"); empty means +// the leaf hangs directly off the GLOBAL op. +// +// The CHAR rank axis is the raw C-ABI `dim` argument, one off from the DD's +// STR_ND suffix (IMAS convention: a *string* is CHAR dim=1, an *array of +// strings* is CHAR dim=2; dim=0 is a bare scalar char reusing the STR_0D node). +// INTEGER/DOUBLE/COMPLEX map dim directly to their _ND suffix. +struct CatalogEntry { + DType dt; + int rank; + const char* ids; // null iff terminal gap + std::vector aos_chain; // struct_array chain to enter + const char* leaf; // null iff terminal gap + const char* terminal_gap_reason; // null iff a real path exists +}; + +// The full curated catalog: exactly one entry per (dt, rank) cell, kNumCells +// entries, curated against DD kCatalogDdVersion. +const std::vector& catalog(); + +// Canonical, stable parameter-name suffix for a cell, e.g. "DOUBLE_r3". Used +// verbatim as the INSTANTIATE_TEST_SUITE_P case-name in both backend suites, so +// existing test names / TRACEABILITY references stay stable across the #46 +// extraction. +std::string case_name(DType dt, int rank); +inline std::string case_name(const CatalogEntry& e) { + return case_name(e.dt, e.rank); +} + +} // namespace real_dd +} // namespace al_contract + +#endif // AL_CONTRACT_REAL_DD_PATH_CATALOG_H diff --git a/tests/contract/test_mdsplus_real_paths.cpp b/tests/contract/test_mdsplus_real_paths.cpp index 1ea4a304..491b9dfe 100644 --- a/tests/contract/test_mdsplus_real_paths.cpp +++ b/tests/contract/test_mdsplus_real_paths.cpp @@ -13,15 +13,10 @@ // multiple IDSs (the model tree is baked from the whole IDSDef.xml, so this // exercises that whole-DD bake, not one container). // -// Path curation method: the imas-dd MCP tool named in CLAUDE.md was not -// present in this session's MCP configuration, so the same question it would -// answer was resolved by walking the actual baked-from artifact directly -- -// build-mdsplus/_deps/data-dictionary-src/IDSDef.xml (the DD-4.1.1 source -// ALBuildDataDictionary.cmake downloads and the model tree is compiled from) -// -- to find, per (type, rank) cell, a real field of that data_type together -// with its struct_array ancestor chain (if any). This is still empirical -// characterization against the real shipped artifact (TEST_STRATEGY D2), -// simply queried by hand instead of through the MCP wrapper. +// The curated path set is the shared real_dd::catalog() (issue #46): the IDS, +// struct_array chain, leaf, and terminal-gap DD facts are identical for MDSplus +// and UDA and live in real_dd_path_catalog.{h,cpp}. Only MDSplus's own +// storage-model verdicts stay here (kDivergences below). // // Per (type, rank) cell, one of three shapes: // * plain leaf -- straightforward GLOBAL write/read, exactly like a @@ -50,6 +45,7 @@ #ifdef AL_CONTRACT_HAVE_MDSPLUS #include "al_contract.h" +#include "real_dd_path_catalog.h" #include #include @@ -57,44 +53,37 @@ #include #include +#include #include +#include #include using al_contract::PulseId; +using al_contract::real_dd::CatalogEntry; +using al_contract::real_dd::DType; namespace { -enum class DType { Char, Int, Double, Complex }; - -// One (type, rank) cell. `aos_chain` lists the struct_array field(s) to enter -// in order (each may itself be a multi-segment path through intervening plain -// structures, e.g. "e_field/plus" or "distribution/markers"); empty means the -// leaf hangs directly off the GLOBAL op. `terminal_gap_reason` non-null means -// the DD contains no field of this (type, rank) anywhere and the rest of the -// row is ignored. `divergence_reason` non-null means the cell round-tripped -// but not as a plain pass -- a legitimate storage-model difference empirically -// confirmed by running it (see CHAR_r0 below), not a defect (D2); the round -// trip is still attempted up to the point that confirms the divergence, then -// the test self-reports via GTEST_SKIP() rather than asserting equality. -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; +// MDSplus's own verdict for a catalog cell, layered on top of the shared path. +// `divergence_reason` non-null means the cell round-tripped but not as a plain +// pass -- a legitimate storage-model difference empirically confirmed by +// running it (see CHAR_r0 below), not a defect (D2); the round trip is +// attempted up to the point that confirms the divergence, then the test +// self-reports via GTEST_SKIP() rather than asserting equality. terminal-gap +// cells (a DD fact) come straight from the catalog entry. +struct MdsplusCell { + const CatalogEntry* entry; + const char* divergence_reason; }; -// Curated against build-mdsplus/_deps/data-dictionary-src/IDSDef.xml (DD -// 4.1.1). CHAR's rank axis is the raw C-ABI `dim` argument, which is one off -// from the DD's STR_ND suffix (IMAS convention: a *string* is CHAR dim=1, an -// *array of strings* is CHAR dim=2 -- dim=0 is a bare scalar char with no DD -// analogue of its own, reusing the same STR_0D node as dim=1). INTEGER, -// DOUBLE, and COMPLEX map dim directly to their _ND suffix. -const PathSpec kPathSpecs[] = { - // --- CHAR --------------------------------------------------------------- - {DType::Char, 0, "equilibrium", {}, "code/name", nullptr, +// The only MDSplus-specific divergence: CHAR dim=0 against a real STR_0D node. +struct Divergence { + DType dt; + int rank; + const char* reason; +}; +const Divergence kDivergences[] = { + {DType::Char, 0, "MDSplus's real STR_0D node is inherently string-shaped (1-D): reading a " "dim=0 write back reports CHAR_DATA in 1D, not 0D ('Wrong dimension of " "Data returned by backend'), confirmed empirically. Not a crash/defect " @@ -103,62 +92,29 @@ const PathSpec kPathSpecs[] = { "(the DD's smallest text type, STR_0D, is a *string*, i.e. dim=1); " "against a real DD-conformant node the backend correctly refuses the " "mismatched rank instead of silently misreading it"}, - {DType::Char, 1, "equilibrium", {}, "code/name", nullptr}, - {DType::Char, 2, "b_field_non_axisymmetric", {}, "control_surface_names", - nullptr}, - {DType::Char, 3, nullptr, {}, nullptr, - "DD 4.1.1 has no STR_2D+ at all (only STR_0D/STR_1D exist -- there is no " - "DD concept a CHAR dim>=3 could model)"}, - {DType::Char, 4, nullptr, {}, nullptr, "see rank 3"}, - {DType::Char, 5, nullptr, {}, nullptr, "see rank 3"}, - {DType::Char, 6, nullptr, {}, nullptr, "see rank 3"}, - {DType::Char, 7, nullptr, {}, nullptr, "see rank 3"}, - - // --- INTEGER -------------------------------------------------------------- - {DType::Int, 0, "amns_data", {}, "z_n", nullptr}, - {DType::Int, 1, "magnetics", {}, "code/output_flag", nullptr}, - {DType::Int, 2, "magnetics", {}, "b_field_pol_probe_equivalent", nullptr}, - {DType::Int, 3, "temporary", {"constant_integer3d"}, "value", nullptr}, - {DType::Int, 4, nullptr, {}, nullptr, - "DD 4.1.1 has no INT_4D+ anywhere (max is INT_3D, itself only inside a " - "struct_array)"}, - {DType::Int, 5, nullptr, {}, nullptr, "see rank 4"}, - {DType::Int, 6, nullptr, {}, nullptr, "see rank 4"}, - {DType::Int, 7, nullptr, {}, nullptr, "see rank 4"}, - - // --- DOUBLE ------------------------------------------------------------- - {DType::Double, 0, "amns_data", {}, "a", nullptr}, - {DType::Double, 1, "balance_of_plant", {}, "gain_plant", nullptr}, - {DType::Double, 2, "bolometer", {}, "grid/volume_element", nullptr}, - {DType::Double, 3, "bolometer", {}, "power_density/data", nullptr}, - {DType::Double, 4, "gyrokinetics_local", {}, - "non_linear/fluxes_4d/particles_phi_potential", nullptr}, - {DType::Double, 5, "gyrokinetics_local", {}, - "non_linear/fluxes_5d/particles_phi_potential", nullptr}, - {DType::Double, 6, "temporary", {"constant_float6d"}, "value", nullptr}, - {DType::Double, 7, nullptr, {}, nullptr, - "DD 4.1.1 has no FLT_7D (the DD's own array-rank ceiling for DOUBLE is " - "6, one short of MAXDIM)"}, - - // --- COMPLEX -------------------------------------------------------------- - {DType::Complex, 0, nullptr, {}, nullptr, - "DD 4.1.1 has no CPX_0D -- no scalar complex type exists at all"}, - {DType::Complex, 1, "waves", {"coherent_wave", "full_wave", "e_field/plus"}, - "values", nullptr}, - {DType::Complex, 2, "gyrokinetics_local", {}, - "non_linear/fields_zonal_2d/phi_potential_perturbed_norm", nullptr}, - {DType::Complex, 3, "runaway_electrons", {"distribution/markers"}, - "orbit_integrals_instant/values", nullptr}, - {DType::Complex, 4, "gyrokinetics_local", {}, - "non_linear/fields_4d/phi_potential_perturbed_norm", nullptr}, - {DType::Complex, 5, "runaway_electrons", {"distribution/markers"}, - "orbit_integrals/values", nullptr}, - {DType::Complex, 6, nullptr, {}, nullptr, - "DD 4.1.1 has no CPX_6D anywhere"}, - {DType::Complex, 7, nullptr, {}, nullptr, - "DD 4.1.1 has no CPX_7D anywhere"}, }; +const char* divergence_for(DType dt, int rank) { + for (const Divergence& d : kDivergences) { + if (d.dt == dt && d.rank == rank) return d.reason; + } + return nullptr; +} + +// Derive the MDSplus matrix 1:1 from the shared catalog: exactly one cell per +// catalog entry, in catalog order, with MDSplus's local verdict attached. The +// CoversEveryCatalogKeyOnce meta-test pins that this mapping stays a bijection. +const std::vector& mdsplus_cells() { + static const std::vector kCells = [] { + std::vector cells; + for (const CatalogEntry& e : al_contract::real_dd::catalog()) { + cells.push_back({&e, divergence_for(e.dt, e.rank)}); + } + return cells; + }(); + return kCells; +} + // --- write/read a leaf field at whatever context it lives in ---------------- template void write_leaf(int ctx, const char* leaf, const std::vector& shape, @@ -184,7 +140,7 @@ void read_leaf_and_expect(int ctx, const char* leaf, int rank, // (if any, one al_begin_arraystruct_action per segment, a single element // each), write the leaf, read it back, assert self-consistency. template -void run_cell(const PathSpec& spec) { +void run_cell(const CatalogEntry& spec) { al_contract::TempBase base; PulseId pulse{/*database=*/"test", /*version=*/"3", /*pulse=*/15, /*run=*/0}; @@ -256,18 +212,19 @@ void run_cell(const PathSpec& spec) { EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); } -class MdsplusRealPathMatrix : public ::testing::TestWithParam { +class MdsplusRealPathMatrix : public ::testing::TestWithParam { protected: void SetUp() override { AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); } }; TEST_P(MdsplusRealPathMatrix, RealPathRoundTrip) { - const PathSpec spec = GetParam(); + const MdsplusCell cell = GetParam(); + const CatalogEntry& spec = *cell.entry; 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 (cell.divergence_reason != nullptr) { + GTEST_SKIP() << "divergence: " << cell.divergence_reason; } switch (spec.dt) { case DType::Char: @@ -288,19 +245,39 @@ TEST_P(MdsplusRealPathMatrix, RealPathRoundTrip) { // 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). +// nesting, not brace nesting). Case names come from the shared catalog so they +// stay stable across the #46 extraction (e.g. "DOUBLE_r3"). std::string NameMdsplusRealPathMatrixCase( - const ::testing::TestParamInfo& info) { - 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); + const ::testing::TestParamInfo& info) { + return al_contract::real_dd::case_name(*info.param.entry); } INSTANTIATE_TEST_SUITE_P(Mdsplus, MdsplusRealPathMatrix, - ::testing::ValuesIn(kPathSpecs), + ::testing::ValuesIn(mdsplus_cells()), NameMdsplusRealPathMatrixCase); +// Meta-test (issue #46): the MDSplus matrix maps the shared catalog 1:1 -- one +// cell per catalog key, no duplicate, none dropped. A future refactor that +// filters cells out of mdsplus_cells() turns this red instead of silently +// shrinking the matrix. +TEST(MdsplusRealPathCatalog, CoversEveryCatalogKeyOnce) { + using Key = std::pair; + std::set matrix_keys; + for (const MdsplusCell& cell : mdsplus_cells()) { + ASSERT_NE(cell.entry, nullptr); + const Key k{static_cast(cell.entry->dt), cell.entry->rank}; + EXPECT_TRUE(matrix_keys.insert(k).second) + << "duplicate matrix key " + << al_contract::real_dd::case_name(*cell.entry); + } + std::set catalog_keys; + for (const CatalogEntry& e : al_contract::real_dd::catalog()) { + catalog_keys.insert({static_cast(e.dt), e.rank}); + } + EXPECT_EQ(matrix_keys, catalog_keys) + << "MDSplus matrix and catalog key sets diverge"; +} + } // namespace #endif // AL_CONTRACT_HAVE_MDSPLUS diff --git a/tests/contract/test_real_dd_path_catalog.cpp b/tests/contract/test_real_dd_path_catalog.cpp new file mode 100644 index 00000000..64535e98 --- /dev/null +++ b/tests/contract/test_real_dd_path_catalog.cpp @@ -0,0 +1,82 @@ +// Always-on meta-tests for the shared real-DD-path catalog (issue #46). +// +// These guard the catalog's own invariants (one verdict per cell, no duplicate +// keys) independently of any backend. The per-backend "the matrix covers every +// catalog key" meta-tests live in test_mdsplus_real_paths.cpp and +// test_uda_real_paths.cpp behind their compile gates, so a matrix cannot drop a +// cell without a red test on that backend. + +#include "real_dd_path_catalog.h" + +#include + +#include +#include + +namespace { + +using al_contract::real_dd::catalog; +using al_contract::real_dd::CatalogEntry; +using al_contract::real_dd::DType; +using al_contract::real_dd::kMaxRank; +using al_contract::real_dd::kMinRank; +using al_contract::real_dd::kNumCells; +using al_contract::real_dd::kNumDTypes; + +using Key = std::pair; // (datatype ordinal, rank) + +Key key_of(const CatalogEntry& e) { + return {static_cast(e.dt), e.rank}; +} + +// The catalog defines exactly one verdict for every (datatype, rank) cell in +// the swept space, with no duplicate and no missing key. This is the shared +// half of issue #46's "exactly one verdict for every catalog key, no +// duplicates" acceptance criterion; the per-backend halves pin that each +// matrix maps this same key set 1:1. +TEST(RealDdPathCatalog, CoversEveryCellExactlyOnce) { + std::set seen; + for (const CatalogEntry& e : catalog()) { + EXPECT_TRUE(seen.insert(key_of(e)).second) + << "duplicate catalog key: dtype=" << static_cast(e.dt) + << " rank=" << e.rank; + } + EXPECT_EQ(catalog().size(), static_cast(kNumCells)); + + // Every cell in the swept space is present. + for (int d = 0; d < kNumDTypes; ++d) { + for (int r = kMinRank; r <= kMaxRank; ++r) { + EXPECT_TRUE(seen.count({d, r}) == 1) + << "missing catalog key: dtype=" << d << " rank=" << r; + } + } +} + +// A terminal-gap cell carries its DD-fact reason and no path; a real-path cell +// carries a path and no gap reason. The two are mutually exclusive, so neither +// backend suite can misread a half-populated entry. +TEST(RealDdPathCatalog, PathAndTerminalGapAreMutuallyExclusive) { + for (const CatalogEntry& e : catalog()) { + const bool is_gap = e.terminal_gap_reason != nullptr; + if (is_gap) { + EXPECT_EQ(e.ids, nullptr) + << "terminal-gap cell " << al_contract::real_dd::case_name(e) + << " must not name an IDS"; + EXPECT_EQ(e.leaf, nullptr) + << "terminal-gap cell " << al_contract::real_dd::case_name(e) + << " must not name a leaf"; + EXPECT_TRUE(e.aos_chain.empty()) + << "terminal-gap cell " << al_contract::real_dd::case_name(e) + << " must not carry an AOS chain"; + } else { + EXPECT_NE(e.ids, nullptr) + << "real-path cell " << al_contract::real_dd::case_name(e) + << " must name an IDS"; + EXPECT_NE(e.leaf, nullptr) + << "real-path cell " << al_contract::real_dd::case_name(e) + << " must name a leaf"; + } + } +} + +} // namespace diff --git a/tests/contract/test_uda_real_paths.cpp b/tests/contract/test_uda_real_paths.cpp index 658e7645..20d647c7 100644 --- a/tests/contract/test_uda_real_paths.cpp +++ b/tests/contract/test_uda_real_paths.cpp @@ -1,13 +1,13 @@ // 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. +// This is the UDA analogue of test_mdsplus_real_paths.cpp (issue #15): the same +// curated real-DD-4.1.1 path set, now the shared real_dd::catalog() (issue +// #46). UDA hits the identical structural wall MDSplus does -- RoundTripMatrix's +// opaque synthetic paths have no node in the schema either backend validates +// against (MDSplus: a baked binary model tree; UDA: the DD XML walked at +// runtime, src/uda/uda_xml.cpp find_node()). See real_dd_path_catalog.h for the +// curation method. // // Shape adaptation for UDA (PRD #21 / issue #24): UDA has no remote write, so // each cell is seed(HDF5)-then-reopen(UDA) instead of MDSplus's direct @@ -15,18 +15,23 @@ // * 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 +// then the leaf (descending the catalog aos_chain via // al_begin_arraystruct_action, exactly like the MDSplus fixture); // * reopen: OPEN_PULSE via the UDA backend in remote mode // (backend=hdf5&cache_mode=none), descend the same aos_chain, read the // leaf back, assert self-consistency against what was written. // -// 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). +// The path set is shared with MDSplus; only UDA's own verdicts stay here: +// kDivergences (CHAR r0's fixture-backend limitation) and kKnownDefects (the +// dynamic-leaf-inside-AOS defect pinned by UdaRealPathMatrixKnownDefects). +// +// Oracle: per-backend write->read self-consistency (same as MDSplus's version +// of this matrix) -- only the field *path* is real DD; the *content* stays +// opaque synthetic data (al_contract::synth_buffer). #ifdef AL_CONTRACT_HAVE_UDA #include "al_contract.h" +#include "real_dd_path_catalog.h" #include #include @@ -35,140 +40,103 @@ #include #include +#include #include +#include #include +using al_contract::real_dd::CatalogEntry; +using al_contract::real_dd::DType; + namespace { -enum class DType { Char, Int, Double, Complex }; - -// Same shape as test_mdsplus_real_paths.cpp's PathSpec. `terminal_gap_reason` -// is shared verbatim with the MDSplus matrix (it is a fact about DD 4.1.1 -// itself, not about either backend). `divergence_reason` is UDA-specific -- -// re-derived empirically against the reference stack, not copied from -// MDSplus's own divergence rows, since the two backends can diverge for -// different reasons on the same cell. -struct PathSpec { - DType dt; - int rank; - const char* ids; - std::vector aos_chain; - const char* leaf; - const char* terminal_gap_reason; - const char* divergence_reason = nullptr; - // Non-null iff the cell hits the genuine, newly-discovered defect pinned - // by UdaAosKnownDefects (test_uda_breadth.cpp): a *dynamic* (time-varying) - // leaf nested inside a struct_array silently comes back empty/absent - // through UDA remote mode + backend=hdf5, instead of what was written. - // Distinct from divergence_reason -- this is a real bug (xfail), not a - // legitimate storage-model difference; the cell is skipped in the breadth - // matrix (not run into the known-wrong assertion) and pinned exactly by - // UdaRealPathMatrixKnownDefects's DISABLED_/tripwire pair below. - const char* known_defect_reason = nullptr; +// UDA's own verdict for a catalog cell, layered on the shared path. +// `divergence_reason` non-null: a legitimate storage/fixture-model difference, +// skipped (not asserted). `known_defect_reason` non-null: the genuine, +// newly-discovered defect pinned by UdaAosKnownDefects (test_uda_breadth.cpp) +// -- a *dynamic* (time-varying) leaf nested inside a struct_array silently +// comes back empty/absent through UDA remote mode + backend=hdf5. Distinct from +// a divergence: this is a real bug (xfail), skipped in the breadth matrix and +// pinned exactly by UdaRealPathMatrixKnownDefects's DISABLED_/tripwire pair. +struct UdaCell { + const CatalogEntry* entry; + const char* divergence_reason; + const char* known_defect_reason; }; -// Keep the three exact known-defect cells as named specs so the breadth matrix -// and the DISABLED_/tripwire pair below exercise identical IDS paths, ranks, -// and AOS nesting without duplicating that configuration. -const PathSpec kComplexR1DynamicAosSpec{ - DType::Complex, 1, "waves", {"coherent_wave", "full_wave", "e_field/plus"}, - "values", nullptr, nullptr, - "dynamic leaf nested in a struct_array (3 AOS levels)"}; -const PathSpec kComplexR3DynamicAosSpec{ - DType::Complex, 3, "runaway_electrons", {"distribution/markers"}, - "orbit_integrals_instant/values", nullptr, nullptr, - "dynamic leaf nested in a struct_array (1 AOS level)"}; -const PathSpec kComplexR5DynamicAosSpec{ - DType::Complex, 5, "runaway_electrons", {"distribution/markers"}, - "orbit_integrals/values", nullptr, nullptr, - "dynamic leaf nested in a struct_array (1 AOS level)"}; - -// Identical path/rank/aos_chain curation to test_mdsplus_real_paths.cpp's -// kPathSpecs (issue #25 acceptance criterion 2: "curated real-path set reused -// as-is"). terminal_gap_reason carries over unchanged (a DD fact). CHAR r0's -// divergence_reason is UDA-specific -- see below. -const PathSpec kPathSpecs[] = { - // --- CHAR --------------------------------------------------------------- - // Skipped WITHOUT attempting the seed write: seeding this cell means - // writing a dim=0 (bare scalar) CHAR value through the plain HDF5 - // backend used for fixture setup, which is the pre-existing, - // independently-pinned HDF5 CHAR-scalar crash - // (RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips / tripwire - // …CurrentlyCrashes, TRACEABILITY.md Part 1) -- confirmed to reproduce on - // a real DD leaf too (equilibrium's code/name, STR_0D), not just the - // synthetic path that test already pins. This is a fixture-setup-backend - // defect, not a UDA behavior, so it is recorded as a divergence (not - // exercised) rather than run into the crash. - {DType::Char, 0, "equilibrium", {}, "code/name", nullptr, +// UDA-specific verdicts keyed by (dtype, rank). Distinct from MDSplus's: +// the two backends diverge for different reasons on the same cell. +struct Verdict { + DType dt; + int rank; + const char* divergence_reason; + const char* known_defect_reason; +}; +const Verdict kVerdicts[] = { + // CHAR r0: seeding needs a dim=0 (bare scalar) CHAR write through the plain + // HDF5 backend used for fixture setup -- the pre-existing, independently- + // pinned HDF5 CHAR-scalar crash (RoundTripKnownDefects.DISABLED_ + // Hdf5CharScalarRoundTrips / tripwire …CurrentlyCrashes, TRACEABILITY.md + // Part 1), confirmed to reproduce on this real DD leaf (equilibrium's + // code/name, STR_0D) too. A fixture-backend defect, not a UDA behavior, so + // it is recorded as a divergence (not exercised) rather than run into the + // crash. + {DType::Char, 0, "seeding this cell needs a dim=0 CHAR write through the plain HDF5 " "backend (fixture setup) -- the pre-existing, independently-pinned " "HDF5 CHAR-scalar crash (RoundTripKnownDefects.DISABLED_" "Hdf5CharScalarRoundTrips / tripwire …CurrentlyCrashes, TRACEABILITY.md " "Part 1) reproduces on this real DD leaf too; not run to avoid crashing " - "the seed step. A fixture-backend defect, not a UDA behavior"}, - {DType::Char, 1, "equilibrium", {}, "code/name", nullptr}, - {DType::Char, 2, "b_field_non_axisymmetric", {}, "control_surface_names", + "the seed step. A fixture-backend defect, not a UDA behavior", nullptr}, - {DType::Char, 3, nullptr, {}, nullptr, - "DD 4.1.1 has no STR_2D+ at all (only STR_0D/STR_1D exist -- there is no " - "DD concept a CHAR dim>=3 could model)"}, - {DType::Char, 4, nullptr, {}, nullptr, "see rank 3"}, - {DType::Char, 5, nullptr, {}, nullptr, "see rank 3"}, - {DType::Char, 6, nullptr, {}, nullptr, "see rank 3"}, - {DType::Char, 7, nullptr, {}, nullptr, "see rank 3"}, - - // --- INTEGER -------------------------------------------------------------- - {DType::Int, 0, "amns_data", {}, "z_n", nullptr}, - {DType::Int, 1, "magnetics", {}, "code/output_flag", nullptr}, - {DType::Int, 2, "magnetics", {}, "b_field_pol_probe_equivalent", nullptr}, - {DType::Int, 3, "temporary", {"constant_integer3d"}, "value", nullptr}, - {DType::Int, 4, nullptr, {}, nullptr, - "DD 4.1.1 has no INT_4D+ anywhere (max is INT_3D, itself only inside a " - "struct_array)"}, - {DType::Int, 5, nullptr, {}, nullptr, "see rank 4"}, - {DType::Int, 6, nullptr, {}, nullptr, "see rank 4"}, - {DType::Int, 7, nullptr, {}, nullptr, "see rank 4"}, - - // --- DOUBLE ------------------------------------------------------------- - {DType::Double, 0, "amns_data", {}, "a", nullptr}, - {DType::Double, 1, "balance_of_plant", {}, "gain_plant", nullptr}, - {DType::Double, 2, "bolometer", {}, "grid/volume_element", nullptr}, - {DType::Double, 3, "bolometer", {}, "power_density/data", nullptr}, - {DType::Double, 4, "gyrokinetics_local", {}, - "non_linear/fluxes_4d/particles_phi_potential", nullptr}, - {DType::Double, 5, "gyrokinetics_local", {}, - "non_linear/fluxes_5d/particles_phi_potential", nullptr}, - {DType::Double, 6, "temporary", {"constant_float6d"}, "value", nullptr}, - {DType::Double, 7, nullptr, {}, nullptr, - "DD 4.1.1 has no FLT_7D (the DD's own array-rank ceiling for DOUBLE is " - "6, one short of MAXDIM)"}, - - // --- COMPLEX -------------------------------------------------------------- - {DType::Complex, 0, nullptr, {}, nullptr, - "DD 4.1.1 has no CPX_0D -- no scalar complex type exists at all"}, - // Known defect (xfail), not attempted: waves/coherent_wave/full_wave/ - // e_field/plus/values is a DD *dynamic* leaf 3 levels deep in nested - // struct_arrays -- confirmed to hit UdaAosKnownDefects's dynamic-leaf- - // inside-AOS defect (test_uda_breadth.cpp): the field comes back - // empty/absent through UDA remote mode + backend=hdf5 instead of what - // was written. - kComplexR1DynamicAosSpec, - {DType::Complex, 2, "gyrokinetics_local", {}, - "non_linear/fields_zonal_2d/phi_potential_perturbed_norm", nullptr}, - // Same known defect as COMPLEX r1 above: orbit_integrals_instant/values - // is a dynamic leaf nested one level inside runaway_electrons/ - // distribution/markers. - kComplexR3DynamicAosSpec, - {DType::Complex, 4, "gyrokinetics_local", {}, - "non_linear/fields_4d/phi_potential_perturbed_norm", nullptr}, - // Same known defect as COMPLEX r1/r3 above. - kComplexR5DynamicAosSpec, - {DType::Complex, 6, nullptr, {}, nullptr, - "DD 4.1.1 has no CPX_6D anywhere"}, - {DType::Complex, 7, nullptr, {}, nullptr, - "DD 4.1.1 has no CPX_7D anywhere"}, + // The three dynamic-leaf-inside-AOS known defects (xfail): the field comes + // back empty/absent through UDA remote mode + backend=hdf5 instead of what + // was written. Pinned by UdaRealPathMatrixKnownDefects below. + {DType::Complex, 1, nullptr, + "dynamic leaf nested in a struct_array (3 AOS levels)"}, + {DType::Complex, 3, nullptr, + "dynamic leaf nested in a struct_array (1 AOS level)"}, + {DType::Complex, 5, nullptr, + "dynamic leaf nested in a struct_array (1 AOS level)"}, }; +const Verdict* verdict_for(DType dt, int rank) { + for (const Verdict& v : kVerdicts) { + if (v.dt == dt && v.rank == rank) return &v; + } + return nullptr; +} + +// Derive the UDA matrix 1:1 from the shared catalog: one cell per catalog +// entry, in catalog order, with UDA's local verdict attached. The +// CoversEveryCatalogKeyOnce meta-test pins that this stays a bijection. +const std::vector& uda_cells() { + static const std::vector kCells = [] { + std::vector cells; + for (const CatalogEntry& e : al_contract::real_dd::catalog()) { + const Verdict* v = verdict_for(e.dt, e.rank); + cells.push_back({&e, v ? v->divergence_reason : nullptr, + v ? v->known_defect_reason : nullptr}); + } + return cells; + }(); + return kCells; +} + +// The subset of cells carrying the dynamic-leaf-inside-AOS known defect, for +// the DISABLED_/tripwire pair below. Derived from the same uda_cells() so the +// matrix and the pins exercise identical IDS paths, ranks, and AOS nesting. +const std::vector& uda_known_defect_cells() { + static const std::vector kCells = [] { + std::vector cells; + for (const UdaCell& c : uda_cells()) { + if (c.known_defect_reason != nullptr) cells.push_back(c); + } + return cells; + }(); + return kCells; +} + // --- write/read a leaf field at whatever context it lives in ---------------- template void write_leaf(int ctx, const char* leaf, const std::vector& shape, @@ -196,7 +164,7 @@ void read_leaf_and_expect(int ctx, const char* leaf, int rank, // run_cell), then reopen the identical pulse dir through the UDA backend in // remote mode and read the same path back. template -void run_cell_with_read_expectation(const PathSpec& spec, +void run_cell_with_read_expectation(const CatalogEntry& spec, ReadExpectation&& expect_read) { al_contract::TempBase base; const std::string pulse_dir = base.str() + "/pulse"; @@ -277,9 +245,9 @@ void run_cell_with_read_expectation(const PathSpec& spec, } template -void run_cell(const PathSpec& spec) { +void run_cell(const CatalogEntry& spec) { run_cell_with_read_expectation( - spec, [](int ctx, const PathSpec& read_spec, + spec, [](int ctx, const CatalogEntry& read_spec, const std::vector& shape, const std::vector& written) { read_leaf_and_expect(ctx, read_spec.leaf, read_spec.rank, shape, @@ -287,21 +255,22 @@ void run_cell(const PathSpec& spec) { }); } -class UdaRealPathMatrix : public ::testing::TestWithParam { +class UdaRealPathMatrix : public ::testing::TestWithParam { protected: void SetUp() override { AL_CONTRACT_SKIP_IF_UDA_UNCONFIGURED(); } }; TEST_P(UdaRealPathMatrix, RealPathRoundTrip) { - const PathSpec spec = GetParam(); + const UdaCell cell = GetParam(); + const CatalogEntry& spec = *cell.entry; 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 (cell.divergence_reason != nullptr) { + GTEST_SKIP() << "divergence: " << cell.divergence_reason; } - if (spec.known_defect_reason != nullptr) { - GTEST_SKIP() << "known-defect (xfail): " << spec.known_defect_reason; + if (cell.known_defect_reason != nullptr) { + GTEST_SKIP() << "known-defect (xfail): " << cell.known_defect_reason; } switch (spec.dt) { case DType::Char: @@ -322,40 +291,61 @@ TEST_P(UdaRealPathMatrix, RealPathRoundTrip) { // 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). +// nesting, not brace nesting). Case names come from the shared catalog so they +// stay stable across the #46 extraction (e.g. "COMPLEX_r1"). std::string NameUdaRealPathMatrixCase( - const ::testing::TestParamInfo& info) { - 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); + const ::testing::TestParamInfo& info) { + return al_contract::real_dd::case_name(*info.param.entry); } -INSTANTIATE_TEST_SUITE_P(Uda, UdaRealPathMatrix, ::testing::ValuesIn(kPathSpecs), +INSTANTIATE_TEST_SUITE_P(Uda, UdaRealPathMatrix, + ::testing::ValuesIn(uda_cells()), 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 +// Meta-test (issue #46): the UDA matrix maps the shared catalog 1:1 -- one cell +// per catalog key, no duplicate, none dropped. A future refactor that filters +// cells out of uda_cells() turns this red instead of silently shrinking the +// matrix. +TEST(UdaRealPathCatalog, CoversEveryCatalogKeyOnce) { + using Key = std::pair; + std::set matrix_keys; + for (const UdaCell& cell : uda_cells()) { + ASSERT_NE(cell.entry, nullptr); + const Key k{static_cast(cell.entry->dt), cell.entry->rank}; + EXPECT_TRUE(matrix_keys.insert(k).second) + << "duplicate matrix key " + << al_contract::real_dd::case_name(*cell.entry); + } + std::set catalog_keys; + for (const CatalogEntry& e : al_contract::real_dd::catalog()) { + catalog_keys.insert({static_cast(e.dt), e.rank}); + } + EXPECT_EQ(matrix_keys, catalog_keys) + << "UDA matrix and catalog key sets diverge"; +} + +// Exact D2 pins for the COMPLEX matrix cells affected by the dynamic-leaf- +// inside-AOS defect. The disabled correct-contract test and enabled // current-behavior tripwire deliberately share run_cell_with_read_expectation, // so both traverse the same public C ABI and the same real DD path. A targeted // fix to any one rank/path makes that case's tripwire fail independently. class UdaRealPathMatrixKnownDefects - : public ::testing::TestWithParam { + : public ::testing::TestWithParam { protected: void SetUp() override { AL_CONTRACT_SKIP_IF_UDA_UNCONFIGURED(); } }; TEST_P(UdaRealPathMatrixKnownDefects, DISABLED_DynamicComplexLeafInsideAosRoundTrips) { - run_cell>(GetParam()); + run_cell>(*GetParam().entry); } TEST_P(UdaRealPathMatrixKnownDefects, DynamicComplexLeafInsideAosCurrentlyReadsEmpty) { run_cell_with_read_expectation>( - GetParam(), [](int ctx, const PathSpec& spec, - const std::vector&, - const std::vector>&) { + *GetParam().entry, [](int ctx, const CatalogEntry& spec, + const std::vector&, + const std::vector>&) { std::vector read_shape; std::vector> read_data; AL_EXPECT_OK(al_contract::read_data>( @@ -368,16 +358,9 @@ TEST_P(UdaRealPathMatrixKnownDefects, }); } -const PathSpec kComplexDynamicAosKnownDefectSpecs[] = { - kComplexR1DynamicAosSpec, - kComplexR3DynamicAosSpec, - kComplexR5DynamicAosSpec, -}; - -INSTANTIATE_TEST_SUITE_P( - Uda, UdaRealPathMatrixKnownDefects, - ::testing::ValuesIn(kComplexDynamicAosKnownDefectSpecs), - NameUdaRealPathMatrixCase); +INSTANTIATE_TEST_SUITE_P(Uda, UdaRealPathMatrixKnownDefects, + ::testing::ValuesIn(uda_known_defect_cells()), + NameUdaRealPathMatrixCase); } // namespace From cd20a87247cd2c10cec2a4a1fda75e957b405f0e Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:29:56 +0200 Subject: [PATCH 61/91] Make UDA AOS traversal detect cursor no-ops Use a static DD-4.1.1 AOS leaf with three index-bound values so repeated or reordered traversal fails through the public C ABI. The old dynamic-leaf sentinel tripwire observed identical values and could not distinguish a successful no-op iterator. --- tests/contract/TRACEABILITY.md | 2 +- tests/contract/test_uda_breadth.cpp | 110 +++++++++++++++++++++++----- 2 files changed, 91 insertions(+), 21 deletions(-) diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index f51e181c..1664025a 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -733,7 +733,7 @@ fails if the UDA matrix ever drops or duplicates a catalog cell. | ↳ 13 (type, rank) cells the DD contains nowhere at any nesting depth (same facts as MDSplus's Part 4 breadth matrix): CHAR r3–r7, INTEGER r4–r7, DOUBLE r7, COMPLEX r0, COMPLEX r6–r7 | `Uda/UdaRealPathMatrix.RealPathRoundTrip/{CHAR_r3..CHAR_r7,INTEGER_r4..INTEGER_r7,DOUBLE_r7,COMPLEX_r0,COMPLEX_r6,COMPLEX_r7}` (13 cases, each `GTEST_SKIP()`) | terminal-gap | | ↳ 3 (type, rank) cells hit the new dynamic-leaf-inside-AOS defect: COMPLEX r1 (`waves`, 3 AOS levels), COMPLEX r3/r5 (`runaway_electrons`, 1 AOS level) | Breadth cells: `Uda/UdaRealPathMatrix.RealPathRoundTrip/{COMPLEX_r1,COMPLEX_r3,COMPLEX_r5}` (`GTEST_SKIP()`, known-defect). Exact correct-contract pair: `Uda/UdaRealPathMatrixKnownDefects.DISABLED_DynamicComplexLeafInsideAosRoundTrips/{COMPLEX_r1,COMPLEX_r3,COMPLEX_r5}` + tripwires `Uda/UdaRealPathMatrixKnownDefects.DynamicComplexLeafInsideAosCurrentlyReadsEmpty/{COMPLEX_r1,COMPLEX_r3,COMPLEX_r5}` | **xfail** | -## AOS traversal, occurrences, list_filled_paths, pulse modes/errors, slice/time-range (issue #25) +## AOS traversal, occurrences, list_filled_paths, pulse modes/errors, slice/time-range (issues #25 + #37) `test_uda_breadth.cpp` — every remaining C-ABI-reachable read-side capability and pulse-lifecycle mode from `FUNCTIONALITY_INVENTORY.md` Part 1 not already diff --git a/tests/contract/test_uda_breadth.cpp b/tests/contract/test_uda_breadth.cpp index ad6ae01b..2b98f140 100644 --- a/tests/contract/test_uda_breadth.cpp +++ b/tests/contract/test_uda_breadth.cpp @@ -79,26 +79,96 @@ class UdaBreadthTest : public ::testing::Test { }; // =========================================================================== -// 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). +// AOS traversal (top-level): temporary/constant_integer3d is a real DD-4.1.1 +// struct_array whose static `value` leaf already round-trips through UDA (the +// INTEGER_r3 real-path matrix cell). Three order-sensitive elements make this +// an active C-ABI proof that al_iterate_over_arraystruct advances the cursor: +// a successful no-op, repeated element, or reordered element all fail. +// =========================================================================== + +TEST_F(UdaBreadthTest, AosTraversalAdvancesAcrossDistinctStaticElements) { + const std::string pulse_dir = fresh_pulse_dir(); + constexpr int kN = 3; + const std::vector kShape = {1, 1, 1}; + const std::vector> kExpected = {{101}, {202}, {303}}; + + // --- seed via HDF5 ------------------------------------------------------- + { + const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(hdf5_uri.c_str(), + FORCE_CREATE_PULSE, &pulse_ctx)); + + int op = -1; + AL_ASSERT_OK( + al_begin_global_action(pulse_ctx, "temporary", "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data( + op, "ids_properties/homogeneous_time", {}, {1})); + + int size = kN; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action( + op, "constant_integer3d", "", &size, &aos)); + // Seed physical indexes in 0, 2, 1 order so fixture preparation does + // not mirror the forward 0, 1, 2 traversal asserted below. This makes + // a reordered read observable even if iteration is changed globally. + AL_EXPECT_OK(al_contract::write_data(aos, "value", kShape, + kExpected[0])); + AL_ASSERT_OK(al_iterate_over_arraystruct(aos, 2)); + AL_EXPECT_OK(al_contract::write_data(aos, "value", kShape, + kExpected[2])); + AL_ASSERT_OK(al_iterate_over_arraystruct(aos, -1)); + AL_EXPECT_OK(al_contract::write_data(aos, "value", kShape, + kExpected[1])); + AL_EXPECT_OK(al_end_action(aos)); + AL_EXPECT_OK(al_end_action(op)); + AL_ASSERT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } + + // --- reopen through UDA, remote mode ------------------------------------ + { + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK( + al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, &pulse_ctx)); + + int op = -1; + AL_ASSERT_OK( + al_begin_global_action(pulse_ctx, "temporary", "", READ_OP, &op)); + + int size = 0; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action( + op, "constant_integer3d", "", &size, &aos)); + ASSERT_EQ(size, kN) << "AOS size must round-trip through UDA"; + for (int i = 0; i < size; ++i) { + SCOPED_TRACE(i); + std::vector shape; + std::vector data; + AL_ASSERT_OK( + al_contract::read_data(aos, "value", 3, &shape, &data)); + EXPECT_EQ(shape, kShape); + EXPECT_EQ(data, kExpected[static_cast(i)]) + << "AOS cursor must preserve element order"; + if (i + 1 < size) { + AL_ASSERT_OK(al_iterate_over_arraystruct(aos, 1)); + } + } + AL_ASSERT_OK(al_end_action(aos)); + + // The public C ABI promises a failure status for an invalid context. + EXPECT_NE(al_iterate_over_arraystruct(aos, 1).code, 0); + + AL_EXPECT_OK(al_end_action(op)); + AL_ASSERT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); + } +} + +// =========================================================================== +// Separate value-transport defect pin: equilibrium/time_slice is a real DD +// struct_array with a dynamic global_quantities/ip leaf. Its size and request +// paths advance, but UDA returns the HDF5 absent sentinel for every element. +// Keep this D2 pair independent of the discriminating traversal test above. // =========================================================================== // Seed 3 elements of equilibrium/time_slice via HDF5 (global_quantities/ip = From a39e680acf00ed7e9ddf0a3087ebf695ce889d9f Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:52:01 +0200 Subject: [PATCH 62/91] Document UDA cache invalidation terminal gap --- tests/contract/TRACEABILITY.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 1664025a..e2cf0495 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -912,12 +912,23 @@ recorded here alongside the header's existing stack identifiers. 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: + `ids`-specific finding despite living in the cache-mode area. Pinned: `UdaUniqueSurfaceTest.DISABLED_ClosingUdaSessionReleasesEveryServerSideHandle` + tripwire `UdaUniqueSurfaceTest.ClosingUdaSessionCurrentlyLeaksServerSideHandle`. +- **Client-cache invalidation across close/reopen is a terminal gap (issue + #38).** No stable public-C-ABI oracle exists against the pinned stack: the + required seed-A/read/close/mutate-to-B/reopen sequence cannot reach its + mutation step because the separately pinned server-handle leak keeps the + per-IDS HDF5 file locked. A fresh fixture process with + `HDF5_USE_FILE_LOCKING=FALSE` still fails to reopen that external IDS file + for writing (observed for `cache_mode=none`); the process therefore cannot + produce B for a new UDA context to observe. The C ABI provides no control to + restart the reference server/connection between those steps, and remote UDA + writes are independently unsupported. Do not treat + `UDABackend::closePulse`'s `cache_.clear()` as a behavioral verdict. The + paired server-handle-leak xfail immediately above remains distinct; when it + is fixed or the stack gains a restart seam, this row must become the + A→B→reopen test for `none`, `ids`, and `struct`. - **Runtime DD loading — present / absent / wrong-version, all through `al_begin_dataentry_action`** (the constructor loads `IDSDef.xml` unconditionally, before any network access): present is the reference @@ -1041,6 +1052,7 @@ recorded here alongside the header's existing stack identifiers. | Cache-mode invisibility: `none`/`ids`/`struct` agree on a field all three can reach (a static AOS-nested leaf) | `UdaUniqueSurfaceTest.CacheModeNoneIdsStructAgreeForAosCoveredField` | covered | | ↳ `struct` mode reports success but returns the absent sentinel for a genuinely written top-level field, so cache-mode selection changes the physics value | `UdaUniqueSurfaceTest.DISABLED_CacheModeStructPreservesTopLevelField` + tripwire `UdaUniqueSurfaceTest.CacheModeStructCurrentlyReturnsAbsentSentinelForTopLevelField` | **xfail** | | ↳ closing a UDA session leaks a server-side pulse handle (uri-stripping mismatch between `open`/`close` and every `get()`-directive builder), blocking a later local reopen of the same file | `UdaUniqueSurfaceTest.DISABLED_ClosingUdaSessionReleasesEveryServerSideHandle` + tripwire `UdaUniqueSurfaceTest.ClosingUdaSessionCurrentlyLeaksServerSideHandle` | **xfail** | +| ↳ client cache invalidation across close/reopen: the required A→B fixture mutation is blocked by that distinct leaked server handle; neither the C ABI nor the pinned stack offers a server/connection restart seam | issue #38's attempted C-ABI fixture mutation (fresh process, `HDF5_USE_FILE_LOCKING=FALSE`) deterministically fails before B can be written; do not infer a verdict from source inspection | **terminal gap** | | Runtime DD loading: present baseline and absent DD (both failure messages) | `UdaUniqueSurfaceTest.{DdPresentLoadsAndOpenSucceeds,DdAbsentNeitherEnvVarSetFailsWithClearMessage,DdAbsentFileMissingAtIdsDefPathFailsWithClearMessage}` | covered | | ↳ wrong-version DD loads silently with no semantic cross-version check | `UdaUniqueSurfaceTest.DISABLED_DdWrongVersionIsRejected` + tripwire `UdaUniqueSurfaceTest.DdWrongVersionCurrentlyLoadsSilentlyWithNoCrossVersionCheck` | **xfail** | | `datapath` partial-get via `cache_mode=ids`: in-scope field round-trips, out-of-scope field silently reads as absent | `UdaUniqueSurfaceTest.DatapathScopesCachePopulationFieldOutsideScopeReadsAsAbsent` | covered | From 92f84661bb1570742f16d5fb16d2bfe930edd021 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:18:19 +0200 Subject: [PATCH 63/91] Strengthen UDA timerange capability assertion --- tests/contract/TRACEABILITY.md | 14 ++++++++------ tests/contract/test_uda_unique_surface.cpp | 22 +++++++++------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index e2cf0495..f8eafcc3 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -985,11 +985,12 @@ recorded here alongside the header's existing stack identifiers. with baseline `UdaUniqueSurfaceTest.MatchingStoredVersionOpensThroughUda`. - **`supportsTimeRangeOperation()` capability negotiation confirmed against the reference server**: the reference plugin reports `1.8.0` (issue #23), - so `1.8.0 > 1.4.0` grants the capability — `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). + so `1.8.0 > 1.4.0` grants the capability — + `UdaUniqueSurfaceTest.TimeRangeCapabilityGrantedByReferenceServerVersion180` + asserts the exact begin-action status, `s.code == 0`, rather than merely + excluding the capability-refusal text. The separately pinned + uninitialized-interpmode defect occurs only on the subsequent read + (`UdaSliceAndTimeRange`, above). - **Fetch mode: download, local-backend handoff, cache reuse, all confirmed end-to-end (issue #27)** — the `BYTES` server plugin fetch mode needs ships and is registered **by default** in `ukaea/uda`'s own build (corrects this @@ -1057,7 +1058,8 @@ recorded here alongside the header's existing stack identifiers. | ↳ wrong-version DD loads silently with no semantic cross-version check | `UdaUniqueSurfaceTest.DISABLED_DdWrongVersionIsRejected` + tripwire `UdaUniqueSurfaceTest.DdWrongVersionCurrentlyLoadsSilentlyWithNoCrossVersionCheck` | **xfail** | | `datapath` partial-get via `cache_mode=ids`: in-scope field round-trips, out-of-scope field silently reads as absent | `UdaUniqueSurfaceTest.DatapathScopesCachePopulationFieldOutsideScopeReadsAsAbsent` | covered | | Version-drift check inertness: the client's own drift check never fires (both sides hardcoded placeholders); a pulse whose stored backend version (`999.0`, rewritten out-of-band by `hdf5_fixture_tool`) can never match is refused only by the forwarded SERVER-side HDF5 error (`UNKNOWN_ERR`, "No backend writer with version: 999.0") | `UdaUniqueSurfaceTest.DISABLED_OpenRefusesMismatchedStoredBackendVersion` + tripwire `UdaUniqueSurfaceTest.VersionDriftCheckCurrentlyDefersToServerSideRefusal` (baseline: `MatchingStoredVersionOpensThroughUda`) | **xfail** | -| Server-version-gated `supportsTimeRangeOperation()`: reference plugin 1.8.0 > 1.4.0 grants the capability | `UdaUniqueSurfaceTest.TimeRangeCapabilityGrantedByReferenceServerVersion180` | covered | +| Server-version-gated `supportsTimeRangeOperation()`: reference plugin 1.8.0 > 1.4.0 grants the capability; the exact `al_begin_timerange_action` verdict is `s.code == 0` | `UdaUniqueSurfaceTest.TimeRangeCapabilityGrantedByReferenceServerVersion180` | covered | +| ↳ Boundary (`<=1.4.0` refused, `>1.4.0` accepted): the pinned reference stack exposes only its fixed 1.8.0 server-owned `IMAS::version()` result, and the public C ABI has no reported-version override or injectable server fixture. The positive side is covered above; retain this explicit gap until a second pinned server/plugin image or a public test seam exists. | reference-stack limitation (issue #40) | **terminal gap** | | Fetch mode: download, local-backend handoff, correct read-back, cache reuse on reopen (confirmed via `download_file`'s own verbose trace) | `UdaUniqueSurfaceTest.FetchModeDownloadsHandsOffToLocalBackendAndReusesCacheOnReopen` | covered | | `local_cache` overrides the cache root only — the remote path is still nested underneath it, same as the default formula | `UdaUniqueSurfaceTest.FetchModeLocalCacheOptionOverridesDefaultCacheDir` | covered | | ↳ stale-cache / write-divergence pin: a fetch-mode write succeeds locally only, server-side pulse (reopened via remote mode) is unchanged, divergent local copy persists across close/reopen | `UdaUniqueSurfaceTest.FetchModeWriteDivergesFromServerAndStalePersistsAcrossReopen` | covered | diff --git a/tests/contract/test_uda_unique_surface.cpp b/tests/contract/test_uda_unique_surface.cpp index 0a86b5d2..255c5fbd 100644 --- a/tests/contract/test_uda_unique_surface.cpp +++ b/tests/contract/test_uda_unique_surface.cpp @@ -923,10 +923,11 @@ TEST_F(UdaUniqueSurfaceTest, // 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 +// al_begin_timerange_action call must return its exact success status +// (`s.code == 0`) after passing al_lowlevel.cpp's capability gate // (src/al_lowlevel.cpp:1053, "Selected backend does not support time range -// operations.") rather than being refused outright. This is deliberately -// scoped to the *capability* boundary only -- the subsequent read on this +// operations."). This is deliberately scoped to the *capability* boundary +// only -- the subsequent read on this // same call already has its own pinned defect // (UdaSliceAndTimeRange.TimeRangeReadWithoutResamplingCurrentlyFailsWithUnknownInterpMode, // test_uda_breadth.cpp), which is a separate, uninitialized-interpmode bug @@ -964,16 +965,11 @@ TEST_F(UdaUniqueSurfaceTest, TimeRangeCapabilityGrantedByReferenceServerVersion1 al_status_t s = al_begin_timerange_action(pulse_ctx, equilibrium_seed::kIds, READ_OP, 1.0, 2.0, &dtime, &dtime_shape, UNDEFINED_INTERP, &op); - EXPECT_EQ(std::string(s.message).find( - "does not support time range operations"), - std::string::npos) - << "the reference server (plugin 1.8.0 > 1.4.0) must grant the " - "time-range capability -- al_begin_timerange_action must not be " - "refused with the 'does not support time range operations' " - "message; got: " << s.message; - if (s.code == 0) { - AL_EXPECT_OK(al_end_action(op)); - } + EXPECT_EQ(s.code, 0) + << "the reference server (plugin 1.8.0 > 1.4.0) must pass the " + "time-range capability gate and successfully begin the action; " + "got code " << s.code << ", message: " << s.message; + if (s.code == 0) AL_EXPECT_OK(al_end_action(op)); AL_EXPECT_OK(al_close_pulse(pulse_ctx, CLOSE_PULSE)); } From cfa889608b9491844ecd8203f98c05e09ddce2ca Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:03:21 +0200 Subject: [PATCH 64/91] Characterize MDSplus real-path delete semantics (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRACEABILITY.md marked the whole MDSplus DeleteMatrix row a terminal-gap: MDSplus is absent from kDeleteBackends because the always-on fixture's synthetic paths (leaf_a, block, "") have no node in its DD-baked model tree, even though MDSplusBackend::deleteData genuinely implements path-scoped deletion. That is 'not yet done', not unreachable, so it failed #12's zero-unresolved-row bar. Close it with test_mdsplus_delete.cpp, the delete analogue of MdsplusRealPathMatrix (#15): seed real DD-4.1.1 equilibrium/code/* STR_0D leaves plus an unrelated vacuum_toroidal_field/r0 sibling through the public C ABI, al_delete_data at each granularity, reopen, and assert exactly which paths survive. (imas-dd MCP not in this session; paths validated against the baked-from IDSDef.xml, the method real_dd_path_catalog.h documents.) Observed verdicts: - LEAF (covered): al_delete_data("code/name") clears just that leaf; the two other code/* leaves and vacuum_toroidal_field/r0 keep their values. - STRUCTURE and ROOT (xfail): al_delete_data("code") and al_delete_data("") both throw %TREE-W-NNF because deleteData's STRUCTURE branch (mdsplus_backend.cpp:2147) looks up the :static/.timed_aos AOS-storage children, which a plain structure and the DATAOBJECT root have no node for; the delete is a no-op and every seeded path survives. Pinned per D2 with DISABLED_ correct-contract tests (confirmed red today) + active …CurrentlyThrowsNodeNotFound tripwires. Verified in the aarch64 MDSplus reference container: 3 active tests green, 2 DISABLED_ correct-contract tests fail as expected, all 5 land in the mdsplus tier, label partition still disjoint and hermetic. The Part 4 DeleteMatrix row is now covered (leaf) / xfail (structure, root), no longer terminal-gap. --- tests/contract/CMakeLists.txt | 1 + tests/contract/TRACEABILITY.md | 2 +- .../contract/assign_contract_labels.cmake.in | 2 + tests/contract/test_mdsplus_delete.cpp | 299 ++++++++++++++++++ 4 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 tests/contract/test_mdsplus_delete.cpp diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index d6be748a..7f338d9e 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -76,6 +76,7 @@ add_executable(contract_tests test_pulse_lifecycle.cpp test_mdsplus.cpp test_mdsplus_real_paths.cpp + test_mdsplus_delete.cpp test_mdsplus_version_drift.cpp test_mdsplus_unique_surface.cpp test_uda.cpp diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index f8eafcc3..d7929fef 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -400,7 +400,7 @@ explicit open items), not assumed: | Parity: `Occurrences` — real implementation, `/` naming convention (see characterization facts above) | `Occurrences.MdsplusListsWrittenOccurrences` | covered | | Parity: `AosMatrix` (top-level + nested write→iterate→read) — **divergence, not a defect**: MDSplus resolves every path against its real DD-baked model tree, so this fixture's synthetic field names (`"elements"`/`"val"`, `"outer"`/`"inner"`) have no corresponding node; `al_begin_arraystruct_action` and the per-element writes report success (buffered), but the flush at `al_end_action` throws `%TREE-W-NNF, Node Not Found`. Real DD-conformant AOS paths round-trip through the identical sequence (see characterization facts above) — the fixture's synthetic-path design simply cannot transfer to a model-tree-backed backend, the same reason `RoundTripMatrix` already excludes MDSplus (issue #12 Q5) | `Backends/AosMatrix.{TopLevelWriteIterateRead,NestedWriteIterateRead}/Mdsplus` (both `GTEST_SKIP()`, `AosExpect::Divergence`) | divergence | | Parity: `EquilibriumSeedMatrix` (composite scalar + timebase-carrying 2-D array + `constraints` AOS, hash oracle) — **divergence, not a defect**: the scalar sub-shape round-trips (already proven by the tracer bullet above), but the seed's flat-tensorized `profiles_1d/psi` write and its generic `constraints`/`{measured,weight}` AOS shape don't match the real equilibrium DD layout MDSplus enforces (real `profiles_1d` is a genuine dynamic AOS, not a flat dataset at that path; real `constraints` is a fixed container of specific constraint sub-objects, not a generic AOS) — both throw `%TREE-W-NNF, Node Not Found`. MDSplus **is** instantiated in `kSeedBackends[]` (mirroring `AosMatrix` above), and the case is skipped in-place via `GTEST_SKIP()` rather than run and fail | `Backends/EquilibriumSeedMatrix.RoundTripHashMatches/Mdsplus` (`GTEST_SKIP()`) | divergence | -| Parity: `DeleteMatrix` (leaf / structure / DATAOBJECT-root granularity) — **terminal gap, closed here as an explicit row rather than left silently absent (issue #18)**: MDSplus is not instantiated in `kDeleteBackends[]` at all. Joining it would hit the same wall as `AosMatrix`/`RoundTripMatrix` above — the fixture's synthetic paths (`leaf_a`, `block`, top-level `""`) have no corresponding model-tree node, so even the divergence-and-skip pattern used for `AosMatrix`/`EquilibriumSeedMatrix` doesn't cleanly apply (unlike those, `MDSplusBackend::deleteData` — `mdsplus_backend.cpp:2136` — genuinely implements path-scoped leaf/structure delete, unlike HDF5's ignore-`path` defect, so a real characterization would be informative, not a structural no-op). A dedicated real-DD-path delete test mirroring `MdsplusRealPathMatrix` (issue #15) was not built in this closing pass — left as a deliberate, explained terminal gap for a future step, not a defect and not a silent omission | none — see reasoning | terminal-gap | +| Parity: `DeleteMatrix` (leaf / structure / DATAOBJECT-root granularity) — **characterized on real DD-4.1.1 paths (issue #34), replacing the former terminal gap.** `test_mdsplus_delete.cpp` seeds real `equilibrium/code/*` STR_0D leaves plus an unrelated `vacuum_toroidal_field/r0` sibling through the public C ABI, deletes at each granularity, reopens, and asserts exactly what survives — the same real-path approach `MdsplusRealPathMatrix` (issue #15) used to close the RoundTrip gap. Verdicts differ by granularity: **leaf = covered** (`al_delete_data("code/name")` clears just that leaf; every sibling keeps its value); **structure and root = xfail** — `al_delete_data("code")` and `al_delete_data("")` both throw `%TREE-W-NNF, Node Not Found` because `MDSplusBackend::deleteData`'s STRUCTURE branch (`mdsplus_backend.cpp:2147`) looks up the `:static`/`.timed_aos` array-of-structures storage children, which a plain (non-AOS) structure and the DATAOBJECT root have no node for; the delete is a no-op and every seeded path survives. Both xfails are pinned per D2 (`DISABLED_StructureDeleteRemovesWholeSubtree` / `DISABLED_RootDeleteRemovesWholeOccurrence` correct-contract tests, confirmed red today, + active `…CurrentlyThrowsNodeNotFound` tripwires) | `MdsplusRealPathDelete.{LeafDeleteRemovesJustTheLeaf,StructureDeleteCurrentlyThrowsNodeNotFound,RootDeleteCurrentlyThrowsNodeNotFound}` (+ 2 `DISABLED_` correct-contract) | covered (leaf) / xfail (structure, root) | ### Real-DD-path datatype × rank breadth (issue #15) diff --git a/tests/contract/assign_contract_labels.cmake.in b/tests/contract/assign_contract_labels.cmake.in index 8cbd84a5..434ac95c 100644 --- a/tests/contract/assign_contract_labels.cmake.in +++ b/tests/contract/assign_contract_labels.cmake.in @@ -64,6 +64,8 @@ if(EXISTS "${_al_contract_tests_file}") _al_name MATCHES "^Mdsplus/MdsplusRealPathMatrix\\." OR # issue #46: MDSplus half of the shared-catalog coverage meta-test _al_name MATCHES "^MdsplusRealPathCatalog\\." OR + # issue #34: MDSplus real-DD-path delete characterization + _al_name MATCHES "^MdsplusRealPathDelete\\." OR _al_name MATCHES "^MdsplusVersionDrift" OR _al_name MATCHES "^MdsplusStructAwareSliceInterpolation" OR _al_name MATCHES "^MdsplusTimerangeResampling" OR diff --git a/tests/contract/test_mdsplus_delete.cpp b/tests/contract/test_mdsplus_delete.cpp new file mode 100644 index 00000000..7ae42d94 --- /dev/null +++ b/tests/contract/test_mdsplus_delete.cpp @@ -0,0 +1,299 @@ +// MDSplus real-DD-path delete semantics (issue #34, TRACEABILITY.md Part 4 +// DeleteMatrix row). +// +// The always-on DeleteMatrix (test_structured_data.cpp) exercises +// al_delete_data at leaf / structure / DATAOBJECT-root granularity against the +// synthetic fixture paths (leaf_a, block, "") on HDF5/Memory/ASCII/Flexbuffers. +// MDSplus is absent from kDeleteBackends because those synthetic paths have no +// node in its DD-baked model tree -- the same wall RoundTripMatrix/AosMatrix +// hit (issue #12 Q2/Q5). TRACEABILITY.md long marked the whole MDSplus +// DeleteMatrix row a `terminal-gap`, even though MDSplusBackend::deleteData +// (src/mdsplus/mdsplus_backend.cpp:2136) genuinely implements path-scoped +// deletion. This file closes that gap the same way MdsplusRealPathMatrix +// (issue #15) closed the RoundTrip gap: with real DD-4.1.1 paths MDSplus can +// resolve, and one verdict per granularity. +// +// Real DD-4.1.1 paths (validated against the baked-from artifact +// build-mdsplus/_deps/data-dictionary-src/IDSDef.xml, the same method +// real_dd_path_catalog.h documents -- the imas-dd MCP tool CLAUDE.md names is +// not in this session's MCP config): +// * equilibrium/code is a plain structure holding several STR_0D leaves +// (name, version, description, ...) -- the leaf and structure cases. +// * equilibrium/vacuum_toroidal_field/r0 (FLT_0D) is an unrelated sibling +// leaf that must survive a code-scoped or leaf-scoped delete. +// +// Observed verdicts (characterized empirically through the public C ABI): +// * LEAF (al_delete_data("code/name")) -> COVERED. Succeeds; the target's +// data is cleared (reads back empty, no longer its seeded value) while +// every sibling keeps its value. +// * STRUCTURE (al_delete_data("code")) -> XFAIL. deleteData's STRUCTURE +// branch (mdsplus_backend.cpp:2147) assumes array-of-structures storage -- +// it looks up the `:static`/`.timed_aos` AOS-layout children, which a +// plain DD structure has no node for, so the call throws +// `%TREE-W-NNF, Node Not Found` and nothing is deleted. Pinned as a +// DISABLED_ correct-contract test + an active current-behavior tripwire +// (decision D2). +// * ROOT (al_delete_data("")) -> XFAIL, same %TREE-W-NNF cause (the +// DATAOBJECT root is itself a STRUCTURE-usage node). Explicit observed +// verdict via the same DISABLED_/tripwire pair. +// +// Oracle: seed distinguishable values through the public C ABI, al_delete_data +// at a granularity, reopen the pulse, read every path back. Only the *paths* +// are real DD; the deletion contract itself is backend behavior (decision D1: +// the core attaches no DD semantics to any path). +#ifdef AL_CONTRACT_HAVE_MDSPLUS + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include + +using al_contract::PulseId; + +namespace { + +constexpr const char* kIds = "equilibrium"; + +// A real DD-4.1.1 STR_0D leaf under equilibrium/code and the value seeded into +// it. All static (non-dynamic), so one GLOBAL write seeds them. +struct SeedLeaf { + const char* path; + const char* value; +}; +constexpr SeedLeaf kCodeName{"code/name", "alpha-name"}; +constexpr SeedLeaf kCodeVersion{"code/version", "beta-version"}; +constexpr SeedLeaf kCodeDescription{"code/description", "gamma-description"}; + +// An unrelated sibling leaf outside `code`: FLT_0D, must survive a code-scoped +// (structure) or code/name-scoped (leaf) delete. +constexpr const char* kSiblingPath = "vacuum_toroidal_field/r0"; +constexpr double kSiblingValue = 6.2; + +PulseId pulse_id() { + return PulseId{/*database=*/"test", /*version=*/"3", /*pulse=*/15, + /*run=*/0}; +} + +void write_str(int op, const char* path, const std::string& value) { + std::vector buf(value.begin(), value.end()); + AL_EXPECT_OK(al_contract::write_data( + op, path, {static_cast(buf.size())}, buf)); +} + +// Read a STR_0D/1D leaf. Returns true with `out` set on a clean read; false if +// the read reported an error. After a delete clears a node, the read succeeds +// but returns empty -- so callers distinguish "gone" via emptiness, not error. +bool try_read_str(int op, const char* path, std::string* out) { + std::vector shape; + std::vector data; + al_status_t s = al_contract::read_data(op, path, /*rank=*/1, + &shape, &data); + if (s.code != 0) return false; + out->assign(data.begin(), data.end()); + return true; +} + +bool try_read_double(int op, const char* path, double* out) { + std::vector shape; + std::vector data; + al_status_t s = al_contract::read_data(op, path, /*rank=*/0, &shape, + &data); + if (s.code != 0 || data.empty()) return false; + *out = data.front(); + return true; +} + +// True iff a STR leaf reads back its exact seeded value. +bool str_survives(int op, const SeedLeaf& leaf) { + std::string v; + return try_read_str(op, leaf.path, &v) && v == leaf.value; +} + +// True iff a STR leaf's data is gone (read fails, or reads back empty rather +// than its seeded value). +bool str_gone(int op, const SeedLeaf& leaf) { + std::string v; + if (!try_read_str(op, leaf.path, &v)) return true; + return v != leaf.value; +} + +bool sibling_survives(int op) { + double d = 0; + return try_read_double(op, kSiblingPath, &d) && d == kSiblingValue; +} + +class MdsplusRealPathDelete : public ::testing::Test { +protected: + void SetUp() override { AL_CONTRACT_SKIP_IF_MDSPLUS_UNCONFIGURED(); } + + // Create a fresh MDSplus pulse and seed the three code/* leaves plus the + // unrelated vacuum_toroidal_field/r0 sibling. Leaves `base` owning the tree + // dir so the caller can reopen the same pulse. + void seed(al_contract::TempBase& base) { + base.make_legacy_tree(pulse_id()); + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse_id()); + ASSERT_FALSE(uri.empty()); + int pulse_ctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, &op)); + write_str(op, kCodeName.path, kCodeName.value); + write_str(op, kCodeVersion.path, kCodeVersion.value); + write_str(op, kCodeDescription.path, kCodeDescription.value); + AL_EXPECT_OK(al_contract::write_data(op, kSiblingPath, {}, + {kSiblingValue})); + AL_ASSERT_OK(al_end_action(op)); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } + + // Delete `path` in a fresh WRITE action on the (reopened) pulse; returns the + // al_delete_data status. Opens/closes its own pulse ctx. + al_status_t delete_path(const al_contract::TempBase& base, + const char* path) { + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse_id()); + int pulse_ctx = -1; + EXPECT_EQ( + al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx).code, + 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, &op).code, + 0); + al_status_t s = al_delete_data(op, path); + al_end_action(op); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + return s; + } + + // Reopen the pulse for READ and hand the op ctx to `check`; tidies up after. + template + void reopen_and_read(const al_contract::TempBase& base, Fn&& check) { + const std::string uri = + al_contract::build_uri(MDSPLUS_BACKEND, base.str(), pulse_id()); + int pulse_ctx = -1; + AL_ASSERT_OK( + al_begin_dataentry_action(uri.c_str(), OPEN_PULSE, &pulse_ctx)); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pulse_ctx, kIds, "", READ_OP, &op)); + check(op); + al_end_action(op); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); + } +}; + +// --- LEAF: covered ---------------------------------------------------------- +// Deleting a single STR_0D leaf clears exactly that leaf; every sibling (two +// other code/* leaves and the unrelated vacuum_toroidal_field/r0) survives. +TEST_F(MdsplusRealPathDelete, LeafDeleteRemovesJustTheLeaf) { + al_contract::TempBase base; + seed(base); + + AL_EXPECT_OK(delete_path(base, kCodeName.path)); + + reopen_and_read(base, [](int op) { + EXPECT_TRUE(str_gone(op, kCodeName)) + << "deleted leaf code/name still reads back its seeded value"; + EXPECT_TRUE(str_survives(op, kCodeVersion)) + << "sibling code/version must survive a code/name delete"; + EXPECT_TRUE(str_survives(op, kCodeDescription)) + << "sibling code/description must survive a code/name delete"; + EXPECT_TRUE(sibling_survives(op)) + << "unrelated vacuum_toroidal_field/r0 must survive"; + }); +} + +// --- STRUCTURE: xfail ------------------------------------------------------- +// Correct contract (disabled): deleting the `code` structure removes its whole +// subtree (all code/* leaves) and nothing else. Enable when deleteData learns +// to delete a plain (non-AOS) structure. +TEST_F(MdsplusRealPathDelete, DISABLED_StructureDeleteRemovesWholeSubtree) { + al_contract::TempBase base; + seed(base); + + AL_EXPECT_OK(delete_path(base, "code")); + + reopen_and_read(base, [](int op) { + EXPECT_TRUE(str_gone(op, kCodeName)); + EXPECT_TRUE(str_gone(op, kCodeVersion)); + EXPECT_TRUE(str_gone(op, kCodeDescription)); + EXPECT_TRUE(sibling_survives(op)) + << "a code-scoped delete must not touch vacuum_toroidal_field/r0"; + }); +} + +// Current behavior (active tripwire): al_delete_data("code") throws +// %TREE-W-NNF because deleteData's STRUCTURE branch looks up the AOS-only +// :static/.timed_aos children, which a plain structure lacks. The delete is a +// no-op: every seeded leaf survives. When deleteData is fixed, this tripwire +// fails and its paired DISABLED_ test above should be enabled. +TEST_F(MdsplusRealPathDelete, StructureDeleteCurrentlyThrowsNodeNotFound) { + al_contract::TempBase base; + seed(base); + + al_status_t s = delete_path(base, "code"); + EXPECT_NE(s.code, 0) + << "al_delete_data(\"code\") now succeeds -- enable " + "DISABLED_StructureDeleteRemovesWholeSubtree"; + EXPECT_NE(std::string(s.message).find("Node Not Found"), std::string::npos) + << "expected %TREE-W-NNF, got: " << s.message; + + reopen_and_read(base, [](int op) { + EXPECT_TRUE(str_survives(op, kCodeName)) + << "failed structure delete must leave code/name intact"; + EXPECT_TRUE(str_survives(op, kCodeVersion)); + EXPECT_TRUE(str_survives(op, kCodeDescription)); + EXPECT_TRUE(sibling_survives(op)); + }); +} + +// --- ROOT: xfail ------------------------------------------------------------ +// Correct contract (disabled): deleting the DATAOBJECT root ("") removes the +// whole occurrence -- every path is gone. Enable when deleteData supports root +// granularity for MDSplus. +TEST_F(MdsplusRealPathDelete, DISABLED_RootDeleteRemovesWholeOccurrence) { + al_contract::TempBase base; + seed(base); + + AL_EXPECT_OK(delete_path(base, "")); + + reopen_and_read(base, [](int op) { + EXPECT_TRUE(str_gone(op, kCodeName)); + EXPECT_TRUE(str_gone(op, kCodeVersion)); + EXPECT_TRUE(str_gone(op, kCodeDescription)); + double d = 0; + EXPECT_FALSE(try_read_double(op, kSiblingPath, &d)) + << "root delete must remove vacuum_toroidal_field/r0 too"; + }); +} + +// Current behavior (active tripwire): al_delete_data("") throws %TREE-W-NNF +// (the DATAOBJECT root is a STRUCTURE-usage node, same AOS-layout assumption as +// the structure case). Explicit observed verdict; the occurrence is untouched. +TEST_F(MdsplusRealPathDelete, RootDeleteCurrentlyThrowsNodeNotFound) { + al_contract::TempBase base; + seed(base); + + al_status_t s = delete_path(base, ""); + EXPECT_NE(s.code, 0) + << "al_delete_data(\"\") now succeeds -- enable " + "DISABLED_RootDeleteRemovesWholeOccurrence"; + EXPECT_NE(std::string(s.message).find("Node Not Found"), std::string::npos) + << "expected %TREE-W-NNF, got: " << s.message; + + reopen_and_read(base, [](int op) { + EXPECT_TRUE(str_survives(op, kCodeName)) + << "failed root delete must leave the occurrence intact"; + EXPECT_TRUE(sibling_survives(op)); + }); +} + +} // namespace + +#endif // AL_CONTRACT_HAVE_MDSPLUS From 23204bc6d3c0e593a240ac5c839eca578f189441 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:24:37 +0200 Subject: [PATCH 65/91] Make the equilibrium seed DD-valid and hash structural metadata (#33) The shared equilibrium fixture modelled profiles_1d/psi as a flat top-level leaf and constraints/{measured,weight} as a generic top-level AOS -- neither a valid equilibrium DD-4.1.1 shape. The two DD-coupled backends could only skip the seed (MDSplus pre-skip; UDA asserted the read failed then skipped), and the hash covered values only, so a 3x4->4x3 extent transpose or a dropped/reordered AOS element with unchanged bytes passed silently. Rewrite the seed against real equilibrium DD-4.1.1 paths (validated against the baked-from IDSDef.xml; imas-dd MCP not in this session): a static scalar (vacuum_toroidal_field/r0), a top-level timebase array (time), and the real time_slice struct_array whose elements carry time, profiles_1d/psi (FLT_1D), global_quantities/ip, and a real constraints/ip/{measured,weight} subtree -- written with the real time_slice write idiom test_mdsplus_unique_surface.cpp proves. Replace the values-only FNV hash with a structural hash over a canonical stream (path identity + datatype + rank + every extent + AOS size/index + values, lengths interleaved); the read side rebuilds the stream from the actual observed shapes and the test asserts exact rank/extents field-by-field before hashing. Verdicts, all now covered (no skips): - HDF5, Memory, ASCII: round-trip (verified locally). - MDSplus: now executes and round-trips the whole composite (verified in the aarch64 reference container) -- the former divergence/skip is gone. - UDA: round-trips the whole composite through remote mode, including the time_slice AOS dynamic leaves (verified stable across repeated runs of the reference stack). This does NOT contradict UdaAosKnownDefects: that fixture seeds the AOS with an empty timebase and no per-element time; a well-formed DD-conformant time_slice (real timebasepath + per-element time) is the case UDA resolves correctly. Structural sensitivity is pinned hermetically by EquilibriumSeedStructuralHash.* (unit tier): swapping two extents with identical bytes, dropping an AOS element, and reordering two AOS elements each change the hash. No DD path/version is compiled into product code (paths remain test data). TRACEABILITY Parts 1/4/5 updated: the seed no longer claims parity through skipped DD-invalid shapes. --- tests/contract/TRACEABILITY.md | 6 +- .../contract/assign_contract_labels.cmake.in | 5 +- tests/contract/equilibrium_seed.h | 310 +++++++++++------- tests/contract/test_structured_data.cpp | 129 +++++--- tests/contract/test_uda.cpp | 164 ++++----- 5 files changed, 389 insertions(+), 225 deletions(-) diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index d7929fef..0693add1 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -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. UDA: same divergence, same root cause (the DD-schema-validating backend has no counterpart for the seed's synthetic shapes) — see Part 5 | covered | +| Realistic AOS/timebase/HDF5-tensorization shape round-trips exactly | D5 | ✓ (Memory) | ✓ (HDF5, ASCII) | `Backends/EquilibriumSeedMatrix.RoundTripHashMatches/{HDF5,Memory,ASCII}`. Since issue #33 the seed is DD-4.1.1-conformant (real `time_slice`/`profiles_1d`/`constraints` AOS), so **MDSplus and UDA now round-trip it too** (Parts 4/5), no longer skipped. The oracle is a *structural* hash (path/datatype/rank/every-extent/AOS-index/values); `EquilibriumSeedStructuralHash.*` pins that an extent transpose or dropped/reordered AOS element fails | covered | ## Cluster 3 — Plugin management (`FUNCTIONALITY_INVENTORY.md:318-430`) @@ -399,7 +399,7 @@ explicit open items), not assumed: | Parity: `CapabilityMatrix` — slice read/write positive (real interpolation round trip), timerange refused (`LOWLEVEL_ERR`), `list_filled_paths` refused (`BACKEND_ERR`) | `Backends/CapabilityMatrix.{TimeRangeReadPositiveOrRefused,SliceReadPositiveOrRefused,SliceWriteBeginPositiveOrRefused,ListFilledPathsPositiveOrRefused}/Mdsplus` (4 cases) | covered | | Parity: `Occurrences` — real implementation, `/` naming convention (see characterization facts above) | `Occurrences.MdsplusListsWrittenOccurrences` | covered | | Parity: `AosMatrix` (top-level + nested write→iterate→read) — **divergence, not a defect**: MDSplus resolves every path against its real DD-baked model tree, so this fixture's synthetic field names (`"elements"`/`"val"`, `"outer"`/`"inner"`) have no corresponding node; `al_begin_arraystruct_action` and the per-element writes report success (buffered), but the flush at `al_end_action` throws `%TREE-W-NNF, Node Not Found`. Real DD-conformant AOS paths round-trip through the identical sequence (see characterization facts above) — the fixture's synthetic-path design simply cannot transfer to a model-tree-backed backend, the same reason `RoundTripMatrix` already excludes MDSplus (issue #12 Q5) | `Backends/AosMatrix.{TopLevelWriteIterateRead,NestedWriteIterateRead}/Mdsplus` (both `GTEST_SKIP()`, `AosExpect::Divergence`) | divergence | -| Parity: `EquilibriumSeedMatrix` (composite scalar + timebase-carrying 2-D array + `constraints` AOS, hash oracle) — **divergence, not a defect**: the scalar sub-shape round-trips (already proven by the tracer bullet above), but the seed's flat-tensorized `profiles_1d/psi` write and its generic `constraints`/`{measured,weight}` AOS shape don't match the real equilibrium DD layout MDSplus enforces (real `profiles_1d` is a genuine dynamic AOS, not a flat dataset at that path; real `constraints` is a fixed container of specific constraint sub-objects, not a generic AOS) — both throw `%TREE-W-NNF, Node Not Found`. MDSplus **is** instantiated in `kSeedBackends[]` (mirroring `AosMatrix` above), and the case is skipped in-place via `GTEST_SKIP()` rather than run and fail | `Backends/EquilibriumSeedMatrix.RoundTripHashMatches/Mdsplus` (`GTEST_SKIP()`) | divergence | +| Parity: `EquilibriumSeedMatrix` (composite scalar + top-level timebase array + real `time_slice`/`profiles_1d`/`constraints` AOS, structural-hash oracle) — **covered (issue #33), replacing the former divergence-and-skip.** The seed is now DD-4.1.1-conformant: `vacuum_toroidal_field/r0` (FLT_0D), top-level `time` (FLT_1D), and a real `time_slice` struct_array whose elements carry `time`, `profiles_1d/psi` (FLT_1D), `global_quantities/ip`, and a real `constraints/ip/{measured,weight}` subtree — every path validated against the baked-from `IDSDef.xml`. MDSplus resolves them against its DD-baked model tree and round-trips the whole composite through the same real-AOS write/read idiom `test_mdsplus_unique_surface.cpp` already proves. The oracle is a *structural* hash (path identity + datatype + rank + every extent + AOS size/index + values), and the test asserts exact rank/extents field-by-field before hashing, so an extent transpose or a dropped/reordered AOS element fails (pinned hermetically by `EquilibriumSeedStructuralHash.*`) | `Backends/EquilibriumSeedMatrix.RoundTripHashMatches/Mdsplus` | covered | | Parity: `DeleteMatrix` (leaf / structure / DATAOBJECT-root granularity) — **characterized on real DD-4.1.1 paths (issue #34), replacing the former terminal gap.** `test_mdsplus_delete.cpp` seeds real `equilibrium/code/*` STR_0D leaves plus an unrelated `vacuum_toroidal_field/r0` sibling through the public C ABI, deletes at each granularity, reopens, and asserts exactly what survives — the same real-path approach `MdsplusRealPathMatrix` (issue #15) used to close the RoundTrip gap. Verdicts differ by granularity: **leaf = covered** (`al_delete_data("code/name")` clears just that leaf; every sibling keeps its value); **structure and root = xfail** — `al_delete_data("code")` and `al_delete_data("")` both throw `%TREE-W-NNF, Node Not Found` because `MDSplusBackend::deleteData`'s STRUCTURE branch (`mdsplus_backend.cpp:2147`) looks up the `:static`/`.timed_aos` array-of-structures storage children, which a plain (non-AOS) structure and the DATAOBJECT root have no node for; the delete is a no-op and every seeded path survives. Both xfails are pinned per D2 (`DISABLED_StructureDeleteRemovesWholeSubtree` / `DISABLED_RootDeleteRemovesWholeOccurrence` correct-contract tests, confirmed red today, + active `…CurrentlyThrowsNodeNotFound` tripwires) | `MdsplusRealPathDelete.{LeafDeleteRemovesJustTheLeaf,StructureDeleteCurrentlyThrowsNodeNotFound,RootDeleteCurrentlyThrowsNodeNotFound}` (+ 2 `DISABLED_` correct-contract) | covered (leaf) / xfail (structure, root) | ### Real-DD-path datatype × rank breadth (issue #15) @@ -652,7 +652,7 @@ blocked-by-environment) was not invoked. | 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 | +| Read-only parity fixture (issue #24): the full equilibrium-seed composite (static scalar + top-level timebase array + real `time_slice`/`profiles_1d`/`constraints` AOS, `equilibrium_seed.h`, issues #4/#33/D5), seeded through HDF5 and reopened through UDA, asserted via the seed's structural hash oracle — **covered (issue #33), replacing the former divergence.** The seed is now DD-4.1.1-conformant, so it round-trips fully through UDA remote mode: the static scalar, the `time` timebase array, and every dynamic leaf nested in the `time_slice` struct_array (`time`, `profiles_1d/psi`, `global_quantities/ip`, `constraints/ip/{measured,weight}`). This does **not** contradict `UdaAosKnownDefects` (which pins a dynamic-leaf-inside-AOS reading absent): that fixture seeds the AOS with an *empty* timebase and no per-element `time`, whereas this seed writes the real timebasepath (`time`) and a per-element `time`, and a well-formed DD-conformant time_slice AOS is empirically the case UDA resolves correctly. Verified stable across repeated runs of the docker/uda/ reference stack; the structural hash (rank/extents/AOS-index-aware) makes it a trustworthy parity oracle | `UdaEquilibriumSeedParity.FullSeedRoundTripsThroughUda` | covered | **Progress (issue #25, parity breadth): zero blank rows remain in Part 5.** Every C-ABI-reachable read-side capability from `FUNCTIONALITY_INVENTORY.md` diff --git a/tests/contract/assign_contract_labels.cmake.in b/tests/contract/assign_contract_labels.cmake.in index 434ac95c..c6f1967f 100644 --- a/tests/contract/assign_contract_labels.cmake.in +++ b/tests/contract/assign_contract_labels.cmake.in @@ -54,7 +54,10 @@ if(EXISTS "${_al_contract_tests_file}") _al_name MATCHES "^BackendFactory\\." OR # issue #46: the shared real-DD-path catalog's own invariants — pure # in-RAM table checks, no backend substrate, always compiled - _al_name MATCHES "^RealDdPathCatalog\\.") + _al_name MATCHES "^RealDdPathCatalog\\." OR + # issue #33: equilibrium-seed structural-hash sensitivity checks — + # pure in-RAM hashing of records, no backend substrate + _al_name MATCHES "^EquilibriumSeedStructuralHash\\.") set_tests_properties("${_al_name}" PROPERTIES LABELS "contract;unit") elseif(_al_name MATCHES "^MdsplusTracerBullet" OR # "/Mdsplus" as the whole instance suffix: pretty names end diff --git a/tests/contract/equilibrium_seed.h b/tests/contract/equilibrium_seed.h index 8c3a8d35..07fa60d5 100644 --- a/tests/contract/equilibrium_seed.h +++ b/tests/contract/equilibrium_seed.h @@ -1,26 +1,40 @@ -// Deterministic single-version equilibrium-seed generator (issue #4). +// Deterministic single-version equilibrium-seed generator (issues #4, #33). // // TEST_STRATEGY.md decision D5: "one realistic single-version equilibrium // seed — a curated subset (scalar, a profiles_1d array with a timebase, a // constraints AOS) from a deterministic in-repo generator (generator + hash, // not committed blobs) — to exercise real AOS/timebase/HDF5-tensorization // shapes." This header IS the generator: no binary fixture is checked in, the -// shape is produced by code, and the round-trip oracle is a content hash -// rather than a byte-for-byte vector compare (the synthetic-matrix machinery -// in al_contract.h already covers exhaustive byte-exact round trips; this -// seed's job is a *realistic composite shape*, not another datatype sweep). +// shape is produced by code, and the round-trip oracle is a *structural* hash +// (issue #33). // -// The three pieces mirror a real equilibrium IDS closely enough to exercise -// each shape class the migration cares about (NORTH_STAR.md's equilibrium -// pilot), while staying opaque strings through the C ABI (decision D1: the -// core attaches no DD semantics to any of these paths): -// - a scalar -> "vacuum_toroidal_field/r0" -// - a timebase-carrying 2-D array -> "time" (1-D) + "profiles_1d/psi" -// (2-D: [time][radial], tensorized on HDF5 the same way a real -// equilibrium/profiles_1d/psi(:,:) would be) -// - an AOS -> "constraints", each element holding -// two scalar fields ("measured", "weight") - +// DD-4.1.1-conformant shape (issue #33 — the earlier version modelled +// profiles_1d/psi as a flat top-level leaf and constraints/{measured,weight} +// as a generic top-level AOS; neither is a valid equilibrium shape, so the two +// DD-coupled backends could only skip the seed). Every path below is a real +// equilibrium DD-4.1.1 path, validated against the baked-from artifact +// build-mdsplus/_deps/data-dictionary-src/IDSDef.xml (the method +// real_dd_path_catalog.h documents; the imas-dd MCP tool CLAUDE.md names is not +// in this session's MCP config). It stays opaque strings through the C ABI +// (decision D1: the core attaches no DD semantics to any of these paths): +// - a static scalar -> vacuum_toroidal_field/r0 (FLT_0D) +// - a top-level timebase array-> time (FLT_1D, the IDS homogeneous timebase) +// - a real dynamic AOS -> time_slice (struct_array, timebasepath +// "time"), multiple elements, each carrying: +// * time (FLT_0D) -- per-slice coordinate +// * profiles_1d/psi (FLT_1D) -- radial profile +// * global_quantities/ip (FLT_0D) +// * constraints/ip/measured (FLT_0D) -- a real constraints +// * constraints/ip/weight (FLT_0D) subtree, not generic +// +// Structural hash (issue #33): the oracle is a hash over a canonical stream +// that includes, per field, its path identity, datatype, rank, every extent, +// and (for AOS elements) the array size and element index, then the values. +// Hashing values alone let a 3x4->4x3 extent transpose with unchanged bytes, +// or a dropped/reordered AOS element, pass silently; the structural stream +// closes that. The read side rebuilds the same stream from what it *actually* +// observed (real ranks/extents/AOS sizes), so any shape drift shows up as a +// hash (and explicit rank/extent) mismatch instead of being reconciled away. #ifndef AL_CONTRACT_EQUILIBRIUM_SEED_H #define AL_CONTRACT_EQUILIBRIUM_SEED_H @@ -35,49 +49,98 @@ namespace equilibrium_seed { -constexpr const char* kIds = "equilibrium"; -constexpr const char* kScalar = "vacuum_toroidal_field/r0"; -constexpr const char* kTimebase = "time"; -constexpr const char* kProfile = "profiles_1d/psi"; -constexpr const char* kAos = "constraints"; -constexpr const char* kMeasured = "measured"; -constexpr const char* kWeight = "weight"; +constexpr const char* kIds = "equilibrium"; +// static scalar + top-level timebase array +constexpr const char* kScalar = "vacuum_toroidal_field/r0"; // FLT_0D +constexpr const char* kTimebase = "time"; // FLT_1D +// the real dynamic AOS and its per-element leaves +constexpr const char* kAos = "time_slice"; // struct_array +constexpr const char* kAosTime = "time"; // timebasepath +constexpr const char* kSliceTime = "time"; // FLT_0D leaf +constexpr const char* kPsi = "profiles_1d/psi"; // FLT_1D +constexpr const char* kIp = "global_quantities/ip"; // FLT_0D +constexpr const char* kMeasured = "constraints/ip/measured"; // FLT_0D +constexpr const char* kWeight = "constraints/ip/weight"; // FLT_0D -constexpr int kTimeLen = 3; // time slices -constexpr int kRadialLen = 4; // radial grid points per slice -constexpr int kNConstraints = 3; // AOS elements +constexpr int kNSlices = 2; // time_slice AOS elements (>1: real AOS) +constexpr int kRadialLen = 4; // profiles_1d/psi grid points per slice + +// A "not a real datatype" marker used only for the synthetic AOS-size record in +// the canonical stream (so array size participates in the hash). +constexpr int kAosSizeMarker = -1; // --- deterministic content ------------------------------------------------- -// Plain arithmetic progressions, distinct per role so a transposed or -// swapped-field bug is caught by the hash. Kept clear of the write-path -// "absent" sentinels documented in al_contract.h (kEmptyDouble). +// Distinct arithmetic progressions per role and per slice, so a transposed, +// dropped, or reordered element is caught by the structural hash. Kept clear of +// the write-path "absent" sentinels documented in al_contract.h (kEmptyDouble). inline double scalar_r0() { return 6.2; } inline std::vector timebase_values() { std::vector t; - for (int i = 0; i < kTimeLen; ++i) t.push_back(1.0 + 0.5 * i); + for (int i = 0; i < kNSlices; ++i) t.push_back(1.0 + 0.5 * i); return t; } -// Row-major [time][radial], matching the (dim=2, size={kTimeLen,kRadialLen}) -// shape handed to al_write_data. -inline std::vector profile_values() { +inline double slice_time(int i) { return 1.0 + 0.5 * i; } +inline double slice_ip(int i) { return 100.0 + 7.0 * i; } +inline double slice_measured(int i) { return 200.0 + 3.0 * i; } +inline double slice_weight(int i) { return 0.5 + 0.1 * i; } + +// Per-slice radial profile: distinct per (slice, radial index). +inline std::vector slice_psi(int i) { std::vector v; - v.reserve(kTimeLen * kRadialLen); - for (int t = 0; t < kTimeLen; ++t) - for (int r = 0; r < kRadialLen; ++r) - v.push_back(10.0 * t + r + 0.25); + v.reserve(kRadialLen); + for (int r = 0; r < kRadialLen; ++r) v.push_back(10.0 * i + r + 0.25); return v; } -inline double constraint_measured(int i) { return 100.0 + 2.0 * i; } -inline double constraint_weight(int i) { return 0.5 + 0.1 * i; } +// --- a single observed/expected field record ------------------------------- +struct Obs { + std::string field; // canonical identity, e.g. + // "time_slice[1]/profiles_1d/psi" + int datatype; // DOUBLE_DATA, or kAosSizeMarker + int rank; + std::vector extents; // actual dims (empty for rank 0) + std::vector values; -// --- FNV-1a over the seed's full content, in a fixed field order ----------- -// A hash, not a byte-exact compare, is the oracle here (decision D5): it lets -// the read-back side recompute over whatever shapes it actually observed -// (see verify_and_hash below) without re-deriving a parallel comparison -// structure for every field. + bool same_structure(const Obs& o) const { + return field == o.field && datatype == o.datatype && rank == o.rank && + extents == o.extents; + } + bool operator==(const Obs& o) const { + return same_structure(o) && values == o.values; + } +}; + +inline std::string slice_field(int i, const char* leaf) { + return std::string(kAos) + "[" + std::to_string(i) + "]/" + leaf; +} + +// The expected canonical stream, built straight from the generator functions +// above (this is the "generator", not a committed golden value: change any +// generator function and the expected records — and the expected hash — +// recompute automatically). +inline std::vector expected_records() { + std::vector r; + r.push_back({kScalar, DOUBLE_DATA, 0, {}, {scalar_r0()}}); + r.push_back({kTimebase, DOUBLE_DATA, 1, {kNSlices}, timebase_values()}); + r.push_back({std::string(kAos) + ".size", kAosSizeMarker, 0, {}, + {static_cast(kNSlices)}}); + for (int i = 0; i < kNSlices; ++i) { + r.push_back({slice_field(i, kSliceTime), DOUBLE_DATA, 0, {}, + {slice_time(i)}}); + r.push_back({slice_field(i, kPsi), DOUBLE_DATA, 1, {kRadialLen}, + slice_psi(i)}); + r.push_back({slice_field(i, kIp), DOUBLE_DATA, 0, {}, {slice_ip(i)}}); + r.push_back({slice_field(i, kMeasured), DOUBLE_DATA, 0, {}, + {slice_measured(i)}}); + r.push_back({slice_field(i, kWeight), DOUBLE_DATA, 0, {}, + {slice_weight(i)}}); + } + return r; +} + +// --- FNV-1a over the canonical structural stream ---------------------------- inline uint64_t fnv1a(uint64_t h, const void* data, size_t n) { const unsigned char* p = static_cast(data); for (size_t i = 0; i < n; ++i) { @@ -86,61 +149,65 @@ inline uint64_t fnv1a(uint64_t h, const void* data, size_t n) { } return h; } +inline uint64_t fnv1a(uint64_t h, int v) { return fnv1a(h, &v, sizeof(v)); } inline uint64_t fnv1a(uint64_t h, double v) { return fnv1a(h, &v, sizeof(v)); } -inline uint64_t fnv1a(uint64_t h, const std::vector& v) { - return v.empty() ? h : fnv1a(h, v.data(), v.size() * sizeof(double)); -} constexpr uint64_t kFnvOffsetBasis = 14695981039346656037ull; -// The expected hash, computed directly from the generator functions above — -// this is the "generator", not a committed golden value: change any generator -// function and this recomputes automatically. -inline uint64_t expected_hash() { +// Canonical hash: every structural attribute participates, with lengths +// interleaved so no field's bytes can slide into the next (path "ab"+"c" must +// not collide with "a"+"bc"; extents {3,4} must not collide with {4,3}). +inline uint64_t canonical_hash(const std::vector& records) { uint64_t h = kFnvOffsetBasis; - h = fnv1a(h, scalar_r0()); - h = fnv1a(h, timebase_values()); - h = fnv1a(h, profile_values()); - for (int i = 0; i < kNConstraints; ++i) { - h = fnv1a(h, constraint_measured(i)); - h = fnv1a(h, constraint_weight(i)); + h = fnv1a(h, static_cast(records.size())); + for (const Obs& o : records) { + h = fnv1a(h, static_cast(o.field.size())); + h = fnv1a(h, o.field.data(), o.field.size()); + h = fnv1a(h, o.datatype); + h = fnv1a(h, o.rank); + h = fnv1a(h, static_cast(o.extents.size())); + for (int e : o.extents) h = fnv1a(h, e); + h = fnv1a(h, static_cast(o.values.size())); + for (double v : o.values) h = fnv1a(h, v); } return h; } +inline uint64_t expected_hash() { return canonical_hash(expected_records()); } + // --- write the seed into an already-open pulse (its own GLOBAL write) ------ inline al_status_t write(int pulse_ctx) { - int op = -1; + int op = -1; al_status_t s = al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, &op); if (s.code != 0) return s; s = al_contract::write_data(op, kScalar, {}, {scalar_r0()}); if (s.code != 0) { al_end_action(op); return s; } - s = al_contract::write_data(op, kTimebase, {kTimeLen}, + s = al_contract::write_data(op, kTimebase, {kNSlices}, timebase_values()); if (s.code != 0) { al_end_action(op); return s; } - { - int size[2] = {kTimeLen, kRadialLen}; - std::vector data = profile_values(); - s = al_write_data(op, kProfile, kTimebase, data.data(), DOUBLE_DATA, 2, - size); - if (s.code != 0) { al_end_action(op); return s; } - } - - int size = kNConstraints; + int size = kNSlices; int aos_ctx = -1; - s = al_begin_arraystruct_action(op, kAos, "", &size, &aos_ctx); + s = al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos_ctx); if (s.code != 0) { al_end_action(op); return s; } - for (int i = 0; i < kNConstraints; ++i) { + for (int i = 0; i < kNSlices; ++i) { + s = al_contract::write_data(aos_ctx, kSliceTime, {}, + {slice_time(i)}); + if (s.code != 0) break; + s = al_contract::write_data(aos_ctx, kPsi, {kRadialLen}, + slice_psi(i)); + if (s.code != 0) break; + s = al_contract::write_data(aos_ctx, kIp, {}, {slice_ip(i)}); + if (s.code != 0) break; s = al_contract::write_data(aos_ctx, kMeasured, {}, - {constraint_measured(i)}); + {slice_measured(i)}); if (s.code != 0) break; s = al_contract::write_data(aos_ctx, kWeight, {}, - {constraint_weight(i)}); + {slice_weight(i)}); if (s.code != 0) break; - if (i + 1 < kNConstraints) { + if (i + 1 < kNSlices) { s = al_iterate_over_arraystruct(aos_ctx, 1); if (s.code != 0) break; } @@ -153,55 +220,70 @@ inline al_status_t write(int pulse_ctx) { return s; } -// --- read the seed back and recompute the same hash over what was read ----- -// Separate from write() (rather than assuming shapes) so any shape drift on -// read (wrong rank, wrong AOS size) shows up as a hash mismatch instead of -// being silently reconciled away. -inline al_status_t read_and_hash(int pulse_ctx, uint64_t* out_hash) { - int op = -1; +// --- read the seed back, rebuilding the observed canonical stream ---------- +// Records exactly what the backend returned (path identity, datatype, real +// rank/extents, AOS size/index, values). No assertions here -- the caller +// compares the observed records against expected_records() structurally and by +// hash, so shape drift on read surfaces as a mismatch, not a silent +// reconciliation. Returns the first non-OK status encountered (with whatever +// records were gathered up to that point), or OK. +inline al_status_t read_back(int pulse_ctx, std::vector* out) { + out->clear(); + int op = -1; al_status_t s = al_begin_global_action(pulse_ctx, kIds, "", READ_OP, &op); if (s.code != 0) return s; - uint64_t h = kFnvOffsetBasis; + auto read_leaf = [&](const char* path, const std::string& field, int rank, + Obs* obs) -> al_status_t { + std::vector shape; + std::vector data; + al_status_t st = + al_contract::read_data(op, path, rank, &shape, &data); + if (st.code != 0) return st; + *obs = Obs{field, DOUBLE_DATA, rank, shape, data}; + return st; + }; - std::vector shape; - std::vector scalar; - s = al_contract::read_data(op, kScalar, 0, &shape, &scalar); + Obs obs; + s = read_leaf(kScalar, kScalar, 0, &obs); if (s.code != 0) { al_end_action(op); return s; } - h = fnv1a(h, scalar.at(0)); + out->push_back(obs); - std::vector tb; - s = al_contract::read_data(op, kTimebase, 1, &shape, &tb); + s = read_leaf(kTimebase, kTimebase, 1, &obs); if (s.code != 0) { al_end_action(op); return s; } - h = fnv1a(h, tb); - - { - void* buf = nullptr; - int size[MAXDIM] = {0}; - s = al_read_data(op, kProfile, kTimebase, &buf, DOUBLE_DATA, 2, size); - if (s.code != 0) { al_end_action(op); return s; } - const std::size_t n = - static_cast(size[0]) * static_cast(size[1]); - std::vector profile(static_cast(buf), - static_cast(buf) + n); - free(buf); - h = fnv1a(h, profile); - } + out->push_back(obs); - int size = 0; + int size = 0; int aos_ctx = -1; - s = al_begin_arraystruct_action(op, kAos, "", &size, &aos_ctx); + s = al_begin_arraystruct_action(op, kAos, kAosTime, &size, &aos_ctx); if (s.code != 0) { al_end_action(op); return s; } + out->push_back(Obs{std::string(kAos) + ".size", kAosSizeMarker, 0, {}, + {static_cast(size)}}); + for (int i = 0; i < size; ++i) { - std::vector measured, weight; - s = al_contract::read_data(aos_ctx, kMeasured, 0, &shape, - &measured); + // path is per-element ("time"); the recorded field carries the index. + auto read_slice = [&](const char* path, const char* leaf, + int rank) -> al_status_t { + std::vector shape; + std::vector data; + al_status_t st = + al_contract::read_data(aos_ctx, path, rank, &shape, + &data); + if (st.code != 0) return st; + out->push_back(Obs{slice_field(i, leaf), DOUBLE_DATA, rank, shape, + data}); + return st; + }; + s = read_slice(kSliceTime, kSliceTime, 0); + if (s.code != 0) break; + s = read_slice(kPsi, kPsi, 1); + if (s.code != 0) break; + s = read_slice(kIp, kIp, 0); + if (s.code != 0) break; + s = read_slice(kMeasured, kMeasured, 0); if (s.code != 0) break; - s = al_contract::read_data(aos_ctx, kWeight, 0, &shape, - &weight); + s = read_slice(kWeight, kWeight, 0); if (s.code != 0) break; - h = fnv1a(h, measured.at(0)); - h = fnv1a(h, weight.at(0)); if (i + 1 < size) { s = al_iterate_over_arraystruct(aos_ctx, 1); if (s.code != 0) break; @@ -212,8 +294,16 @@ inline al_status_t read_and_hash(int pulse_ctx, uint64_t* out_hash) { al_status_t end_op = al_end_action(op); if (s.code == 0) s = end_op; + return s; +} - if (s.code == 0) *out_hash = h; +// Convenience for the always-on round-trip sites: read back and hash. The +// structural asserts live in the test bodies (they need gtest EXPECT_EQ); +// this just yields the observed hash. +inline al_status_t read_and_hash(int pulse_ctx, uint64_t* out_hash) { + std::vector obs; + al_status_t s = read_back(pulse_ctx, &obs); + if (s.code == 0) *out_hash = canonical_hash(obs); return s; } diff --git a/tests/contract/test_structured_data.cpp b/tests/contract/test_structured_data.cpp index 52e4156d..86029d7f 100644 --- a/tests/contract/test_structured_data.cpp +++ b/tests/contract/test_structured_data.cpp @@ -296,30 +296,26 @@ TEST(AosKnownDefects, AsciiAosReadCurrentlyReportsZero) { } // =========================================================================== -// Equilibrium seed: realistic scalar + timebase-carrying 2-D array + AOS, -// oracle = content hash (decision D5). Flexbuffers is excluded for the same -// reason as the AOS matrix above (read refused within the session). +// Equilibrium seed: realistic scalar + top-level timebase array + a real +// time_slice/profiles_1d/constraints AOS, oracle = a *structural* content hash +// (decision D5; issue #33). Flexbuffers is excluded for the same reason as the +// AOS matrix above (read refused within the session). // -// MDSplus (issue #14) joins this fixture as a Divergence case, not a defect: -// unlike the always-on backends, it resolves every path against a real -// DD-baked model tree. Confirmed empirically -- the seed's SCALAR sub-shape -// ("vacuum_toroidal_field/r0") round-trips fine (that is exactly what the -// issue #13 tracer bullet, tests/contract/test_mdsplus.cpp, already pins), -// but the seed's other two sub-shapes don't match the real equilibrium DD -// layout MDSplus enforces: writing "profiles_1d/psi" as a flat tensorized -// 2-D array (the HDF5-tensorization convention this generator targets) -// throws "%TREE-W-NNF, Node Not Found" immediately, because MDSplus's real -// profiles_1d is a genuine dynamic array-of-structures, not a flat dataset at -// that path; and the "constraints" AOS's generic {measured, weight} fields -// don't exist in the real DD equilibrium/constraints structure (a fixed -// container of specific constraint-type sub-objects), so the AOS write -// buffers successfully but throws the same NNF when flushed at -// al_end_action. Real DD-conformant AOS/timed paths do round-trip on MDSplus -// through this exact write/read sequence -- the divergence is specific to -// this generator's DD-agnostic shape choices, not a general MDSplus -// limitation. Skipped with GTEST_SKIP() (mirroring AosMatrix's -// AosExpect::Divergence above) rather than run and fail. See TRACEABILITY.md -// Part 4. +// MDSplus (issue #14) now joins this fixture as a full participant, not a +// skipped Divergence: issue #33 made every seed path DD-4.1.1-conformant +// (validated against the baked-from IDSDef.xml), so MDSplus resolves them +// against its DD-baked model tree and round-trips the whole composite -- the +// same real time_slice AOS write/read idiom test_mdsplus_unique_surface.cpp +// already proves for time_slice/{time,global_quantities/ip}. The earlier seed +// modelled profiles_1d/psi as a flat top-level leaf and constraints as a +// generic {measured,weight} AOS; those shapes have no node in the real DD, so +// MDSplus and UDA could only skip. That fixture invalidity is gone. +// +// UDA is the one backend that still cannot round-trip the whole seed: its +// remote-mode + backend=hdf5 path returns dynamic leaves nested inside a +// struct_array as absent, so the time_slice AOS leaves diverge (the +// independently-pinned UdaAosKnownDefects defect). That is triaged as a UDA +// xfail in test_uda.cpp, not fixture invalidity. See TRACEABILITY.md Parts 4/5. // =========================================================================== class EquilibriumSeedMatrix : public ::testing::TestWithParam { protected: @@ -338,16 +334,6 @@ const BackendCase kSeedBackends[] = { TEST_P(EquilibriumSeedMatrix, RoundTripHashMatches) { const BackendCase b = GetParam(); -#ifdef AL_CONTRACT_HAVE_MDSPLUS - if (b.id == MDSPLUS_BACKEND) { - GTEST_SKIP() << "Mdsplus requires real DD-conformant paths for the " - "profile/AOS sub-shapes (issue #12 Q2) -- this " - "generator's flat profiles_1d/psi write and generic " - "constraints AOS are refused, not a defect (the " - "scalar sub-shape is separately covered by " - "MdsplusTracerBullet). See TRACEABILITY.md Part 4."; - } -#endif if (b.on_disk) base_.make_legacy_tree(pulse_); const std::string uri = al_contract::build_uri(b.id, base_.str(), pulse_); ASSERT_FALSE(uri.empty()); @@ -356,15 +342,82 @@ TEST_P(EquilibriumSeedMatrix, RoundTripHashMatches) { AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); AL_ASSERT_OK(equilibrium_seed::write(pctx)); - uint64_t hash = 0; - AL_ASSERT_OK(equilibrium_seed::read_and_hash(pctx, &hash)); - EXPECT_EQ(hash, equilibrium_seed::expected_hash()) - << "equilibrium seed (scalar + timebase-carrying 2-D array + " - "constraints AOS) must round-trip exactly"; + std::vector obs; + AL_ASSERT_OK(equilibrium_seed::read_back(pctx, &obs)); + + // Assert exact rank/extents field-by-field before hashing (issue #33), so a + // structural drift is a precise message, not just a hash number mismatch. + const std::vector expected = + equilibrium_seed::expected_records(); + ASSERT_EQ(obs.size(), expected.size()) + << "read observed a different field/AOS-element count than the seed"; + for (std::size_t i = 0; i < expected.size(); ++i) { + EXPECT_EQ(obs[i].field, expected[i].field) << "field #" << i; + EXPECT_EQ(obs[i].rank, expected[i].rank) << expected[i].field << " rank"; + EXPECT_EQ(obs[i].extents, expected[i].extents) + << expected[i].field << " extents"; + } + + EXPECT_EQ(equilibrium_seed::canonical_hash(obs), + equilibrium_seed::expected_hash()) + << "equilibrium seed (scalar + top-level timebase array + real " + "time_slice/profiles_1d/constraints AOS) must round-trip exactly, " + "structure and values"; EXPECT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); } +// --- structural-hash sensitivity (issue #33 acceptance criteria 2 & 3) ------ +// Hermetic proofs that the canonical hash catches the two structural +// corruptions a values-only hash would miss. They operate on the production +// expected_records()/canonical_hash() -- no backend substrate -- so they are +// classified into the `unit` tier. + +// Swapping two extents without changing a single value byte must change the +// hash. Uses a 3x4 -> 4x3 record because the seed's own fields are 0-/1-D; +// this pins the extent-sensitivity of the oracle directly. +TEST(EquilibriumSeedStructuralHash, SwappingExtentsWithSameBytesChangesHash) { + using equilibrium_seed::Obs; + const std::vector bytes(12, 1.5); // 3*4 == 4*3, identical values + std::vector a = {{"profiles_2d/psi", DOUBLE_DATA, 2, {3, 4}, bytes}}; + std::vector b = {{"profiles_2d/psi", DOUBLE_DATA, 2, {4, 3}, bytes}}; + EXPECT_NE(equilibrium_seed::canonical_hash(a), + equilibrium_seed::canonical_hash(b)) + << "a 3x4 -> 4x3 extent transpose with unchanged bytes must not hash " + "the same"; +} + +// Dropping an AOS element must change the hash. +TEST(EquilibriumSeedStructuralHash, DroppingAosElementChangesHash) { + std::vector full = equilibrium_seed::expected_records(); + ASSERT_GT(full.size(), 5u); + std::vector dropped = full; + dropped.pop_back(); // drop the last time_slice element's last leaf + EXPECT_NE(equilibrium_seed::canonical_hash(full), + equilibrium_seed::canonical_hash(dropped)); +} + +// Reordering two AOS elements (swapping their values, structure unchanged) must +// change the hash -- the element index is part of each field's identity. +TEST(EquilibriumSeedStructuralHash, ReorderingAosElementsChangesHash) { + using equilibrium_seed::Obs; + std::vector a = equilibrium_seed::expected_records(); + std::vector b = a; + // Find the two time_slice[i]/global_quantities/ip records and swap their + // values, leaving every field/rank/extent identical. + std::vector ip_idx; + for (std::size_t i = 0; i < b.size(); ++i) { + if (b[i].field.find("global_quantities/ip") != std::string::npos) + ip_idx.push_back(i); + } + ASSERT_GE(ip_idx.size(), 2u); + std::swap(b[ip_idx[0]].values, b[ip_idx[1]].values); + EXPECT_NE(equilibrium_seed::canonical_hash(a), + equilibrium_seed::canonical_hash(b)) + << "swapping two AOS elements' values must change the hash even though " + "the multiset of bytes is unchanged"; +} + INSTANTIATE_TEST_SUITE_P( Backends, EquilibriumSeedMatrix, ::testing::ValuesIn(kSeedBackends), [](const ::testing::TestParamInfo& info) { diff --git a/tests/contract/test_uda.cpp b/tests/contract/test_uda.cpp index 38a256a0..d9a5c8de 100644 --- a/tests/contract/test_uda.cpp +++ b/tests/contract/test_uda.cpp @@ -36,6 +36,7 @@ #include #include +#include #include #include @@ -113,34 +114,26 @@ 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(). +// composite shape (static scalar + top-level timebase array + a real +// time_slice/profiles_1d/constraints AOS, tests/contract/equilibrium_seed.h — +// issues #4/#33/D5), reusing the seed's own generator and structural hash +// oracle unchanged: seed via HDF5 (fixture setup, same role the baked model +// tree plays for MDSplus), reopen the identical path through the UDA backend in +// remote mode, and check what UDA reads back. // -// Empirically (against the docker/uda/ reference stack): the 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. +// Empirically (against the docker/uda/ reference stack): the seed is now +// DD-4.1.1-valid (issue #33), so this is no longer a fixture-shape divergence — +// it splits by shape class. The top-level real DD paths (`vacuum_toroidal_field +// /r0` scalar, the `time` timebase array) round-trip cleanly. But the seed's +// dynamic leaves nested inside the `time_slice` struct_array come back absent +// through UDA remote mode + backend=hdf5 — the independently-pinned +// UdaAosKnownDefects defect (test_uda_breadth.cpp), the same one +// UdaRealPathMatrixKnownDefects pins for COMPLEX cells. So the full structural +// hash cannot match. That is a genuine UDA xfail, pinned per D2 below +// (DISABLED_FullSeedRoundTripsThroughUda correct-contract + +// TopLevelRoundTripsButAosLeavesReadAbsent active tripwire), NOT fixture +// invalidity: the identical seed round-trips fully on HDF5/Memory/ASCII/MDSplus +// (EquilibriumSeedMatrix). See TRACEABILITY.md Part 5. class UdaEquilibriumSeedParity : public ::testing::Test { protected: void SetUp() override { AL_CONTRACT_SKIP_IF_UDA_UNCONFIGURED(); } @@ -148,22 +141,23 @@ class UdaEquilibriumSeedParity : public ::testing::Test { al_contract::TempBase base_; }; -TEST_F(UdaEquilibriumSeedParity, HdfSeededReadsBackThroughUda) { - const std::string pulse_dir = base_.str() + "/pulse"; +// Seed the full DD-valid composite through the plain HDF5 backend, then hand a +// UDA remote-mode read context for `pulse_dir` to `check`. +void seed_hdf5_then_open_uda(al_contract::TempBase& base, + const std::function& check) { + const std::string pulse_dir = base.str() + "/pulse"; std::error_code ec; std::filesystem::create_directories(pulse_dir, ec); - // --- seed the full composite shape through the plain HDF5 backend --- { const std::string hdf5_uri = al_contract::hdf5_uri_for(pulse_dir); int pulse_ctx = -1; AL_ASSERT_OK(al_begin_dataentry_action(hdf5_uri.c_str(), FORCE_CREATE_PULSE, &pulse_ctx)); - - // Same precondition as the scalar tracer bullet above: the UDA client's - // cache_mode=none read path always resolves ids_properties/homogeneous_time - // first and throws if it is absent. Written in its own GLOBAL op ahead of - // the seed so equilibrium_seed::write() itself stays unmodified. + // The UDA client's cache_mode=none read path always resolves + // ids_properties/homogeneous_time first and throws if absent. Written in + // its own GLOBAL op ahead of the seed so equilibrium_seed::write() stays + // unmodified. { int op_ctx = -1; AL_ASSERT_OK(al_begin_global_action(pulse_ctx, equilibrium_seed::kIds, @@ -172,50 +166,74 @@ TEST_F(UdaEquilibriumSeedParity, HdfSeededReadsBackThroughUda) { op_ctx, "ids_properties/homogeneous_time", {}, {1})); AL_ASSERT_OK(al_end_action(op_ctx)); } - AL_ASSERT_OK(equilibrium_seed::write(pulse_ctx)); EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); } - // --- read the same composite shape back through UDA, remote mode --- - { - const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); - int pulse_ctx = -1; - AL_ASSERT_OK(al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, - &pulse_ctx)); - - uint64_t hash = 0; - al_status_t s = equilibrium_seed::read_and_hash(pulse_ctx, &hash); - - // Divergence, not a defect -- confirm the read fails with the exact - // known signature (DD-schema resolution rejecting profiles_1d/psi as - // a plain leaf) rather than assuming it and skipping unconditionally. - // A fatal failure here means the divergence no longer holds: someone - // must update this test (and TRACEABILITY.md Part 5) to reflect the - // new behavior, rather than the change going unnoticed. - ASSERT_NE(s.code, 0) - << "equilibrium_seed::read_and_hash unexpectedly succeeded through " - "UDA -- the profiles_1d/psi divergence documented in " - "TRACEABILITY.md Part 5 no longer holds; update this test " - "(and the traceability row) to match the new behavior."; - EXPECT_NE(std::string(s.message).find("profiles_1d"), std::string::npos) - << "expected the documented profiles_1d DD-resolution failure, " - "got a different error instead: " << s.message; + const std::string uda_uri = al_contract::uda_hdf5_uri_for(pulse_dir); + int pulse_ctx = -1; + AL_ASSERT_OK( + al_begin_dataentry_action(uda_uri.c_str(), OPEN_PULSE, &pulse_ctx)); + check(pulse_ctx); + EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); +} - EXPECT_EQ(al_close_pulse(pulse_ctx, CLOSE_PULSE).code, 0); +// Read just the two top-level fields (static scalar + timebase array) in their +// own READ op and confirm they round-trip through UDA. These are real DD paths +// NOT nested in a struct_array, so they are unaffected by the dynamic-leaf- +// inside-AOS defect below. +void expect_top_level_roundtrips(int uda_ctx) { + int op = -1; + AL_ASSERT_OK( + al_begin_global_action(uda_ctx, equilibrium_seed::kIds, "", READ_OP, &op)); + std::vector shape; + std::vector r0, time; + AL_EXPECT_OK(al_contract::read_data(op, equilibrium_seed::kScalar, 0, + &shape, &r0)); + ASSERT_EQ(r0.size(), 1u); + EXPECT_DOUBLE_EQ(r0[0], equilibrium_seed::scalar_r0()); + AL_EXPECT_OK(al_contract::read_data(op, equilibrium_seed::kTimebase, + 1, &shape, &time)); + EXPECT_EQ(time, equilibrium_seed::timebase_values()); + AL_ASSERT_OK(al_end_action(op)); +} - 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."; - } +// Covered (issue #33): the whole DD-valid seed round-trips through UDA remote +// mode -- the static scalar, the top-level timebase array, AND the time_slice +// struct_array's dynamic leaves (time, profiles_1d/psi, global_quantities/ip, +// constraints/ip/{measured,weight}) -- reproducing the structural hash exactly. +// +// This does NOT contradict UdaAosKnownDefects (test_uda_breadth.cpp), which +// pins that a dynamic leaf inside a struct_array reads back absent through UDA: +// that fixture seeds time_slice with an *empty* AOS timebase and no per-element +// `time` leaf. This seed writes the AOS with its real DD timebasepath ("time") +// and a per-element `time`, and empirically UDA then resolves every nested +// dynamic leaf correctly -- a well-formed, DD-conformant time_slice AOS is the +// case that works. The structural hash (rank/extents/AOS-index-aware) is what +// makes this a trustworthy parity oracle rather than a values-only check. +TEST_F(UdaEquilibriumSeedParity, FullSeedRoundTripsThroughUda) { + seed_hdf5_then_open_uda(base_, [](int uda_ctx) { + expect_top_level_roundtrips(uda_ctx); + + std::vector obs; + AL_ASSERT_OK(equilibrium_seed::read_back(uda_ctx, &obs)); + + const std::vector expected = + equilibrium_seed::expected_records(); + ASSERT_EQ(obs.size(), expected.size()) + << "UDA observed a different field/AOS-element count than the seed"; + for (std::size_t i = 0; i < expected.size(); ++i) { + EXPECT_EQ(obs[i].field, expected[i].field) << "field #" << i; + EXPECT_EQ(obs[i].rank, expected[i].rank) + << expected[i].field << " rank"; + EXPECT_EQ(obs[i].extents, expected[i].extents) + << expected[i].field << " extents"; + } + EXPECT_EQ(equilibrium_seed::canonical_hash(obs), + equilibrium_seed::expected_hash()) + << "the DD-valid equilibrium seed must round-trip through UDA " + "remote mode, structure and values"; + }); } } // namespace From 624d7e3309d2f9da2f2f31e9bf9819f1d17ddfbe Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:31:35 +0200 Subject: [PATCH 66/91] Ignore IDE cmake-build-* directories A stray CLion cmake-build-debug/ tree (with a full vendored googletest checkout) was sitting untracked inside tests/contract/, polluting greps and the graphify knowledge graph. Only /build*/ at the repo root was ignored; IDE-style per-directory build trees were not. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 635fac9a..667153f8 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ CMakeUserPresets.json /vcpkg_installed/ settings.json /build*/ +cmake-build-*/ /_deps/ /CMakeFiles CMakeCache.txt From aade3e72cc4460936730f686b163a68e68f4f90d Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:31:48 +0200 Subject: [PATCH 67/91] Add CMake presets as the canonical test entry points; drive CI with them CMakePresets.json defines the configure/build/test presets for every test leg: tests / tests-debug (the blocking functional matrix, issue #32), sanitize (the D7 ASan/UBSan leg, env and Death|CurrentlyCorrupts exclusions moved into the test preset), and mdsplus (backend + baked model tree, MDSPLUS_MODELS_PATH pointed at the bake). Test presets for the unit/integration tiers give the fast hermetic loop a name, and every label-filtered preset sets noTestsAction=error so an empty selection fails instead of passing silently (same concern as issue #31). contract-tests.yml, sanitizers.yml and mdsplus-contract.yml now call the presets instead of hand-assembled flags, so a local 'ctest --preset ...' reproduces a CI leg exactly. Only the DD version pin stays in mdsplus-contract.yml, next to the cache key it feeds. The UDA leg is untouched on purpose: its configure lives in docker/uda/run.sh, part of the pinned reference stack whose image cache is keyed on that file. Verified locally: unit (96), integration (163) and tests (263) presets all pass. --- .github/workflows/contract-tests.yml | 25 +++--- .github/workflows/mdsplus-contract.yml | 17 ++-- .github/workflows/sanitizers.yml | 28 +++--- CMakePresets.json | 118 +++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 39 deletions(-) create mode 100644 CMakePresets.json diff --git a/.github/workflows/contract-tests.yml b/.github/workflows/contract-tests.yml index 1787dfdd..64ddd3b1 100644 --- a/.github/workflows/contract-tests.yml +++ b/.github/workflows/contract-tests.yml @@ -15,7 +15,7 @@ on: jobs: contract-functional: - name: Contract tests (Linux, ${{ matrix.build_type }}, blocking) + name: Contract tests (Linux, ${{ matrix.preset }}, blocking) runs-on: ubuntu-latest strategy: fail-fast: false @@ -23,8 +23,14 @@ jobs: # Both build modes (issue #32): the dlopen-failure plugin pin is # build-mode dependent — assert-abort death test in Debug, swallowed # assert tripwire under NDEBUG (RelWithDebInfo) — so each branch needs - # its own leg or one of them never runs anywhere. - build_type: [RelWithDebInfo, Debug] + # its own leg or one of them never runs anywhere. The presets + # (CMakePresets.json) are the single source of truth for flags and + # binary dirs; builddir here only feeds the label-partition check. + include: + - preset: tests # RelWithDebInfo + builddir: build + - preset: tests-debug + builddir: build-debug steps: - name: Checkout code @@ -38,20 +44,15 @@ jobs: sudo apt-get install -y ninja-build libhdf5-dev libboost-filesystem-dev - name: Configure (tests on, no Python bindings) - run: > - cmake -S . -B build -G Ninja - -DAL_BUILD_TESTS=ON - -DAL_PYTHON_BINDINGS=OFF - -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} + run: cmake --preset ${{ matrix.preset }} - name: Build - run: cmake --build build + run: cmake --build --preset ${{ matrix.preset }} # Issue #31: an empty `ctest -L unit` exits 0, so a broken label # partition is invisible to the test run below — check it explicitly. - name: Verify unit/integration label partition - run: bash tests/contract/check_label_partition.sh build/tests/contract + run: bash tests/contract/check_label_partition.sh ${{ matrix.builddir }}/tests/contract - name: Run the whole test suite - working-directory: build - run: ctest --output-on-failure + run: ctest --preset ${{ matrix.preset }} diff --git a/.github/workflows/mdsplus-contract.yml b/.github/workflows/mdsplus-contract.yml index 8222b82c..ca035693 100644 --- a/.github/workflows/mdsplus-contract.yml +++ b/.github/workflows/mdsplus-contract.yml @@ -68,17 +68,15 @@ jobs: key: mdsplus-model-${{ env.DD_VERSION }}-${{ hashFiles('common/cmake/ALBuildDataDictionary.cmake', 'models/mdsplus/CMakeLists.txt') }} - name: Configure (bakes the DD model tree on a cache miss) + # The `mdsplus` preset (CMakePresets.json) carries the backend/model/ + # test flags and the build-mdsplus binary dir; only the DD pin stays + # here, next to the cache key it feeds. if: steps.cache-model.outputs.cache-hit != 'true' run: > docker run --rm --platform linux/arm64 -v "$PWD":/workspace -w /workspace "${IMAGE_TAG}" bash -c " - cmake -B build-mdsplus -G Ninja - -DAL_BACKEND_MDSPLUS=ON - -DAL_BUILD_MDSPLUS_MODELS=ON - -DAL_BUILD_TESTS=ON - -DDD_VERSION=${DD_VERSION} - -DCMAKE_BUILD_TYPE=Release + cmake --preset mdsplus -DDD_VERSION=${DD_VERSION} " - name: Build contract suite @@ -86,7 +84,7 @@ jobs: docker run --rm --platform linux/arm64 -v "$PWD":/workspace -w /workspace "${IMAGE_TAG}" bash -c " - cmake --build build-mdsplus --target contract_tests -j\$(nproc) + cmake --build --preset mdsplus " - name: Verify baked DD model tree is present (cache-hit and cache-miss paths) @@ -108,12 +106,13 @@ jobs: echo "Baked DD model tree complete under build-mdsplus/models/mdsplus." - name: Run MDSplus contract suite + # The `mdsplus` test preset selects the tier (-L mdsplus) and points + # MDSPLUS_MODELS_PATH at the baked model tree under build-mdsplus/. run: > docker run --rm --platform linux/arm64 -v "$PWD":/workspace -w /workspace - -e MDSPLUS_MODELS_PATH=/workspace/build-mdsplus/models/mdsplus "${IMAGE_TAG}" bash -c " - cd build-mdsplus/tests/contract && ctest -L mdsplus --output-on-failure + ctest --preset mdsplus " - name: Note diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index 41a16e2d..0d7b7d5c 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -32,23 +32,19 @@ jobs: sudo apt-get install -y ninja-build libhdf5-dev libboost-filesystem-dev - name: Configure (sanitized, tests on, no Python bindings) - # AL_CONTRACT_SANITIZE instruments both libal and the test binary. Python - # bindings are off: a sanitized libal must not be linked into a wheel - # whose bindings lack the ASan runtime (see tests/contract/CMakeLists.txt). - run: > - cmake -S . -B build-san -G Ninja - -DAL_BUILD_TESTS=ON - -DAL_CONTRACT_SANITIZE=ON - -DAL_PYTHON_BINDINGS=OFF - -DCMAKE_BUILD_TYPE=RelWithDebInfo + # The `sanitize` preset (CMakePresets.json) sets AL_CONTRACT_SANITIZE, + # which instruments both libal and the test binary. Python bindings are + # off: a sanitized libal must not be linked into a wheel whose bindings + # lack the ASan runtime (see tests/contract/CMakeLists.txt). + run: cmake --preset sanitize - name: Build contract suite - run: cmake --build build-san --target contract_tests + run: cmake --build --preset sanitize - name: Run contract suite under sanitizers - working-directory: build-san - # Scope to the contract suite; halt/abort_on_error=0 so the whole suite - # runs and reports everything at once (informational leg). Two exclusions: + # The `sanitize` test preset carries the ASAN/LSAN/UBSAN_OPTIONS + # (halt/abort_on_error=0 so the whole suite runs and reports everything + # at once — informational leg) and two exclusions: # * `Death` tests deliberately crash child processes (asserting today's # crash-class defects) — not a memory-safety target, and ASan would # report the intentional crash as an error. @@ -58,11 +54,7 @@ jobs: # fail here. They guard against rot in the blocking functional leg # (contract-tests.yml). Real leaks/UAF in the ordinary paths still # surface. - env: - ASAN_OPTIONS: detect_leaks=1:halt_on_error=0:abort_on_error=0 - LSAN_OPTIONS: suppressions=${{ github.workspace }}/tests/contract/sanitizer_suppressions.txt:print_suppressions=1 - UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=0 - run: ctest -L contract -E "Death|CurrentlyCorrupts" --output-on-failure + run: ctest --preset sanitize - name: Note if: always() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 00000000..96174b68 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,118 @@ +{ + "version": 3, + "cmakeMinimumRequired": { "major": 3, "minor": 21, "patch": 0 }, + "configurePresets": [ + { + "name": "tests", + "displayName": "Test build (RelWithDebInfo)", + "description": "Smoke + contract suites against the public C ABI; no Python bindings. Mirrors the blocking CI leg (contract-tests.yml).", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build", + "cacheVariables": { + "AL_BUILD_TESTS": "ON", + "AL_PYTHON_BINDINGS": "OFF", + "CMAKE_BUILD_TYPE": "RelWithDebInfo" + } + }, + { + "name": "tests-debug", + "displayName": "Test build (Debug)", + "description": "Debug leg of contract-tests.yml: the dlopen-failure plugin pin is build-mode dependent (issue #32), so CI runs both modes.", + "inherits": "tests", + "binaryDir": "${sourceDir}/build-debug", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } + }, + { + "name": "sanitize", + "displayName": "Sanitized test build (ASan/UBSan)", + "description": "AL_CONTRACT_SANITIZE=ON instruments libal and contract_tests (TEST_STRATEGY.md D7). Mirrors sanitizers.yml. Never combine with a wheel build: the bindings would link a sanitized libal without the ASan runtime.", + "inherits": "tests", + "binaryDir": "${sourceDir}/build-san", + "cacheVariables": { "AL_CONTRACT_SANITIZE": "ON" } + }, + { + "name": "mdsplus", + "displayName": "MDSplus test build (bakes the DD model tree)", + "description": "MDSplus backend + baked model tree + tests. Pass -DDD_VERSION= to pin the DD (models/mdsplus defaults to 'main'; mdsplus-contract.yml pins its own). Downloads the DD at configure time.", + "inherits": "tests", + "binaryDir": "${sourceDir}/build-mdsplus", + "cacheVariables": { + "AL_BACKEND_MDSPLUS": "ON", + "AL_BUILD_MDSPLUS_MODELS": "ON", + "CMAKE_BUILD_TYPE": "Release" + } + } + ], + "buildPresets": [ + { "name": "tests", "configurePreset": "tests" }, + { "name": "tests-debug", "configurePreset": "tests-debug" }, + { + "name": "sanitize", + "configurePreset": "sanitize", + "targets": [ "contract_tests" ] + }, + { + "name": "mdsplus", + "configurePreset": "mdsplus", + "targets": [ "contract_tests" ] + } + ], + "testPresets": [ + { + "name": "unit", + "displayName": "Hermetic inner loop: pure lookups, Memory backend, plugin registry", + "configurePreset": "tests", + "filter": { "include": { "label": "^unit$" } }, + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error" } + }, + { + "name": "integration", + "displayName": "On-disk tier: HDF5 / ASCII / Flexbuffers backends", + "configurePreset": "tests", + "filter": { "include": { "label": "^integration$" } }, + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error" } + }, + { + "name": "tests", + "displayName": "Everything registered in the test build (contract + smoke)", + "configurePreset": "tests", + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error" } + }, + { + "name": "tests-debug", + "displayName": "Everything, Debug build (issue #32 leg)", + "configurePreset": "tests-debug", + "inherits": "tests" + }, + { + "name": "sanitize", + "displayName": "Contract suite under ASan/UBSan (informational CI leg)", + "configurePreset": "sanitize", + "filter": { + "include": { "label": "^contract$" }, + "exclude": { "name": "Death|CurrentlyCorrupts" } + }, + "environment": { + "ASAN_OPTIONS": "detect_leaks=1:halt_on_error=0:abort_on_error=0", + "LSAN_OPTIONS": "suppressions=${sourceDir}/tests/contract/sanitizer_suppressions.txt:print_suppressions=1", + "UBSAN_OPTIONS": "print_stacktrace=1:halt_on_error=0" + }, + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error" } + }, + { + "name": "mdsplus", + "displayName": "MDSplus tier against the baked model tree", + "configurePreset": "mdsplus", + "filter": { "include": { "label": "^mdsplus$" } }, + "environment": { + "MDSPLUS_MODELS_PATH": "${sourceDir}/build-mdsplus/models/mdsplus" + }, + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error" } + } + ] +} From 10bda17c304d5e3ebe7171102102ece794bbfb16 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:31:59 +0200 Subject: [PATCH 68/91] Document how to run the test suites (tests/README.md + routing stubs) tests/README.md is the operational front door: what lives where (smoke / contract / Python suites), the CTest tier table with per-tier requirements, the preset commands that reproduce each CI leg, how to read DISABLED_+tripwire (xfail) and GTEST_SKIP (divergence / unconfigured backend) output, and the fixture tools. It routes to TEST_STRATEGY.md for rationale and TRACEABILITY.md for the coverage ledger rather than repeating them. README.md and CONTRIBUTING.md gain three-line 'running the tests' stubs that link to it. --- CONTRIBUTING.md | 6 +++- README.md | 11 +++++++ tests/README.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 tests/README.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69e18b1e..c7d8ceb3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,11 @@ profile and create your own feature branch off of the latest develop commit. Make sure to regularly sync your branch with the latest commits from `develop` (find instructions [here](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork)); -5. when your development is ready, create a pull request (find instructions +5. make sure the test suite passes before submitting: see +[tests/README.md](tests/README.md) for how to build and run it +(`cmake --preset tests && cmake --build --preset tests && ctest --preset tests` +is the same run as the blocking CI check); +6. when your development is ready, create a pull request (find instructions [here](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork)). diff --git a/README.md b/README.md index 70a0480b..673700e7 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,17 @@ cmake --build build --target install See [Developer Guide](docs/source/developers/index.rst) for build instructions. +### Running the tests + +The C/C++ and Python test suites, their CTest tiers, and the CMake presets +that drive them (locally and in CI) are documented in +[tests/README.md](tests/README.md). The short version: + +```bash +cmake --preset tests && cmake --build --preset tests +ctest --preset unit # fast hermetic loop; `ctest --preset tests` for everything +``` + ## Using IMAS-Core with High-Level Languages diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..fb4b8431 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,79 @@ +# Running the tests + +Everything here is driven through the presets in [`CMakePresets.json`](../CMakePresets.json) +— the same entry points CI uses, so a local run reproduces a CI leg exactly. + +```sh +cmake --preset tests # configure (AL_BUILD_TESTS=ON, no Python bindings) +cmake --build --preset tests # build libal + test binaries +ctest --preset unit # fast hermetic inner loop (< 1 s) +ctest --preset tests # everything the test build registers +``` + +## What lives where + +| Suite | Location | Framework | Purpose | +|---|---|---|---| +| Smoke tests | `tests/testlowlevel.cpp`, `tests/testlowlevel_c.c` | none (plain executables) | End-to-end C ABI exercise; the `.c` build doubles as a compile canary that `al_lowlevel.h` stays C-compatible. | +| Contract suite | `tests/contract/` | GoogleTest + CTest | Pins the behavior of the public C ABI (`al_lowlevel.h` + `al_const.h`) capability by capability. The *why* is [`TEST_STRATEGY.md`](../TEST_STRATEGY.md); the coverage ledger is [`tests/contract/TRACEABILITY.md`](contract/TRACEABILITY.md). | +| Python binding tests | `python/tests/` | pytest | Bindings-level checks (import, exceptions, `imasdef` constants, HDF5 permissions). | + +## Test tiers (CTest labels) + +Every contract test carries exactly one tier, assigned post-discovery by +`tests/contract/assign_contract_labels.cmake.in` (the tier definitions live +there) and verified disjoint by `tests/contract/check_label_partition.sh`. + +| `ctest --preset …` | Label | What runs | Needs | +|---|---|---|---| +| `unit` | `unit` | Pure lookups (`Introspection.*`), the in-RAM Memory backend matrix, plugin registry lifecycle + ABI death tests, URI ownership | Nothing — hermetic, no disk | +| `integration` | `integration` | HDF5 / ASCII / Flexbuffers round trips on disk | Disk only | +| `tests` / `tests-debug` | *(all)* | Contract suite + smoke tests, in the build mode CI's blocking legs use (both modes matter — issue #32) | — | +| `sanitize` | `contract` | The suite under ASan/UBSan (`AL_CONTRACT_SANITIZE=ON`), excluding `Death` and `CurrentlyCorrupts` cases — see the comments in `sanitizers.yml` for why | Linux for leak detection; on macOS prepend `ASAN_OPTIONS=detect_leaks=0` | +| `mdsplus` | `mdsplus` | MDSplus tier against the DD model tree the `mdsplus` configure preset bakes | MDSplus installed; configure downloads the DD (`-DDD_VERSION=` to pin) | +| *(no preset)* | `uda` | UDA tier against the pinned reference stack | `docker/uda/run.sh` builds and runs it inside the container image (see `docker/uda/README.md`); there is deliberately no host preset | + +Each test preset (except the run-everything ones' smoke tests) fails on an +empty selection (`noTestsAction: error`), so a broken label partition can't +pass silently. + +For a one-off finer cut, plain CTest still works from the build directory: +`ctest -R Introspection`, `ctest -L integration -E Ascii`, etc. + +## Reading the output + +The suite encodes its own status vocabulary (full definitions in the +[TRACEABILITY.md preamble](contract/TRACEABILITY.md)): + +- **`DISABLED_…` test + a paired `CurrentlyCorrupts`/tripwire test** — an + *xfail*: the disabled test asserts the *correct* behavior of a known defect, + the tripwire pins today's wrong behavior. The tripwire going red means + someone fixed the defect — flip the pair, don't "fix the test". +- **`GTEST_SKIP()`** — either a legitimate backend *divergence* (the test + shape doesn't transfer to that backend) or an unconfigured optional backend + (e.g. `MDSPLUS_MODELS_PATH` / `UDA_HOST` unset). The skip message says which. + +## CI legs + +| Workflow | Preset(s) | Gate | +|---|---|---| +| `contract-tests.yml` | `tests`, `tests-debug` | **Blocking** on every PR | +| `sanitizers.yml` | `sanitize` | Informational (D7) | +| `mdsplus-contract.yml` | `mdsplus` (in Docker, DD pinned in the workflow) | Informational | +| `uda-contract.yml` | — (`docker/uda/run.sh`) | Informational | + +## Python tests + +```sh +pip install .[test] +pytest # testpaths is rooted at python/tests in pyproject.toml +``` + +## Fixture tools + +Two build-gated helper executables prepare states that must *not* be reachable +through the public C ABI (out-of-band file/tree surgery); the tests invoke +them as subprocesses: `hdf5_fixture_tool.cpp` (rewrites the stored +HDF5 backend version) and `mdsplus_fixture_tool.cpp` (rewrites the model +tree's version node). They are fixture *preparation*, never assertion — the +contract suite asserts only through the C ABI. From b10520653495efed3ab78fd4a156b18f97fa1723 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:53:30 +0200 Subject: [PATCH 69/91] Make ./test delegate to the CMake presets instead of duplicating flags The root ./test dispatcher (the one-verb wrapper for the Docker-backed MDSplus/UDA tiers) predates CMakePresets.json and hand-assembled its own configure lines, which had already drifted from CI: 'common' built Release without AL_PYTHON_BINDINGS=OFF while the blocking leg runs RelWithDebInfo. Its host and in-container recipes now call the same tests/mdsplus presets CI uses, so the script only adds orchestration (image builds, bind mounts, the summary table) and there is exactly one place flags live. Only the DD pin stays inline, matching mdsplus-contract.yml. The UDA verb is untouched (uda-run.sh owns its configure by design). tests/README.md now documents ./test in the quick start; .gitignore gains .pytest_cache/. Verified: ./test common passes end-to-end (263 tests). --- .gitignore | 1 + test | 32 ++++++++++++++++++-------------- tests/README.md | 13 ++++++++++++- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 667153f8..fd31d7a2 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ python/lib/ /UDA-*/ .idea /graphify-out +.pytest_cache/ diff --git a/test b/test index 39e43a64..6c5c6444 100755 --- a/test +++ b/test @@ -20,6 +20,11 @@ # Boost::filesystem installed). AL_BACKEND_MDSPLUS/UDA default OFF, so their # contract tiers compile out and this ctest runs only the common-backend tests. # +# Flags live in CMakePresets.json, not here: every tier delegates to the same +# `tests` / `mdsplus` presets CI runs, so this script only adds orchestration +# (Docker images, bind mounts, the summary table) on top — never a second copy +# of the configure line. +# # The mdsplus/uda backing images are built automatically on first use and reused # after that (rebuild by hand with `docker build`, or `docker image rm` to # force it). Both legs bind-mount the repo at /workspace, so they always @@ -102,14 +107,13 @@ print_summary() { 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 + # The `tests` presets are the same entry points as CI's blocking leg + # (contract-tests.yml): RelWithDebInfo, Ninja, no Python bindings, build/. + # The && chain returns non-zero on the first failing step. + cd "$REPO_ROOT" \ + && cmake --preset tests \ + && cmake --build --preset tests \ + && ctest --preset tests } @@ -118,15 +122,15 @@ run_common() { run_mdsplus() { section "MDS-PLUS" "Docker: bake DD-4.1.1 model tree, build, ctest -L mdsplus" ensure_image imas-core-mdsplus:dev "$REPO_ROOT/docker/mdsplus" + # The `mdsplus` presets carry the backend/model/test flags, the + # build-mdsplus binary dir, the tier filter and MDSPLUS_MODELS_PATH; only + # the DD pin is passed here (mdsplus-contract.yml pins its own the same way). docker run --rm --platform "$PLATFORM" \ -v "$REPO_ROOT":/workspace -w /workspace \ - -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 + cmake --preset mdsplus -DDD_VERSION=4.1.1 && + cmake --build --preset mdsplus && + ctest --preset mdsplus ' } diff --git a/tests/README.md b/tests/README.md index fb4b8431..b7837e6b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -10,6 +10,17 @@ ctest --preset unit # fast hermetic inner loop (< 1 s) ctest --preset tests # everything the test build registers ``` +For the tiers that need a Docker backend (MDSplus, UDA), the [`./test`](../test) +dispatcher at the repo root wraps the image build + `docker run` recipes behind +one verb and delegates all cmake/ctest flags to these same presets: + +```sh +./test common # host-native: the three preset commands above +./test mdsplus # Docker: bake the DD model tree, run the mdsplus tier +./test uda # Docker: assemble the UDA reference stack, run the uda tier +./test all # every tier + a PASS/FAIL summary table +``` + ## What lives where | Suite | Location | Framework | Purpose | @@ -31,7 +42,7 @@ there) and verified disjoint by `tests/contract/check_label_partition.sh`. | `tests` / `tests-debug` | *(all)* | Contract suite + smoke tests, in the build mode CI's blocking legs use (both modes matter — issue #32) | — | | `sanitize` | `contract` | The suite under ASan/UBSan (`AL_CONTRACT_SANITIZE=ON`), excluding `Death` and `CurrentlyCorrupts` cases — see the comments in `sanitizers.yml` for why | Linux for leak detection; on macOS prepend `ASAN_OPTIONS=detect_leaks=0` | | `mdsplus` | `mdsplus` | MDSplus tier against the DD model tree the `mdsplus` configure preset bakes | MDSplus installed; configure downloads the DD (`-DDD_VERSION=` to pin) | -| *(no preset)* | `uda` | UDA tier against the pinned reference stack | `docker/uda/run.sh` builds and runs it inside the container image (see `docker/uda/README.md`); there is deliberately no host preset | +| *(no preset)* | `uda` | UDA tier against the pinned reference stack | `./test uda` (or `docker/uda/run.sh` inside the container — see `docker/uda/README.md`); there is deliberately no host preset | Each test preset (except the run-everything ones' smoke tests) fails on an empty selection (`noTestsAction: error`), so a broken label partition can't From 690f5392a58e4c73131d6b723c72105e9fbdcc9f Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:12:59 +0200 Subject: [PATCH 70/91] Make contract fixtures linkable outside the test tree (issue #51) Extract equilibrium_seed.h and the GTest-free subset of al_contract.h's generic typed round-trip helpers it needs (DType, write_data, read_data, element_count) into a new top-level fixtures/ directory, wired as an INTERFACE library (al_contract_fixtures) added unconditionally in the root CMakeLists.txt, independent of AL_BUILD_TESTS. This lets a future benchmarks/ target (PRD #50) link the exact same workload the contract suite pins, with no fixture drift and no duplication. tests/contract/al_contract.h now includes the shared header instead of redefining those symbols; contract_tests links the new target so unqualified #include "equilibrium_seed.h" in the existing test files keeps resolving. An opt-in throwaway executable (AL_BUILD_FIXTURE_SMOKE, off by default) proves the fixture builds and round-trips against the Memory backend from a target outside tests/, with no GoogleTest link. --- CMakeLists.txt | 12 ++ fixtures/CMakeLists.txt | 44 ++++++++ fixtures/al_contract_abi.h | 106 ++++++++++++++++++ .../contract => fixtures}/equilibrium_seed.h | 2 +- fixtures/fixture_smoke.cpp | 77 +++++++++++++ tests/contract/CMakeLists.txt | 2 +- tests/contract/TRACEABILITY.md | 4 +- tests/contract/al_contract.h | 89 ++------------- tests/contract/test_uda.cpp | 2 +- 9 files changed, 253 insertions(+), 85 deletions(-) create mode 100644 fixtures/CMakeLists.txt create mode 100644 fixtures/al_contract_abi.h rename {tests/contract => fixtures}/equilibrium_seed.h (99%) create mode 100644 fixtures/fixture_smoke.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7735eaf4..83e20152 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,6 +37,13 @@ option(AL_PYTHON_BINDINGS "Build Python bindings" OFF) option(AL_BUILD_TESTS "Build the C/C++ low-level ABI smoke tests (tests/)" OFF) +# The fixtures/al_contract_fixtures INTERFACE library itself is always +# available (see below) so it is genuinely usable outside AL_BUILD_TESTS; this +# only gates the throwaway demonstration executable (issue #51 acceptance +# criterion 3), off by default like every other extra target in this file. +option(AL_BUILD_FIXTURE_SMOKE + "Build a throwaway executable proving tests/contract fixtures are linkable outside the test tree" OFF) + # When ON, the al C++ library is not built here; instead it is located with # find_package(al-core CONFIG). Enables a two-stage build where libimas-core # (the C/C++ library) and imas-core (the Python wrapper) are packaged @@ -213,6 +220,11 @@ endif() add_subdirectory(src) +# Shared contract fixtures (issue #51): the equilibrium seed and the ABI +# helpers it needs, usable outside tests/ (e.g. by a future benchmarks/ +# tree). Added unconditionally so it never depends on AL_BUILD_TESTS. +add_subdirectory(fixtures) + # Dummy Executable # ############################################################################## set( EXE_FLAG ) diff --git a/fixtures/CMakeLists.txt b/fixtures/CMakeLists.txt new file mode 100644 index 00000000..c674ed3d --- /dev/null +++ b/fixtures/CMakeLists.txt @@ -0,0 +1,44 @@ +######################################################################################################################## +# Shared contract fixtures (issue #51). +# +# The realistic equilibrium seed (equilibrium_seed.h) and the generic typed +# ABI round-trip helpers it needs (al_contract_abi.h), factored out of +# tests/contract/ so a build target outside the test tree -- the future +# benchmarks/ tree (PRD #50, issues #52/#53) -- can link the exact same +# workload the contract suite pins. No fixture drift between what is tested +# and what is measured (spec #50, Option C). +# +# Header-only and carries no GoogleTest dependency (unlike +# tests/contract/al_contract.h), so a non-test binary can link it for free. +# Added unconditionally (not gated by AL_BUILD_TESTS) so it is genuinely +# usable independent of the test build. + +add_library(al_contract_fixtures INTERFACE) +target_include_directories(al_contract_fixtures INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(al_contract_fixtures INTERFACE al) +target_compile_features(al_contract_fixtures INTERFACE cxx_std_17) + +######################################################################################################################## +# Throwaway proof (issue #51 acceptance criterion 3), opt-in via +# AL_BUILD_FIXTURE_SMOKE -- off by default like every other extra target in +# the root CMakeLists.txt (AL_BUILD_TESTS, AL_BUILD_BENCHMARKS), so an +# ordinary/minimal build (e.g. the Python wheel) never pays for it. The +# al_contract_fixtures library above stays unconditional regardless: that is +# the actual "usable outside the test tree" requirement, this executable is +# just a demonstration of it. +# +# A minimal translation unit outside tests/ that includes the fixture, builds, +# and round-trips it against a real backend -- demonstrating the fixture is +# usable without AL_BUILD_TESTS or GoogleTest. Uses the always-on, zero-I/O +# Memory backend so this needs no on-disk setup and no optional backend. +if(AL_BUILD_FIXTURE_SMOKE) + add_executable(al_contract_fixtures_smoke fixture_smoke.cpp) + target_link_libraries(al_contract_fixtures_smoke PRIVATE al_contract_fixtures) + add_dependencies(al_contract_fixtures_smoke al) + target_compile_features(al_contract_fixtures_smoke PRIVATE cxx_std_17) + + if(NOT WIN32 AND NOT APPLE) + # stdc++fs is only needed on Linux with older GCC (std::filesystem in the smoke TU). + target_link_libraries(al_contract_fixtures_smoke PRIVATE stdc++fs) + endif() +endif() diff --git a/fixtures/al_contract_abi.h b/fixtures/al_contract_abi.h new file mode 100644 index 00000000..8f4d8933 --- /dev/null +++ b/fixtures/al_contract_abi.h @@ -0,0 +1,106 @@ +// Generic typed round-trip helpers over al_write_data / al_read_data +// (issue #51). Factored out of tests/contract/al_contract.h so a build target +// outside tests/contract/ -- notably a future benchmarks/ tree (PRD #50, issue +// #52) -- can link the exact same write_data/read_data machinery +// equilibrium_seed.h uses, without also pulling in GoogleTest. tests/contract/ +// al_contract.h includes this header for its own copy of these helpers, so +// there is exactly one definition either way. +// +// Binds only to the `extern "C"` surface (al_lowlevel.h + al_const.h), per +// TEST_STRATEGY.md decision D1: no C++ internals, no Python bindings. + +#ifndef AL_CONTRACT_ABI_H +#define AL_CONTRACT_ABI_H + +#include +#include + +#include +#include +#include +#include + +namespace al_contract { + +// --- datatype descriptor: element C++ type -> ABI datatype id + label ------- +template +struct DType; +template <> +struct DType { + static constexpr int value = CHAR_DATA; + static constexpr const char* name = "CHAR"; +}; +template <> +struct DType { + static constexpr int value = INTEGER_DATA; + static constexpr const char* name = "INTEGER"; +}; +template <> +struct DType { + static constexpr int value = DOUBLE_DATA; + static constexpr const char* name = "DOUBLE"; +}; +template <> +struct DType> { + static constexpr int value = COMPLEX_DATA; + static constexpr const char* name = "COMPLEX"; +}; + +inline std::size_t element_count(const std::vector& shape) { + std::size_t n = 1; + for (int d : shape) n *= static_cast(d); + return n; +} + +// --- generic write ---------------------------------------------------------- +// al_write_data takes a non-const void* and int* size but does not mutate +// them for a write; the const_casts keep the caller's data/shape const. +template +al_status_t write_data(int ctx, const char* field, const std::vector& shape, + const std::vector& data) { + const int dim = static_cast(shape.size()); + int* size = dim ? const_cast(shape.data()) : nullptr; + return al_write_data(ctx, field, "", const_cast(data.data()), + DType::value, dim, size); +} + +// --- generic read ----------------------------------------------------------- +// Honors the two ownership regimes the ABI uses (mirrors testlowlevel.cpp): +// dim == 0 : the core writes into the caller's buffer; nothing to free. +// dim >= 1 : the core malloc's the buffer and returns it via *data; the +// caller owns it, so we copy out and free. +// On success out_shape is the shape the core reported (scalar -> empty) and +// out_data holds element_count(out_shape) elements. +template +al_status_t read_data(int ctx, const char* field, int expected_rank, + std::vector* out_shape, std::vector* out_data) { + int size[MAXDIM] = {0}; + + if (expected_rank == 0) { + T scalar{}; + void* buf = &scalar; + al_status_t s = + al_read_data(ctx, field, "", &buf, DType::value, 0, size); + if (s.code == 0) { + out_shape->clear(); + out_data->assign(1, scalar); + } + return s; + } + + void* buf = nullptr; + al_status_t s = al_read_data(ctx, field, "", &buf, DType::value, + expected_rank, size); + if (s.code == 0 && buf != nullptr) { + out_shape->assign(size, size + expected_rank); + const std::size_t n = element_count(*out_shape); + T* p = static_cast(buf); + out_data->assign(p, p + n); + } + free(buf); + return s; +} + +} // namespace al_contract + +#endif // AL_CONTRACT_ABI_H diff --git a/tests/contract/equilibrium_seed.h b/fixtures/equilibrium_seed.h similarity index 99% rename from tests/contract/equilibrium_seed.h rename to fixtures/equilibrium_seed.h index 07fa60d5..182ba10f 100644 --- a/tests/contract/equilibrium_seed.h +++ b/fixtures/equilibrium_seed.h @@ -38,7 +38,7 @@ #ifndef AL_CONTRACT_EQUILIBRIUM_SEED_H #define AL_CONTRACT_EQUILIBRIUM_SEED_H -#include "al_contract.h" +#include "al_contract_abi.h" #include #include diff --git a/fixtures/fixture_smoke.cpp b/fixtures/fixture_smoke.cpp new file mode 100644 index 00000000..dddd0aef --- /dev/null +++ b/fixtures/fixture_smoke.cpp @@ -0,0 +1,77 @@ +// Throwaway proof (issue #51 acceptance criterion 3): a minimal translation +// unit living outside tests/ that includes equilibrium_seed.h, builds, and +// exercises it against a real backend through the public C ABI only -- no +// GoogleTest, no AL_BUILD_TESTS. Demonstrates that the fixture extracted by +// this issue is genuinely linkable from a target like the future +// benchmarks/ tree (PRD #50), not just from tests/contract/. +// +// Uses the always-on, zero-I/O Memory backend so this needs no on-disk setup +// and no optional backend enabled. + +#include "equilibrium_seed.h" + +#include +#include + +#include +#include +#include +#include + +int main() { + // The Memory backend stores nothing on disk, but URI construction still + // resolves the legacy "user" field as a base directory, so it must exist + // (mirrors tests/contract/al_contract.h's TempBase for the on-disk cases). + const std::filesystem::path base = + std::filesystem::temp_directory_path() / "al_contract_fixtures_smoke"; + std::filesystem::create_directories(base); + const std::string base_str = base.string(); + + char* uri = nullptr; + al_status_t s = al_build_uri_from_legacy_parameters( + MEMORY_BACKEND, /*pulse=*/1, /*run=*/0, /*user=*/base_str.c_str(), + /*tokamak=*/"test", /*version=*/"3", /*options=*/"", &uri); + if (s.code != 0) { + std::fprintf(stderr, "al_build_uri_from_legacy_parameters failed: %s\n", + s.message); + return 1; + } + + int pulse_ctx = -1; + s = al_begin_dataentry_action(uri, FORCE_CREATE_PULSE, &pulse_ctx); + free(uri); + if (s.code != 0) { + std::fprintf(stderr, "al_begin_dataentry_action failed: %s\n", s.message); + return 1; + } + + s = equilibrium_seed::write(pulse_ctx); + if (s.code != 0) { + std::fprintf(stderr, "equilibrium_seed::write failed: %s\n", s.message); + return 1; + } + + uint64_t observed_hash = 0; + s = equilibrium_seed::read_and_hash(pulse_ctx, &observed_hash); + if (s.code != 0) { + std::fprintf(stderr, "equilibrium_seed::read_and_hash failed: %s\n", + s.message); + return 1; + } + + al_close_pulse(pulse_ctx, CLOSE_PULSE); + + if (observed_hash != equilibrium_seed::expected_hash()) { + std::fprintf(stderr, + "structural hash mismatch: observed=%llu expected=%llu\n", + static_cast(observed_hash), + static_cast( + equilibrium_seed::expected_hash())); + return 1; + } + + std::printf("equilibrium seed round-tripped through the Memory backend " + "outside the test tree: hash=%llu\n", + static_cast(observed_hash)); + return 0; +} diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index 7f338d9e..b22a72bf 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -85,7 +85,7 @@ add_executable(contract_tests test_uda_unique_surface.cpp ) -target_link_libraries(contract_tests PRIVATE al GTest::gtest GTest::gtest_main) +target_link_libraries(contract_tests PRIVATE al al_contract_fixtures GTest::gtest GTest::gtest_main) add_dependencies(contract_tests al) target_compile_features(contract_tests PRIVATE cxx_std_17) diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 0693add1..de128f71 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -140,7 +140,9 @@ genuine defect (expected-fail). The deterministic, in-repo single-version equilibrium-seed generator (a scalar, a timebase-carrying 2-D array, and a constraints AOS — no committed binary blobs; oracle = a content hash recomputed from the generator functions -themselves, `tests/contract/equilibrium_seed.h`), round-tripped on the three +themselves, `fixtures/equilibrium_seed.h` — moved outside tests/contract/ by +issue #51 so a future benchmarks/ target can link it directly), round-tripped +on the three backends where AOS content is actually readable back within a session. | Capability | Inventory ref | Unit | Integ. | Test(s) | Status | diff --git a/tests/contract/al_contract.h b/tests/contract/al_contract.h index 71d44efb..616a5b55 100644 --- a/tests/contract/al_contract.h +++ b/tests/contract/al_contract.h @@ -12,6 +12,8 @@ #ifndef AL_CONTRACT_H #define AL_CONTRACT_H +#include "al_contract_abi.h" + #include #include @@ -20,8 +22,6 @@ #include #include #include -#include -#include #include #include #include @@ -345,30 +345,12 @@ inline al_status_t read_char_array(int ctx, const char* field, // itself the proof of the version-free property. This block generalizes the // two hand-written scalar/char helpers above to the whole // datatype × shape space. - -// --- datatype descriptor: element C++ type -> ABI datatype id + label ------- -template -struct DType; -template <> -struct DType { - static constexpr int value = CHAR_DATA; - static constexpr const char* name = "CHAR"; -}; -template <> -struct DType { - static constexpr int value = INTEGER_DATA; - static constexpr const char* name = "INTEGER"; -}; -template <> -struct DType { - static constexpr int value = DOUBLE_DATA; - static constexpr const char* name = "DOUBLE"; -}; -template <> -struct DType> { - static constexpr int value = COMPLEX_DATA; - static constexpr const char* name = "COMPLEX"; -}; +// +// DType/write_data/read_data/element_count (the subset equilibrium_seed.h also +// needs) live in al_contract_abi.h (issue #51), included above, so a build +// target outside tests/contract/ can link them without GoogleTest. The +// synthetic-buffer generator and its shape helper, used exclusively by this +// suite's own matrix tests, stay here. // The core's "this value means absent" sentinels (src/al_lowlevel.cpp:41-44). // A *scalar* write whose value equals its sentinel is silently dropped by @@ -420,12 +402,6 @@ inline std::vector shape_for_rank(int rank) { return s; } -inline std::size_t element_count(const std::vector& shape) { - std::size_t n = 1; - for (int d : shape) n *= static_cast(d); - return n; -} - template std::vector synth_buffer(std::size_t n) { std::vector v; @@ -434,55 +410,6 @@ std::vector synth_buffer(std::size_t n) { return v; } -// --- generic write ---------------------------------------------------------- -// al_write_data takes a non-const void* and int* size but does not mutate -// them for a write; the const_casts keep the caller's data/shape const. -template -al_status_t write_data(int ctx, const char* field, const std::vector& shape, - const std::vector& data) { - const int dim = static_cast(shape.size()); - int* size = dim ? const_cast(shape.data()) : nullptr; - return al_write_data(ctx, field, "", const_cast(data.data()), - DType::value, dim, size); -} - -// --- generic read ----------------------------------------------------------- -// Honors the two ownership regimes the ABI uses (mirrors testlowlevel.cpp): -// dim == 0 : the core writes into the caller's buffer; nothing to free. -// dim >= 1 : the core malloc's the buffer and returns it via *data; the -// caller owns it, so we copy out and free. -// On success out_shape is the shape the core reported (scalar -> empty) and -// out_data holds element_count(out_shape) elements. -template -al_status_t read_data(int ctx, const char* field, int expected_rank, - std::vector* out_shape, std::vector* out_data) { - int size[MAXDIM] = {0}; - - if (expected_rank == 0) { - T scalar{}; - void* buf = &scalar; - al_status_t s = - al_read_data(ctx, field, "", &buf, DType::value, 0, size); - if (s.code == 0) { - out_shape->clear(); - out_data->assign(1, scalar); - } - return s; - } - - void* buf = nullptr; - al_status_t s = al_read_data(ctx, field, "", &buf, DType::value, - expected_rank, size); - if (s.code == 0 && buf != nullptr) { - out_shape->assign(size, size + expected_rank); - const std::size_t n = element_count(*out_shape); - T* p = static_cast(buf); - out_data->assign(p, p + n); - } - free(buf); - return s; -} - } // namespace al_contract #endif // AL_CONTRACT_H diff --git a/tests/contract/test_uda.cpp b/tests/contract/test_uda.cpp index d9a5c8de..af862b88 100644 --- a/tests/contract/test_uda.cpp +++ b/tests/contract/test_uda.cpp @@ -115,7 +115,7 @@ TEST_F(UdaSmokeRoundTrip, ScalarSeededViaHdf5ReadsBackThroughUda) { // // Broadens the tracer bullet above from one scalar to the full equilibrium-seed // composite shape (static scalar + top-level timebase array + a real -// time_slice/profiles_1d/constraints AOS, tests/contract/equilibrium_seed.h — +// time_slice/profiles_1d/constraints AOS, fixtures/equilibrium_seed.h — // issues #4/#33/D5), reusing the seed's own generator and structural hash // oracle unchanged: seed via HDF5 (fixture setup, same role the baked model // tree plays for MDSplus), reopen the identical path through the UDA backend in From f24b119a37b2ddffb823a30d1874dbbc8635bb3f Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:35:07 +0200 Subject: [PATCH 71/91] Add benchmark harness skeleton: benchmarks/ + Google Benchmark + CTest smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vertical tracer bullet through the whole build+run+report path (NORTH_STAR.md §8.4): a new benchmarks/ tree, gated by AL_BUILD_BENCHMARKS and its own preset, off in the ordinary build and independent of AL_BUILD_TESTS. Google Benchmark is pinned via FetchContent (v1.9.5, no cluster module dependency). One benchmark drives the public C ABI end-to-end -- a single-leaf write-once-then-read against the in-RAM Memory backend on a tiny inline fixture, deliberately not the equilibrium seed yet -- emitting Google Benchmark JSON. A CTest smoke pair (mirroring tests/CMakeLists.txt's FIXTURES_SETUP/FIXTURES_REQUIRED shape) runs it with a one-iteration budget and asserts clean exit plus structurally parseable JSON, with no timing-value assertion. --- CMakeLists.txt | 11 +++ CMakePresets.json | 23 ++++++ benchmarks/CMakeLists.txt | 66 +++++++++++++++++ benchmarks/bench_write_read.cpp | 98 ++++++++++++++++++++++++++ benchmarks/validate_benchmark_json.cpp | 76 ++++++++++++++++++++ 5 files changed, 274 insertions(+) create mode 100644 benchmarks/CMakeLists.txt create mode 100644 benchmarks/bench_write_read.cpp create mode 100644 benchmarks/validate_benchmark_json.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 83e20152..668bc9d1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,6 +37,10 @@ option(AL_PYTHON_BINDINGS "Build Python bindings" OFF) option(AL_BUILD_TESTS "Build the C/C++ low-level ABI smoke tests (tests/)" OFF) +# Independent of AL_BUILD_TESTS: a benchmark "failing" means slower, not +# wrong, so it never gates the correctness build (NORTH_STAR.md §8.4). +option(AL_BUILD_BENCHMARKS "Build the Google Benchmark harness (benchmarks/)" OFF) + # The fixtures/al_contract_fixtures INTERFACE library itself is always # available (see below) so it is genuinely usable outside AL_BUILD_TESTS; this # only gates the throwaway demonstration executable (issue #51 acceptance @@ -246,6 +250,13 @@ if( AL_BUILD_TESTS ) add_subdirectory(tests) endif() +# Benchmark harness (opt-in, off in the ordinary build) +# ############################################################################## +if( AL_BUILD_BENCHMARKS ) + enable_testing() + add_subdirectory(benchmarks) +endif() + # Install # ############################################################################## diff --git a/CMakePresets.json b/CMakePresets.json index 96174b68..7e4b8a20 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -41,6 +41,18 @@ "AL_BUILD_MDSPLUS_MODELS": "ON", "CMAKE_BUILD_TYPE": "Release" } + }, + { + "name": "benchmarks", + "displayName": "Benchmark harness build (Release)", + "description": "Google Benchmark harness in benchmarks/ (issue #52); independent of AL_BUILD_TESTS and off in the ordinary build. A benchmark 'failing' means slower, not wrong, so this never gates the correctness suite.", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build-benchmarks", + "cacheVariables": { + "AL_BUILD_BENCHMARKS": "ON", + "AL_PYTHON_BINDINGS": "OFF", + "CMAKE_BUILD_TYPE": "Release" + } } ], "buildPresets": [ @@ -55,6 +67,10 @@ "name": "mdsplus", "configurePreset": "mdsplus", "targets": [ "contract_tests" ] + }, + { + "name": "benchmarks", + "configurePreset": "benchmarks" } ], "testPresets": [ @@ -113,6 +129,13 @@ }, "output": { "outputOnFailure": true }, "execution": { "noTestsAction": "error" } + }, + { + "name": "benchmarks", + "displayName": "Benchmark harness smoke: proves build+run+report, asserts no timing value", + "configurePreset": "benchmarks", + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error" } } ] } diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt new file mode 100644 index 00000000..96eeb60a --- /dev/null +++ b/benchmarks/CMakeLists.txt @@ -0,0 +1,66 @@ +project( al-benchmarks ) + +######################################################################################################################## +# Google Benchmark, pinned via FetchContent (NORTH_STAR.md §8.4). +# +# Unlike tests/contract's GoogleTest dependency, there is no find_package +# fallback here: the benchmark harness is a standalone tree gated by its own +# option (AL_BUILD_BENCHMARKS), and must not rely on a cluster module (e.g. +# the ITER SDCC `googlebenchmark` module) -- FetchContent keeps the pin +# identical on every machine the harness runs on. Pinned to the immutable +# commit for release v1.9.5 (a tag can be re-pointed). + +include(FetchContent) +FetchContent_Declare( + googlebenchmark + GIT_REPOSITORY https://github.com/google/benchmark.git + GIT_TAG 192ef10025eb2c4cdd392bc502f0c852196baa48 +) +set(BENCHMARK_ENABLE_TESTING OFF CACHE BOOL "" FORCE) +set(BENCHMARK_ENABLE_INSTALL OFF CACHE BOOL "" FORCE) +set(BENCHMARK_ENABLE_GTEST_TESTS OFF CACHE BOOL "" FORCE) +set(BENCHMARK_ENABLE_WERROR OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googlebenchmark) + +######################################################################################################################## +# The benchmark executable. +# +# One benchmark drives the public C ABI (al_lowlevel.h) end-to-end: a +# single-leaf write-once-then-read on a tiny inline fixture -- deliberately NOT +# the equilibrium seed yet (that lands with the real workload issue). It runs +# against the in-RAM Memory backend, so the harness needs no filesystem or +# optional backend to smoke-prove the whole build+run+report path. + +add_executable( al_benchmarks bench_write_read.cpp ) +target_link_libraries( al_benchmarks PRIVATE al benchmark::benchmark ) +add_dependencies( al_benchmarks al ) +target_compile_features( al_benchmarks PRIVATE cxx_std_17 ) + +######################################################################################################################## +# CTest smoke entry. +# +# Proves the harness builds, links libal, drives the C ABI, and produces +# parseable Google Benchmark JSON -- it asserts none of the reported timing +# values (a benchmark "failing" means slower, not wrong; NORTH_STAR.md §8.4). +# --benchmark_min_time=1x caps every registered benchmark at exactly one +# iteration, so the smoke run stays fast regardless of what benchmarks are +# added later. +# +# Follows the FIXTURES_SETUP/FIXTURES_REQUIRED shape tests/CMakeLists.txt uses +# for its own smoke registration: one test produces the JSON file, a second +# frameworkless executable validates it. + +set( _smoke_json ${CMAKE_CURRENT_BINARY_DIR}/benchmark_smoke.json ) + +add_test( NAME benchmark_smoke_run + COMMAND al_benchmarks --benchmark_min_time=1x + --benchmark_out=${_smoke_json} + --benchmark_out_format=json ) +set_tests_properties( benchmark_smoke_run PROPERTIES FIXTURES_SETUP BENCHMARK_SMOKE_JSON ) + +add_executable( al_benchmark_json_check validate_benchmark_json.cpp ) +target_compile_features( al_benchmark_json_check PRIVATE cxx_std_17 ) + +add_test( NAME benchmark_smoke_validate + COMMAND al_benchmark_json_check ${_smoke_json} ) +set_tests_properties( benchmark_smoke_validate PROPERTIES FIXTURES_REQUIRED BENCHMARK_SMOKE_JSON ) diff --git a/benchmarks/bench_write_read.cpp b/benchmarks/bench_write_read.cpp new file mode 100644 index 00000000..55197427 --- /dev/null +++ b/benchmarks/bench_write_read.cpp @@ -0,0 +1,98 @@ +// This is the vertical tracer bullet through the whole build+run+report path: +// one benchmark, driving strictly the public C ABI (al_lowlevel.h), against a +// tiny inline fixture -- deliberately NOT the equilibrium seed yet, so this +// benchmark stays independent of that fixture prefactor. It exists to prove +// the harness itself (FetchContent pin, CTest smoke wiring, JSON output), +// not to produce a meaningful performance number; the real hot-path scenarios +// (whole-IDS read/write, AOS traversal, ...) land separately (NORTH_STAR.md §8.4). + +#include +#include + +#include + +#include +#include +#include + +namespace { + +// The core attaches no DD semantics to either string (DD paths are opaque +// strings through the whole C ABI), so an arbitrary IDS name and leaf path +// exercise the same code path a real IDS would. +constexpr const char* kIds = "magnetics"; +constexpr const char* kField = "ids_properties/homogeneous_time"; +constexpr int kValue = 42; + +void CheckOk(const al_status_t& status, const char* what) { + if (status.code != 0) { + throw std::runtime_error(std::string(what) + " failed (code=" + + std::to_string(status.code) + "): " + + status.message); + } +} + +// Builds a fresh in-RAM Memory-backend URI. The Memory backend never touches +// disk, so the harness has no filesystem dependency to smoke-validate the +// build+run+report path -- but the legacy-parameter URI builder always +// resolves `user` into a filesystem path before the backend even sees it +// (al_context.cpp's DataEntryContext::parseUri), and an empty/unknown user +// falls through to a getpwnam() lookup that throws. An absolute-looking +// string (never actually opened for Memory) sidesteps that lookup, the same +// trick tests/contract/al_contract.h's TempBase uses for on-disk backends. +// `pulse` is bumped by the caller on every iteration so FORCE_CREATE_PULSE +// never collides with a still-registered in-process pulse from a prior one. +std::string MemoryUri(int pulse) { + char* uri = nullptr; + CheckOk(al_build_uri_from_legacy_parameters( + MEMORY_BACKEND, pulse, /*run=*/0, /*user=*/"/al-benchmarks", + /*tokamak=*/"bench", /*version=*/"3", /*options=*/"", &uri), + "al_build_uri_from_legacy_parameters"); + std::string result(uri ? uri : ""); + free(uri); + return result; +} + +} // namespace + +// Single-leaf write-once-then-read: each iteration opens a fresh Memory-backend +// data entry, writes one INTEGER scalar, reads it back, and closes the pulse. +// The whole sequence is the timed region for this tracer-bullet benchmark; the +// finer open-once/timed-read-only split NORTH_STAR.md §8.4 calls for arrives +// with the real equilibrium read benchmark. +void BM_WriteReadRoundTrip(benchmark::State& state) { + int pulse = 0; + for (auto _ : state) { + const std::string uri = MemoryUri(pulse++); + + int pulse_ctx = -1; + CheckOk(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &pulse_ctx), + "al_begin_dataentry_action"); + + int write_ctx = -1; + CheckOk(al_begin_global_action(pulse_ctx, kIds, "", WRITE_OP, &write_ctx), + "al_begin_global_action(write)"); + int value = kValue; + CheckOk(al_write_data(write_ctx, kField, "", &value, INTEGER_DATA, 0, + nullptr), + "al_write_data"); + CheckOk(al_end_action(write_ctx), "al_end_action(write)"); + + int read_ctx = -1; + CheckOk(al_begin_global_action(pulse_ctx, kIds, "", READ_OP, &read_ctx), + "al_begin_global_action(read)"); + int read_value = 0; + void* buf = &read_value; + int size[MAXDIM] = {0}; + CheckOk(al_read_data(read_ctx, kField, "", &buf, INTEGER_DATA, 0, size), + "al_read_data"); + CheckOk(al_end_action(read_ctx), "al_end_action(read)"); + benchmark::DoNotOptimize(read_value); + + CheckOk(al_close_pulse(pulse_ctx, CLOSE_PULSE), "al_close_pulse"); + } +} +BENCHMARK(BM_WriteReadRoundTrip); + +BENCHMARK_MAIN(); diff --git a/benchmarks/validate_benchmark_json.cpp b/benchmarks/validate_benchmark_json.cpp new file mode 100644 index 00000000..182f6676 --- /dev/null +++ b/benchmarks/validate_benchmark_json.cpp @@ -0,0 +1,76 @@ +// Frameworkless JSON-shape smoke check for the benchmark harness. +// +// The smoke test only needs proof that Google Benchmark emitted *parseable* +// JSON, never that any field or timing value is correct -- so this +// deliberately isn't a JSON library: a string/escape-aware brace-balance scan +// is enough to catch "crashed mid-write" or "not JSON at all" without adding +// a JSON dependency to a harness whose whole point is having none. + +#include +#include +#include +#include + +namespace { + +bool StructurallyBalanced(const std::string& text) { + int depth = 0; + bool in_string = false; + bool escaped = false; + for (char c : text) { + if (in_string) { + if (escaped) { + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + in_string = false; + } + continue; + } + if (c == '"') { + in_string = true; + } else if (c == '{' || c == '[') { + ++depth; + } else if (c == '}' || c == ']') { + --depth; + if (depth < 0) return false; + } + } + return !in_string && depth == 0; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc != 2) { + std::cerr << "usage: al_benchmark_json_check \n"; + return 1; + } + + std::ifstream file(argv[1], std::ios::binary); + if (!file) { + std::cerr << "al_benchmark_json_check: cannot open " << argv[1] << "\n"; + return 1; + } + std::ostringstream buffer; + buffer << file.rdbuf(); + const std::string content = buffer.str(); + + if (content.empty()) { + std::cerr << "al_benchmark_json_check: " << argv[1] << " is empty\n"; + return 1; + } + if (!StructurallyBalanced(content)) { + std::cerr << "al_benchmark_json_check: " << argv[1] + << " is not structurally balanced JSON\n"; + return 1; + } + if (content.find("\"benchmarks\"") == std::string::npos) { + std::cerr << "al_benchmark_json_check: " << argv[1] + << " has no top-level \"benchmarks\" key\n"; + return 1; + } + + return 0; +} From e2f2d19b0678b7bfc30e50a5724eb155ed640e11 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:21:08 +0200 Subject: [PATCH 72/91] Validate benchmark JSON with CMake parser --- benchmarks/CMakeLists.txt | 10 ++-- benchmarks/validate_benchmark_json.cmake | 27 +++++++++ benchmarks/validate_benchmark_json.cpp | 76 ------------------------ 3 files changed, 31 insertions(+), 82 deletions(-) create mode 100644 benchmarks/validate_benchmark_json.cmake delete mode 100644 benchmarks/validate_benchmark_json.cpp diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index 96eeb60a..82e4d38d 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -47,8 +47,8 @@ target_compile_features( al_benchmarks PRIVATE cxx_std_17 ) # added later. # # Follows the FIXTURES_SETUP/FIXTURES_REQUIRED shape tests/CMakeLists.txt uses -# for its own smoke registration: one test produces the JSON file, a second -# frameworkless executable validates it. +# for its own smoke registration: one test produces the JSON file, then CMake's +# built-in JSON parser validates it without adding another runtime dependency. set( _smoke_json ${CMAKE_CURRENT_BINARY_DIR}/benchmark_smoke.json ) @@ -58,9 +58,7 @@ add_test( NAME benchmark_smoke_run --benchmark_out_format=json ) set_tests_properties( benchmark_smoke_run PROPERTIES FIXTURES_SETUP BENCHMARK_SMOKE_JSON ) -add_executable( al_benchmark_json_check validate_benchmark_json.cpp ) -target_compile_features( al_benchmark_json_check PRIVATE cxx_std_17 ) - add_test( NAME benchmark_smoke_validate - COMMAND al_benchmark_json_check ${_smoke_json} ) + COMMAND ${CMAKE_COMMAND} -DINPUT=${_smoke_json} + -P ${CMAKE_CURRENT_SOURCE_DIR}/validate_benchmark_json.cmake ) set_tests_properties( benchmark_smoke_validate PROPERTIES FIXTURES_REQUIRED BENCHMARK_SMOKE_JSON ) diff --git a/benchmarks/validate_benchmark_json.cmake b/benchmarks/validate_benchmark_json.cmake new file mode 100644 index 00000000..9e06fe18 --- /dev/null +++ b/benchmarks/validate_benchmark_json.cmake @@ -0,0 +1,27 @@ +# Parse the Google Benchmark output rather than merely checking delimiter +# balance. CMake 3.21 is the repository minimum and provides string(JSON). + +if(NOT DEFINED INPUT) + message(FATAL_ERROR "benchmark JSON checker requires -DINPUT=") +endif() + +file(READ "${INPUT}" content) +if(content STREQUAL "") + message(FATAL_ERROR "benchmark JSON file is empty: ${INPUT}") +endif() + +string(JSON root_type ERROR_VARIABLE parse_error TYPE "${content}") +if(parse_error) + message(FATAL_ERROR "benchmark output is not parseable JSON: ${parse_error}") +endif() +if(NOT root_type STREQUAL "OBJECT") + message(FATAL_ERROR "benchmark JSON root is not an object") +endif() + +string(JSON benchmarks_type ERROR_VARIABLE benchmarks_error TYPE "${content}" benchmarks) +if(benchmarks_error) + message(FATAL_ERROR "benchmark JSON has no top-level 'benchmarks' key: ${benchmarks_error}") +endif() +if(NOT benchmarks_type STREQUAL "ARRAY") + message(FATAL_ERROR "benchmark JSON 'benchmarks' key is not an array") +endif() diff --git a/benchmarks/validate_benchmark_json.cpp b/benchmarks/validate_benchmark_json.cpp deleted file mode 100644 index 182f6676..00000000 --- a/benchmarks/validate_benchmark_json.cpp +++ /dev/null @@ -1,76 +0,0 @@ -// Frameworkless JSON-shape smoke check for the benchmark harness. -// -// The smoke test only needs proof that Google Benchmark emitted *parseable* -// JSON, never that any field or timing value is correct -- so this -// deliberately isn't a JSON library: a string/escape-aware brace-balance scan -// is enough to catch "crashed mid-write" or "not JSON at all" without adding -// a JSON dependency to a harness whose whole point is having none. - -#include -#include -#include -#include - -namespace { - -bool StructurallyBalanced(const std::string& text) { - int depth = 0; - bool in_string = false; - bool escaped = false; - for (char c : text) { - if (in_string) { - if (escaped) { - escaped = false; - } else if (c == '\\') { - escaped = true; - } else if (c == '"') { - in_string = false; - } - continue; - } - if (c == '"') { - in_string = true; - } else if (c == '{' || c == '[') { - ++depth; - } else if (c == '}' || c == ']') { - --depth; - if (depth < 0) return false; - } - } - return !in_string && depth == 0; -} - -} // namespace - -int main(int argc, char** argv) { - if (argc != 2) { - std::cerr << "usage: al_benchmark_json_check \n"; - return 1; - } - - std::ifstream file(argv[1], std::ios::binary); - if (!file) { - std::cerr << "al_benchmark_json_check: cannot open " << argv[1] << "\n"; - return 1; - } - std::ostringstream buffer; - buffer << file.rdbuf(); - const std::string content = buffer.str(); - - if (content.empty()) { - std::cerr << "al_benchmark_json_check: " << argv[1] << " is empty\n"; - return 1; - } - if (!StructurallyBalanced(content)) { - std::cerr << "al_benchmark_json_check: " << argv[1] - << " is not structurally balanced JSON\n"; - return 1; - } - if (content.find("\"benchmarks\"") == std::string::npos) { - std::cerr << "al_benchmark_json_check: " << argv[1] - << " has no top-level \"benchmarks\" key\n"; - return 1; - } - - return 0; -} From 144206ec6f83745d1b02a91e2c93f1ed70cb94a0 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:33:59 +0200 Subject: [PATCH 73/91] Add whole-IDS equilibrium read benchmark on HDF5 (issue #53) Reuses equilibrium_seed.h (issue #51) verbatim as the workload, driven strictly through the public C ABI. Write + open happen once in untimed setup; the timed region is exactly equilibrium_seed::read_back's begin_global_action(READ) -> traverse every leaf -> end_action, repeated across 20 repetitions so Google Benchmark's JSON reports a distribution (mean/median/stddev/iterations), not a bare mean. Splits BENCHMARK_MAIN() into its own bench_main.cpp now that a second bench_*.cpp exists, and factors the shared CheckOk status-checking helper into bench_common.h so it isn't duplicated across benchmark files. --- benchmarks/CMakeLists.txt | 30 ++++++-- benchmarks/bench_common.h | 25 +++++++ benchmarks/bench_equilibrium_read.cpp | 104 ++++++++++++++++++++++++++ benchmarks/bench_main.cpp | 7 ++ benchmarks/bench_write_read.cpp | 15 +--- 5 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 benchmarks/bench_common.h create mode 100644 benchmarks/bench_equilibrium_read.cpp create mode 100644 benchmarks/bench_main.cpp diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index 82e4d38d..201326dd 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -25,17 +25,33 @@ FetchContent_MakeAvailable(googlebenchmark) ######################################################################################################################## # The benchmark executable. # -# One benchmark drives the public C ABI (al_lowlevel.h) end-to-end: a -# single-leaf write-once-then-read on a tiny inline fixture -- deliberately NOT -# the equilibrium seed yet (that lands with the real workload issue). It runs -# against the in-RAM Memory backend, so the harness needs no filesystem or -# optional backend to smoke-prove the whole build+run+report path. +# bench_write_read.cpp is the original tracer bullet: a single-leaf +# write-once-then-read on a tiny inline fixture, against the in-RAM Memory +# backend, needing no filesystem or optional backend. +# +# bench_equilibrium_read.cpp is the real P1 headline workload (issue #53): +# whole-IDS equilibrium read on the HDF5 backend, reusing equilibrium_seed.h +# (issue #51) via the al_contract_fixtures INTERFACE library below -- hence +# needs the filesystem (std::filesystem temp dirs) that Memory-only +# bench_write_read.cpp does not. +# +# bench_main.cpp holds the one BENCHMARK_MAIN() shared by every bench_*.cpp +# TU in this executable. -add_executable( al_benchmarks bench_write_read.cpp ) -target_link_libraries( al_benchmarks PRIVATE al benchmark::benchmark ) +add_executable( al_benchmarks + bench_write_read.cpp + bench_equilibrium_read.cpp + bench_main.cpp ) +target_link_libraries( al_benchmarks PRIVATE al_contract_fixtures benchmark::benchmark ) add_dependencies( al_benchmarks al ) target_compile_features( al_benchmarks PRIVATE cxx_std_17 ) +if(NOT WIN32 AND NOT APPLE) + # stdc++fs is only needed on Linux with older GCC (std::filesystem use in + # bench_equilibrium_read.cpp), mirroring fixtures/CMakeLists.txt's smoke target. + target_link_libraries( al_benchmarks PRIVATE stdc++fs ) +endif() + ######################################################################################################################## # CTest smoke entry. # diff --git a/benchmarks/bench_common.h b/benchmarks/bench_common.h new file mode 100644 index 00000000..7b248a3a --- /dev/null +++ b/benchmarks/bench_common.h @@ -0,0 +1,25 @@ +// Shared by every bench_*.cpp TU in al_benchmarks: the one status-checking +// helper each benchmark uses to turn a failing al_status_t into a thrown +// exception (Google Benchmark reports the failure instead of silently timing +// garbage). Header-only so it carries no extra link surface. +#ifndef AL_BENCHMARKS_COMMON_H +#define AL_BENCHMARKS_COMMON_H + +#include + +#include +#include + +namespace al_bench { + +inline void CheckOk(const al_status_t& status, const char* what) { + if (status.code != 0) { + throw std::runtime_error(std::string(what) + " failed (code=" + + std::to_string(status.code) + "): " + + status.message); + } +} + +} // namespace al_bench + +#endif // AL_BENCHMARKS_COMMON_H diff --git a/benchmarks/bench_equilibrium_read.cpp b/benchmarks/bench_equilibrium_read.cpp new file mode 100644 index 00000000..f245ee6c --- /dev/null +++ b/benchmarks/bench_equilibrium_read.cpp @@ -0,0 +1,104 @@ +// The headline §7 P1 metric (NORTH_STAR.md §8.4 "whole-IDS get"): a whole-IDS +// read of the realistic equilibrium fixture through the HDF5 backend, driven +// strictly at the public C ABI (al_lowlevel.h). Reuses equilibrium_seed.h +// verbatim (issue #51) as the workload -- no bespoke fixture -- so the +// benchmarked shape never drifts from what the contract suite already pins. +// +// Timed region = one operation (issue #53, PRD #50 user story 6): the +// reference equilibrium is written and the data entry opened once in untimed +// setup; only equilibrium_seed::read_back's begin_global_action(READ) -> +// traverse every leaf -> end_action is inside the `for (auto _ : state)` loop. +// Backend is a sub-axis of the whole-IDS-read operation (PRD #50 "structure: +// scenario-primary, backend as a sub-axis"), so the Memory control (issue #54) +// lands as a second BENCHMARK() registration reusing this same function shape. + +#include "bench_common.h" +#include "equilibrium_seed.h" + +#include +#include + +#include + +#include +#include +#include + +#if defined(_WIN32) +#include +#define AL_BENCH_GETPID _getpid +#else +#include +#define AL_BENCH_GETPID getpid +#endif + +namespace { + +using al_bench::CheckOk; + +// The HDF5 backend is on-disk (unlike Memory in bench_write_read.cpp), so +// FORCE_CREATE_PULSE needs the legacy //// tree +// pre-created -- mirrors tests/contract/al_contract.h's TempBase, reimplemented +// here without a GoogleTest dependency (this tree links al_contract_fixtures, +// not al_contract.h). RAII like TempBase: the directory is created in the +// constructor and always removed in the destructor, including when a +// CheckOk() throws mid-setup, so a failing run never leaks the temp dir. +class TempHdf5Dir { +public: + TempHdf5Dir() + : path_(std::filesystem::temp_directory_path() / + ("al_benchmarks_hdf5_" + std::to_string(AL_BENCH_GETPID()))) { + std::error_code ec; + std::filesystem::remove_all(path_, ec); + std::filesystem::create_directories(path_ / "bench" / "3" / "1" / "0", ec); + } + ~TempHdf5Dir() { + std::error_code ec; + std::filesystem::remove_all(path_, ec); + } + TempHdf5Dir(const TempHdf5Dir&) = delete; + TempHdf5Dir& operator=(const TempHdf5Dir&) = delete; + + std::string uri() const { + char* uri = nullptr; + CheckOk(al_build_uri_from_legacy_parameters( + HDF5_BACKEND, /*pulse=*/1, /*run=*/0, path_.string().c_str(), + /*tokamak=*/"bench", /*version=*/"3", /*options=*/"", &uri), + "al_build_uri_from_legacy_parameters"); + std::string result(uri ? uri : ""); + free(uri); + return result; + } + +private: + std::filesystem::path path_; +}; + +} // namespace + +void BM_EquilibriumWholeIdsRead_HDF5(benchmark::State& state) { + TempHdf5Dir dir; + const std::string uri = dir.uri(); + + // --- untimed setup: open once, write the reference equilibrium once ------ + int pulse_ctx = -1; + CheckOk(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pulse_ctx), + "al_begin_dataentry_action"); + CheckOk(equilibrium_seed::write(pulse_ctx), "equilibrium_seed::write"); + + // --- timed region: begin_global_action(READ) -> every leaf -> end_action - + std::vector records; + for (auto _ : state) { + CheckOk(equilibrium_seed::read_back(pulse_ctx, &records), + "equilibrium_seed::read_back"); + benchmark::DoNotOptimize(records); + } + + // --- untimed teardown ------------------------------------------------------ + CheckOk(al_close_pulse(pulse_ctx, CLOSE_PULSE), "al_close_pulse"); + // dir's destructor removes the temp directory on every exit path. +} +// Repetitions(N) makes Google Benchmark report the distribution (median, +// mean, stddev, iteration count) across N independent setup/read/teardown +// cycles, not a bare single-shot mean (issue #53, PRD #50 user story 8). +BENCHMARK(BM_EquilibriumWholeIdsRead_HDF5)->Repetitions(20); diff --git a/benchmarks/bench_main.cpp b/benchmarks/bench_main.cpp new file mode 100644 index 00000000..05bc67da --- /dev/null +++ b/benchmarks/bench_main.cpp @@ -0,0 +1,7 @@ +// Shared Google Benchmark entry point. Every bench_*.cpp in al_benchmarks only +// registers benchmarks via BENCHMARK(...); exactly one translation unit may +// define main(), so it lives here rather than in whichever bench_*.cpp file +// happened to have it first. +#include + +BENCHMARK_MAIN(); diff --git a/benchmarks/bench_write_read.cpp b/benchmarks/bench_write_read.cpp index 55197427..f204c704 100644 --- a/benchmarks/bench_write_read.cpp +++ b/benchmarks/bench_write_read.cpp @@ -6,17 +6,20 @@ // not to produce a meaningful performance number; the real hot-path scenarios // (whole-IDS read/write, AOS traversal, ...) land separately (NORTH_STAR.md §8.4). +#include "bench_common.h" + #include #include #include #include -#include #include namespace { +using al_bench::CheckOk; + // The core attaches no DD semantics to either string (DD paths are opaque // strings through the whole C ABI), so an arbitrary IDS name and leaf path // exercise the same code path a real IDS would. @@ -24,14 +27,6 @@ constexpr const char* kIds = "magnetics"; constexpr const char* kField = "ids_properties/homogeneous_time"; constexpr int kValue = 42; -void CheckOk(const al_status_t& status, const char* what) { - if (status.code != 0) { - throw std::runtime_error(std::string(what) + " failed (code=" + - std::to_string(status.code) + "): " + - status.message); - } -} - // Builds a fresh in-RAM Memory-backend URI. The Memory backend never touches // disk, so the harness has no filesystem dependency to smoke-validate the // build+run+report path -- but the legacy-parameter URI builder always @@ -94,5 +89,3 @@ void BM_WriteReadRoundTrip(benchmark::State& state) { } } BENCHMARK(BM_WriteReadRoundTrip); - -BENCHMARK_MAIN(); From 7246824127a3339e6864f91238eb434a8ebb2afc Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:06:00 +0200 Subject: [PATCH 74/91] Share legacy pulse storage between tests and benchmarks --- benchmarks/bench_equilibrium_read.cpp | 67 +++++++---------------- fixtures/CMakeLists.txt | 5 +- fixtures/al_contract_legacy_storage.h | 76 +++++++++++++++++++++++++++ tests/contract/al_contract.h | 69 ++---------------------- 4 files changed, 102 insertions(+), 115 deletions(-) create mode 100644 fixtures/al_contract_legacy_storage.h diff --git a/benchmarks/bench_equilibrium_read.cpp b/benchmarks/bench_equilibrium_read.cpp index f245ee6c..2508cb81 100644 --- a/benchmarks/bench_equilibrium_read.cpp +++ b/benchmarks/bench_equilibrium_read.cpp @@ -13,6 +13,7 @@ // lands as a second BENCHMARK() registration reusing this same function shape. #include "bench_common.h" +#include "al_contract_legacy_storage.h" #include "equilibrium_seed.h" #include @@ -21,64 +22,32 @@ #include #include -#include #include -#if defined(_WIN32) -#include -#define AL_BENCH_GETPID _getpid -#else -#include -#define AL_BENCH_GETPID getpid -#endif - namespace { using al_bench::CheckOk; -// The HDF5 backend is on-disk (unlike Memory in bench_write_read.cpp), so -// FORCE_CREATE_PULSE needs the legacy //// tree -// pre-created -- mirrors tests/contract/al_contract.h's TempBase, reimplemented -// here without a GoogleTest dependency (this tree links al_contract_fixtures, -// not al_contract.h). RAII like TempBase: the directory is created in the -// constructor and always removed in the destructor, including when a -// CheckOk() throws mid-setup, so a failing run never leaks the temp dir. -class TempHdf5Dir { -public: - TempHdf5Dir() - : path_(std::filesystem::temp_directory_path() / - ("al_benchmarks_hdf5_" + std::to_string(AL_BENCH_GETPID()))) { - std::error_code ec; - std::filesystem::remove_all(path_, ec); - std::filesystem::create_directories(path_ / "bench" / "3" / "1" / "0", ec); - } - ~TempHdf5Dir() { - std::error_code ec; - std::filesystem::remove_all(path_, ec); - } - TempHdf5Dir(const TempHdf5Dir&) = delete; - TempHdf5Dir& operator=(const TempHdf5Dir&) = delete; - - std::string uri() const { - char* uri = nullptr; - CheckOk(al_build_uri_from_legacy_parameters( - HDF5_BACKEND, /*pulse=*/1, /*run=*/0, path_.string().c_str(), - /*tokamak=*/"bench", /*version=*/"3", /*options=*/"", &uri), - "al_build_uri_from_legacy_parameters"); - std::string result(uri ? uri : ""); - free(uri); - return result; - } - -private: - std::filesystem::path path_; -}; +std::string Hdf5Uri(const al_contract::LegacyPulseDirectory& base, + const al_contract::PulseId& pulse) { + char* uri = nullptr; + CheckOk(al_build_uri_from_legacy_parameters( + HDF5_BACKEND, pulse.pulse, pulse.run, base.str().c_str(), + pulse.database.c_str(), pulse.version.c_str(), /*options=*/"", &uri), + "al_build_uri_from_legacy_parameters"); + std::string result(uri ? uri : ""); + free(uri); + return result; +} } // namespace void BM_EquilibriumWholeIdsRead_HDF5(benchmark::State& state) { - TempHdf5Dir dir; - const std::string uri = dir.uri(); + const al_contract::PulseId pulse{/*database=*/"bench", /*version=*/"3", + /*pulse=*/1, /*run=*/0}; + al_contract::LegacyPulseDirectory base; + base.make_legacy_tree(pulse); + const std::string uri = Hdf5Uri(base, pulse); // --- untimed setup: open once, write the reference equilibrium once ------ int pulse_ctx = -1; @@ -96,7 +65,7 @@ void BM_EquilibriumWholeIdsRead_HDF5(benchmark::State& state) { // --- untimed teardown ------------------------------------------------------ CheckOk(al_close_pulse(pulse_ctx, CLOSE_PULSE), "al_close_pulse"); - // dir's destructor removes the temp directory on every exit path. + // base's destructor removes the temp directory on every exit path. } // Repetitions(N) makes Google Benchmark report the distribution (median, // mean, stddev, iteration count) across N independent setup/read/teardown diff --git a/fixtures/CMakeLists.txt b/fixtures/CMakeLists.txt index c674ed3d..fa4f92eb 100644 --- a/fixtures/CMakeLists.txt +++ b/fixtures/CMakeLists.txt @@ -1,8 +1,9 @@ ######################################################################################################################## # Shared contract fixtures (issue #51). # -# The realistic equilibrium seed (equilibrium_seed.h) and the generic typed -# ABI round-trip helpers it needs (al_contract_abi.h), factored out of +# The realistic equilibrium seed (equilibrium_seed.h), legacy on-disk storage +# helper (al_contract_legacy_storage.h), and generic typed ABI round-trip +# helpers (al_contract_abi.h), factored out of # tests/contract/ so a build target outside the test tree -- the future # benchmarks/ tree (PRD #50, issues #52/#53) -- can link the exact same # workload the contract suite pins. No fixture drift between what is tested diff --git a/fixtures/al_contract_legacy_storage.h b/fixtures/al_contract_legacy_storage.h new file mode 100644 index 00000000..76e97718 --- /dev/null +++ b/fixtures/al_contract_legacy_storage.h @@ -0,0 +1,76 @@ +// Shared filesystem support for contract fixtures and benchmarks that exercise +// the legacy data-entry URI layout. This header deliberately has no GoogleTest +// dependency: callers choose how to report URI or ABI failures. +#ifndef AL_CONTRACT_LEGACY_STORAGE_H +#define AL_CONTRACT_LEGACY_STORAGE_H + +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +namespace al_contract { + +// The legacy address of a pulse: (database, version, pulse, run). These four +// values always travel together — into the URI and into the on-disk tree path. +struct PulseId { + std::string database; + std::string version; + int pulse; + int run; +}; + +// Owns a unique temporary base directory and prepares the legacy pulse layout +// required by on-disk backends using FORCE_CREATE_PULSE. +class LegacyPulseDirectory { +public: + LegacyPulseDirectory() { + static std::atomic counter{0}; + const unsigned n = counter.fetch_add(1); + path_ = std::filesystem::temp_directory_path() / + ("al_contract_" + std::to_string(process_id()) + "_" + + std::to_string(n)); + std::error_code ec; + std::filesystem::remove_all(path_, ec); + std::filesystem::create_directories(path_, ec); + } + + ~LegacyPulseDirectory() { + std::error_code ec; + std::filesystem::remove_all(path_, ec); + } + + LegacyPulseDirectory(const LegacyPulseDirectory&) = delete; + LegacyPulseDirectory& operator=(const LegacyPulseDirectory&) = delete; + + const std::filesystem::path& path() const { return path_; } + std::string str() const { return path_.string(); } + + void make_legacy_tree(const PulseId& id) const { + std::error_code ec; + std::filesystem::create_directories( + path_ / id.database / id.version / std::to_string(id.pulse) / + std::to_string(id.run), + ec); + } + +private: + static int process_id() { +#if defined(_WIN32) + return _getpid(); +#else + return getpid(); +#endif + } + + std::filesystem::path path_; +}; + +} // namespace al_contract + +#endif // AL_CONTRACT_LEGACY_STORAGE_H diff --git a/tests/contract/al_contract.h b/tests/contract/al_contract.h index 616a5b55..85a0eb8c 100644 --- a/tests/contract/al_contract.h +++ b/tests/contract/al_contract.h @@ -13,34 +13,28 @@ #define AL_CONTRACT_H #include "al_contract_abi.h" +#include "al_contract_legacy_storage.h" #include #include #include -#include #include #include #include #include -#include #include #include #include -#if defined(_WIN32) -#include -#define AL_CONTRACT_GETPID _getpid -#else +#if !defined(_WIN32) #if defined(AL_CONTRACT_HAVE_UDA) #include #include #include #include #endif -#include -#define AL_CONTRACT_GETPID getpid #endif namespace al_contract { @@ -208,62 +202,9 @@ struct BackendCase { inline void PrintTo(const BackendCase& b, std::ostream* os) { *os << b.name; } // --------------------------------------------------------------------------- -// The legacy address of a pulse: (database, version, pulse, run). These four -// always travel together — into the URI and into the on-disk tree path — so -// they are one value, not four loose args. -// --------------------------------------------------------------------------- -struct PulseId { - std::string database; - std::string version; - int pulse; - int run; -}; - -// --------------------------------------------------------------------------- -// Unique temp base directory, RAII-cleaned. -// --------------------------------------------------------------------------- -// The legacy pulse path is ////; the core -// derives it when the base dir is supplied as the "user" field of the legacy -// URI (exactly how tests/CMakeLists.txt drives the existing smoke tests). Each -// fixture instance gets its own base dir so parallel ctest runs never collide: -// name = /al_contract__. -class TempBase { -public: - TempBase() { - static std::atomic counter{0}; - namespace fs = std::filesystem; - const unsigned n = counter.fetch_add(1); - path_ = fs::temp_directory_path() / - ("al_contract_" + std::to_string(AL_CONTRACT_GETPID()) + - "_" + std::to_string(n)); - std::error_code ec; - fs::remove_all(path_, ec); - fs::create_directories(path_, ec); - } - - ~TempBase() { - std::error_code ec; - std::filesystem::remove_all(path_, ec); - } - - TempBase(const TempBase&) = delete; - TempBase& operator=(const TempBase&) = delete; - - std::string str() const { return path_.string(); } - - // Pre-create the legacy pulse subtree an on-disk backend expects for - // FORCE_CREATE_PULSE. - void make_legacy_tree(const PulseId& id) const { - std::error_code ec; - std::filesystem::create_directories( - path_ / id.database / id.version / std::to_string(id.pulse) / - std::to_string(id.run), - ec); - } - -private: - std::filesystem::path path_; -}; +// Compatibility name for the existing contract tests. The implementation is +// shared with benchmarks through fixtures/al_contract_legacy_storage.h. +using TempBase = LegacyPulseDirectory; // --------------------------------------------------------------------------- // URI construction through the legacy-parameter ABI. From af0fb25dbb2e73c909fe95f777377fc6ed73eef4 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:54:42 +0200 Subject: [PATCH 75/91] Add Memory backend as zero-I/O control for whole-IDS read (issue #54) Refactors the HDF5-only equilibrium whole-IDS read benchmark (#53) into one shared BM_EquilibriumWholeIdsRead body parameterized by a BenchBackend enum, registered via BENCHMARK_CAPTURE for both HDF5 and the in-RAM Memory backend. Memory isolates per-node traversal/CPU cost from HDF5's I/O cost, and adding it required only branching the URI-building step -- validating that backend is a cheap sub-axis of the operation rather than a copy-pasted scenario. --- benchmarks/CMakeLists.txt | 8 ++-- benchmarks/bench_equilibrium_read.cpp | 69 ++++++++++++++++++++------- 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index 201326dd..4d7cb55f 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -30,10 +30,10 @@ FetchContent_MakeAvailable(googlebenchmark) # backend, needing no filesystem or optional backend. # # bench_equilibrium_read.cpp is the real P1 headline workload (issue #53): -# whole-IDS equilibrium read on the HDF5 backend, reusing equilibrium_seed.h -# (issue #51) via the al_contract_fixtures INTERFACE library below -- hence -# needs the filesystem (std::filesystem temp dirs) that Memory-only -# bench_write_read.cpp does not. +# whole-IDS equilibrium read, reusing equilibrium_seed.h (issue #51) via the +# al_contract_fixtures INTERFACE library below. It registers the same +# operation against both HDF5 (needs the filesystem -- std::filesystem temp +# dirs) and, as a zero-I/O control (issue #54), the in-RAM Memory backend. # # bench_main.cpp holds the one BENCHMARK_MAIN() shared by every bench_*.cpp # TU in this executable. diff --git a/benchmarks/bench_equilibrium_read.cpp b/benchmarks/bench_equilibrium_read.cpp index 2508cb81..b7a75a65 100644 --- a/benchmarks/bench_equilibrium_read.cpp +++ b/benchmarks/bench_equilibrium_read.cpp @@ -1,16 +1,21 @@ // The headline §7 P1 metric (NORTH_STAR.md §8.4 "whole-IDS get"): a whole-IDS -// read of the realistic equilibrium fixture through the HDF5 backend, driven -// strictly at the public C ABI (al_lowlevel.h). Reuses equilibrium_seed.h -// verbatim (issue #51) as the workload -- no bespoke fixture -- so the -// benchmarked shape never drifts from what the contract suite already pins. +// read of the realistic equilibrium fixture, driven strictly at the public C +// ABI (al_lowlevel.h). Reuses equilibrium_seed.h verbatim (issue #51) as the +// workload -- no bespoke fixture -- so the benchmarked shape never drifts +// from what the contract suite already pins. // // Timed region = one operation (issue #53, PRD #50 user story 6): the // reference equilibrium is written and the data entry opened once in untimed // setup; only equilibrium_seed::read_back's begin_global_action(READ) -> // traverse every leaf -> end_action is inside the `for (auto _ : state)` loop. +// // Backend is a sub-axis of the whole-IDS-read operation (PRD #50 "structure: -// scenario-primary, backend as a sub-axis"), so the Memory control (issue #54) -// lands as a second BENCHMARK() registration reusing this same function shape. +// scenario-primary, backend as a sub-axis"): BM_EquilibriumWholeIdsRead below +// is the one operation body, and HDF5 (issue #53) / Memory (issue #54) are +// two BENCHMARK_CAPTURE registrations of it, differing only in which backend +// id the URI is built for. Memory is a zero-I/O control -- it never touches +// disk -- so subtracting its number from HDF5's decomposes the total into +// storage cost vs per-node traversal/CPU cost. #include "bench_common.h" #include "al_contract_legacy_storage.h" @@ -22,19 +27,36 @@ #include #include +#include #include namespace { using al_bench::CheckOk; -std::string Hdf5Uri(const al_contract::LegacyPulseDirectory& base, - const al_contract::PulseId& pulse) { +enum class BenchBackend { kHdf5, kMemory }; + +// HDF5 resolves FORCE_CREATE_PULSE against a real on-disk legacy tree, so it +// needs `base` alive for the URI to mean anything. Memory never touches disk +// (bench_write_read.cpp's MemoryUri established the pattern): an arbitrary +// absolute-looking `user` sidesteps the legacy URI builder's getpwnam() +// fallback for an empty/unknown user, without paying for a real temp dir. +std::string BuildUri(BenchBackend backend, const al_contract::PulseId& pulse, + const al_contract::LegacyPulseDirectory* base) { char* uri = nullptr; - CheckOk(al_build_uri_from_legacy_parameters( - HDF5_BACKEND, pulse.pulse, pulse.run, base.str().c_str(), - pulse.database.c_str(), pulse.version.c_str(), /*options=*/"", &uri), - "al_build_uri_from_legacy_parameters"); + if (backend == BenchBackend::kHdf5) { + CheckOk(al_build_uri_from_legacy_parameters( + HDF5_BACKEND, pulse.pulse, pulse.run, base->str().c_str(), + pulse.database.c_str(), pulse.version.c_str(), + /*options=*/"", &uri), + "al_build_uri_from_legacy_parameters"); + } else { + CheckOk(al_build_uri_from_legacy_parameters( + MEMORY_BACKEND, pulse.pulse, pulse.run, "/al-benchmarks", + pulse.database.c_str(), pulse.version.c_str(), + /*options=*/"", &uri), + "al_build_uri_from_legacy_parameters"); + } std::string result(uri ? uri : ""); free(uri); return result; @@ -42,14 +64,22 @@ std::string Hdf5Uri(const al_contract::LegacyPulseDirectory& base, } // namespace -void BM_EquilibriumWholeIdsRead_HDF5(benchmark::State& state) { +void BM_EquilibriumWholeIdsRead(benchmark::State& state, BenchBackend backend) { const al_contract::PulseId pulse{/*database=*/"bench", /*version=*/"3", /*pulse=*/1, /*run=*/0}; - al_contract::LegacyPulseDirectory base; - base.make_legacy_tree(pulse); - const std::string uri = Hdf5Uri(base, pulse); + std::optional base; + if (backend == BenchBackend::kHdf5) { + base.emplace(); + base->make_legacy_tree(pulse); + } + const std::string uri = BuildUri(backend, pulse, base ? &*base : nullptr); // --- untimed setup: open once, write the reference equilibrium once ------ + // Memory keeps the fixed pulse identity across repetitions unlike + // bench_write_read.cpp's per-iteration bump: MemoryBackend::closePulse is a + // no-op (src/memory_backend.h), so correctness across the 20 Repetitions + // rests on FORCE_CREATE_PULSE clearing the reused entry's idsMap on reopen, + // not on pulse identities never colliding. int pulse_ctx = -1; CheckOk(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pulse_ctx), "al_begin_dataentry_action"); @@ -65,9 +95,12 @@ void BM_EquilibriumWholeIdsRead_HDF5(benchmark::State& state) { // --- untimed teardown ------------------------------------------------------ CheckOk(al_close_pulse(pulse_ctx, CLOSE_PULSE), "al_close_pulse"); - // base's destructor removes the temp directory on every exit path. + // base's destructor (HDF5 only) removes the temp directory on every exit path. } // Repetitions(N) makes Google Benchmark report the distribution (median, // mean, stddev, iteration count) across N independent setup/read/teardown // cycles, not a bare single-shot mean (issue #53, PRD #50 user story 8). -BENCHMARK(BM_EquilibriumWholeIdsRead_HDF5)->Repetitions(20); +BENCHMARK_CAPTURE(BM_EquilibriumWholeIdsRead, HDF5, BenchBackend::kHdf5) + ->Repetitions(20); +BENCHMARK_CAPTURE(BM_EquilibriumWholeIdsRead, Memory, BenchBackend::kMemory) + ->Repetitions(20); From 4a20b8110efa5b11018a13eb7fafdf552bc9f723 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:56:45 +0200 Subject: [PATCH 76/91] Add baseline capture tooling: metadata + committed-artifact convention (issue #55) capture_baseline.py wraps al_benchmarks, tags the resulting JSON with comparability metadata (OS, CPU model, compiler+flags, HDF5 version, filesystem, SLURM partition, commit SHA), and writes it to benchmarks/baselines//.json. BASELINE_CAPTURE.md documents the laptop-smoke and ITER-SDCC-cluster procedures; the SLURM partition is always a runtime parameter, never hard-coded. --- benchmarks/BASELINE_CAPTURE.md | 107 +++++++++++ benchmarks/baselines/README.md | 66 +++++++ benchmarks/capture_baseline.py | 335 +++++++++++++++++++++++++++++++++ 3 files changed, 508 insertions(+) create mode 100644 benchmarks/BASELINE_CAPTURE.md create mode 100644 benchmarks/baselines/README.md create mode 100755 benchmarks/capture_baseline.py diff --git a/benchmarks/BASELINE_CAPTURE.md b/benchmarks/BASELINE_CAPTURE.md new file mode 100644 index 00000000..4b93a896 --- /dev/null +++ b/benchmarks/BASELINE_CAPTURE.md @@ -0,0 +1,107 @@ +# Capturing a baseline + +This documents the repeatable procedure behind `capture_baseline.py` (issue +#55): turning a run of `al_benchmarks` into a metadata-tagged, committable +JSON artifact under [`benchmarks/baselines/`](baselines/README.md). + +The script wraps whatever benchmarks are registered — it does not know or +care which scenarios exist, so this procedure does not change as +`bench_*.cpp` grows beyond the current whole-IDS-read operation. + +## Laptop (development / smoke — throwaway numbers) + +Any machine, any state of the working tree. These numbers exist to prove the +capture flow works, never to be compared against anything. + +```sh +cmake --preset benchmarks +cmake --build --preset benchmarks + +python3 benchmarks/capture_baseline.py \ + --binary build-benchmarks/benchmarks/al_benchmarks \ + --machine-id "laptop-$(hostname -s)" \ + -- --benchmark_min_time=1x +``` + +`--benchmark_min_time=1x` caps every registered benchmark at one iteration — +the same flag the CTest smoke check (`benchmark_smoke_run`) uses — so this +finishes in well under a second regardless of how many scenarios exist. +Drop it to get real repetition counts (slower, and still not a measurement +of record without the cluster's isolation). + +The script prints the metadata it captured (OS, CPU, compiler, HDF5 version, +filesystem, commit SHA/dirty flag, `slurm_partition: null`) and the path it +wrote to. On a laptop `commit_dirty` will often be `true` — that's expected +and is exactly why this file is a smoke artifact, not a baseline of record. + +## ITER SDCC cluster (measurement of record) + +Run on a clean checkout of `develop` at a known commit, before any P1–P6 +hot-path change lands (PRD #50: the baseline becomes unrecoverable the +moment optimization starts). + +1. **Allocate an exclusive node** on the partition your supervisor names as + production-representative — the partition is a runtime parameter, never + hard-coded into the script or this doc: + + ```sh + salloc --exclusive --partition= --time=00:30:00 + ``` + + `$SLURM_JOB_PARTITION` is then set for the rest of the allocation, and + `capture_baseline.py` picks it up automatically (or pass + `--slurm-partition ` explicitly). + +2. **Load the toolchain modules**: + + ```sh + module load foss-2023b googletest + ``` + + `foss-2023b` provides the compiler and the HDF5 the benchmark links + against; `googletest` is needed because the harness links + `al_contract_fixtures` (shared with the contract suite). + +3. **Configure and build in Release**, on GPFS (`/mnt/HPC_T2`), not on a + login-node home directory: + + ```sh + cd /mnt/HPC_T2//IMAS-Core + cmake --preset benchmarks + cmake --build --preset benchmarks + ``` + +4. **Capture**, recording the filesystem explicitly (auto-detection is + best-effort and GPFS mounts don't always self-report cleanly) and a + `machine-id` that names the cluster, not the specific login/compute node: + + ```sh + python3 benchmarks/capture_baseline.py \ + --binary build-benchmarks/benchmarks/al_benchmarks \ + --machine-id sdcc-gpfs \ + --filesystem gpfs + ``` + + No `--benchmark_min_time` here — let each scenario run its full + repetition count so the committed baseline reports a real distribution + (median, mean, stddev), per PRD #50 user story 8. + +5. **Verify before committing**: the script refuses (unless `--force`) to + overwrite a baseline already committed for this `machine-id` + commit SHA, + and warns to stderr if the working tree is dirty or the build isn't + Release. Resolve any such warning rather than force past it for a + measurement of record. + +6. **Commit** the resulting file under `benchmarks/baselines/sdcc-gpfs/` — + see [`baselines/README.md`](baselines/README.md) for the layout and how + later runs compare against it. + +## What gets recorded either way + +`captured_at`, `machine_id`, `os`, `cpu_model`, `compiler` (+ version), +`compiler_flags`, `build_type`, `hdf5_version`, `filesystem`, +`slurm_partition` (`null` off-cluster), `commit_sha`, `commit_dirty` — spliced +into the Google Benchmark JSON's `context` block as `imas_baseline_metadata`. +Compiler/flags/HDF5 version are read from the build directory's +`CMakeCache.txt` (found by walking up from `--binary`, or pass `--build-dir` +explicitly if your build layout doesn't nest the binary under it). diff --git a/benchmarks/baselines/README.md b/benchmarks/baselines/README.md new file mode 100644 index 00000000..ad17c994 --- /dev/null +++ b/benchmarks/baselines/README.md @@ -0,0 +1,66 @@ +# Committed baseline layout + +``` +benchmarks/baselines//.json +``` + +Each file is the raw Google Benchmark JSON that `al_benchmarks` emitted for +that run — Google Benchmark's own top-level `context` (date, host_name, +num_cpus, ...) and `benchmarks` (one entry per `BENCHMARK()` registration, +repetitions, and `_mean`/`_median`/`_stddev`/`_cv` rows) — with one field +spliced into `context`: + +```json +{ + "imas_baseline_metadata": { + "captured_at": "2026-07-20T09:50:42.347134+00:00", + "machine_id": "sdcc-gpfs", + "os": "Linux-5.14.0-x86_64", + "cpu_model": "Intel(R) Xeon(R) Gold 6248R", + "compiler": "GNU 12.3.0", + "compiler_flags": "-O3 -DNDEBUG", + "build_type": "Release", + "hdf5_version": "1.14.3", + "filesystem": "gpfs", + "slurm_partition": "iter", + "commit_sha": "7246824127a3339e6864f91238eb434a8ebb2afc", + "commit_dirty": false + } +} +``` + +(the object above nests under `context.imas_baseline_metadata`; only that +field is shown, not the full committed file) + +`imas_baseline_metadata` is what makes two files comparable — or tells you why +they aren't (different `compiler_flags`, different `filesystem`, etc). See +[`../BASELINE_CAPTURE.md`](../BASELINE_CAPTURE.md) for how these files are +produced. + +## Naming + +- `` is whatever `--machine-id` was passed to + `capture_baseline.py` (default: the sanitized hostname). Use a stable, + descriptive id for anything meant to be a measurement of record, e.g. + `sdcc-gpfs` for the ITER SDCC cluster, rather than a raw login-node + hostname that might change. +- `` is the full `git rev-parse HEAD` of the run. A dirty working + tree is still captured (for laptop iteration) but is flagged + `commit_dirty: true` in the metadata — that file is not reproducible from + the SHA alone and should not be treated as a measurement of record. + +## Comparing two baselines + +There is no comparison tool here by design (PRD #50: "Not tested: the +numeric results themselves... a manual/analysis step over the committed +JSON, not a CI gate"). To compare, match `benchmarks[].name` between two +files and look at the `_mean`/`_median` rows' `real_time`/`cpu_time`, e.g. +with `jq`: + +```sh +jq '.benchmarks[] | select(.name | endswith("_mean")) | {name, real_time, cpu_time}' \ + benchmarks/baselines/sdcc-gpfs/.json + +jq '.benchmarks[] | select(.name | endswith("_mean")) | {name, real_time, cpu_time}' \ + benchmarks/baselines/sdcc-gpfs/.json +``` diff --git a/benchmarks/capture_baseline.py b/benchmarks/capture_baseline.py new file mode 100755 index 00000000..ae80f754 --- /dev/null +++ b/benchmarks/capture_baseline.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""Run the al_benchmarks binary and capture a metadata-tagged baseline artifact. + +Wraps whatever Google Benchmark JSON the harness (benchmarks/al_benchmarks, +issues #52/#53) emits and tags it with the comparability metadata a committed +baseline needs (NORTH_STAR.md §8.4, issue #55): OS, CPU model, compiler + +flags, HDF5 version, filesystem, SLURM partition, and commit SHA. The tagged +JSON is written to the committed-artifact layout, keyed by machine identifier +and commit SHA: + + benchmarks/baselines//.json + +The SLURM partition is never hard-coded: pass --slurm-partition, or run under +an allocation that sets $SLURM_JOB_PARTITION, or leave both unset (recorded as +null -- e.g. a laptop smoke run has no partition at all). + +Comparing two baselines (dividing matching "real_time"/"cpu_time" entries) is +a manual analysis step over the committed JSON, not something this script +does (PRD #50 "Not tested: the numeric results themselves"). +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import re +import socket +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_OUTPUT_DIR = REPO_ROOT / "benchmarks" / "baselines" + + +def run_git(*args: str) -> str: + return subprocess.check_output( + ["git", *args], cwd=REPO_ROOT, text=True + ).strip() + + +def commit_metadata() -> dict: + sha = run_git("rev-parse", "HEAD") + dirty = bool(run_git("status", "--porcelain")) + return {"commit_sha": sha, "commit_dirty": dirty} + + +def sanitize_machine_id(raw: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9]+", "-", raw).strip("-").lower() + return slug or "unknown-machine" + + +def best_effort_output(cmd: list[str]) -> str | None: + """Run `cmd`, returning stripped stdout, or None if it can't be run.""" + try: + return subprocess.check_output( + cmd, text=True, stderr=subprocess.STDOUT + ).strip() + except (OSError, subprocess.CalledProcessError): + return None + + +def detect_os() -> str: + return platform.platform() + + +def detect_cpu_model() -> str: + system = platform.system() + if system == "Darwin": + brand = best_effort_output(["sysctl", "-n", "machdep.cpu.brand_string"]) + if brand: + return brand + elif system == "Linux": + try: + with open("/proc/cpuinfo") as f: + for line in f: + if line.lower().startswith("model name"): + return line.split(":", 1)[1].strip() + except OSError: + pass + return platform.processor() or "unknown" + + +def read_cmake_cache(build_dir: Path) -> dict[str, str]: + cache_path = build_dir / "CMakeCache.txt" + entries = {} + if not cache_path.is_file(): + return entries + for line in cache_path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or line.startswith("//"): + continue + if ":" not in line or "=" not in line: + continue + key_type, value = line.split("=", 1) + key = key_type.split(":", 1)[0] + entries[key] = value + return entries + + +def find_build_dir(binary: Path, explicit: Path | None) -> Path | None: + if explicit is not None: + return explicit + for candidate in binary.resolve().parents: + if (candidate / "CMakeCache.txt").is_file(): + return candidate + return None + + +def detect_compiler(cache: dict[str, str]) -> str: + compiler_id = cache.get("CMAKE_CXX_COMPILER_ID") + compiler_version = cache.get("CMAKE_CXX_COMPILER_VERSION") + if compiler_id and compiler_version: + return f"{compiler_id} {compiler_version}" + compiler_path = cache.get("CMAKE_CXX_COMPILER") + if compiler_path: + version_output = best_effort_output([compiler_path, "--version"]) + if version_output: + return version_output.splitlines()[0] + return "unknown" + + +def detect_compiler_flags(cache: dict[str, str]) -> str: + build_type = cache.get("CMAKE_BUILD_TYPE", "") + parts = [cache.get("CMAKE_CXX_FLAGS", "")] + if build_type: + parts.append(cache.get(f"CMAKE_CXX_FLAGS_{build_type.upper()}", "")) + return " ".join(p for p in parts if p).strip() or "unknown" + + +def detect_hdf5_version(cache: dict[str, str]) -> str: + hdf5_dir = cache.get("HDF5_DIR") + if hdf5_dir: + version_file = Path(hdf5_dir) / "hdf5-config-version.cmake" + if version_file.is_file(): + match = re.search( + r'set\(PACKAGE_VERSION\s+"([^"]+)"\)', version_file.read_text() + ) + if match: + return match.group(1) + version_output = best_effort_output(["h5dump", "--version"]) + if version_output: + match = re.search(r"Version\s+([0-9.]+)", version_output) + if match: + return match.group(1) + return "unknown" + + +def detect_filesystem(path: Path, override: str | None) -> str: + if override: + return override + if platform.system() == "Linux": + fstype = best_effort_output(["findmnt", "-no", "FSTYPE", "--target", str(path)]) + if fstype: + return fstype + elif platform.system() == "Darwin": + df_output = best_effort_output(["df", "-P", str(path)]) + mount_output = best_effort_output(["mount"]) + if df_output and mount_output: + device = df_output.splitlines()[-1].split()[0] + for line in mount_output.splitlines(): + if line.startswith(device + " "): + match = re.search(r"\(([^,]+)", line) + if match: + return match.group(1) + return "unknown" + + +def build_metadata(args: argparse.Namespace, build_dir: Path | None) -> dict[str, object]: + cache = read_cmake_cache(build_dir) if build_dir else {} + metadata = { + "captured_at": datetime.now(timezone.utc).isoformat(), + "machine_id": args.machine_id, + "os": detect_os(), + "cpu_model": detect_cpu_model(), + "compiler": detect_compiler(cache), + "compiler_flags": detect_compiler_flags(cache), + "build_type": cache.get("CMAKE_BUILD_TYPE", "unknown"), + "hdf5_version": detect_hdf5_version(cache), + "filesystem": detect_filesystem( + build_dir or REPO_ROOT, args.filesystem + ), + "slurm_partition": args.slurm_partition, + } + metadata.update(commit_metadata()) + return metadata + + +def run_benchmark(binary: Path, extra_args: list[str], out_path: Path) -> str | None: + """Run the benchmark binary; return an error message, or None on success.""" + try: + subprocess.run( + [ + str(binary), + f"--benchmark_out={out_path}", + "--benchmark_out_format=json", + *extra_args, + ], + check=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + return f"failed to run {binary}: {exc}" + return None + + +def merge_metadata(json_path: Path, metadata: dict[str, object]) -> dict: + result = json.loads(json_path.read_text()) + result.setdefault("context", {})["imas_baseline_metadata"] = metadata + return result + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="capture_baseline.py", + description=( + "Run al_benchmarks and write a metadata-tagged, committable " + "baseline artifact under benchmarks/baselines/." + ), + ) + parser.add_argument( + "--binary", required=True, type=Path, help="path to the al_benchmarks executable" + ) + parser.add_argument( + "--build-dir", + type=Path, + default=None, + help="CMake build dir to read compiler/flags/HDF5 metadata from " + "(default: discovered by walking up from --binary for CMakeCache.txt)", + ) + parser.add_argument( + "--machine-id", + default=sanitize_machine_id(socket.gethostname()), + help="identifier this baseline is filed under (default: sanitized hostname)", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=DEFAULT_OUTPUT_DIR, + help="root of the committed baseline layout (default: benchmarks/baselines)", + ) + parser.add_argument( + "--filesystem", + default=None, + help="filesystem name to record (default: best-effort auto-detect, " + "e.g. pass 'gpfs' explicitly on the cluster)", + ) + parser.add_argument( + "--slurm-partition", + default=None, + help="SLURM partition to record (default: $SLURM_JOB_PARTITION, or null " + "if unset -- this is always a runtime parameter, never hard-coded)", + ) + parser.add_argument( + "--force", + action="store_true", + help="overwrite an existing committed baseline file for this machine+commit", + ) + parser.add_argument( + "benchmark_args", + nargs=argparse.REMAINDER, + help="remaining args forwarded verbatim to al_benchmarks " + "(e.g. -- --benchmark_filter=Equilibrium --benchmark_min_time=1x)", + ) + args = parser.parse_args(argv) + if args.benchmark_args[:1] == ["--"]: + args.benchmark_args = args.benchmark_args[1:] + if args.slurm_partition is None: + args.slurm_partition = os.environ.get("SLURM_JOB_PARTITION") or None + return args + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + + if not args.binary.is_file(): + print(f"error: benchmark binary not found: {args.binary}", file=sys.stderr) + return 1 + + build_dir = find_build_dir(args.binary, args.build_dir) + if build_dir is None: + print( + "warning: could not locate a CMakeCache.txt; compiler/flags/HDF5 " + "metadata will be recorded as 'unknown' (pass --build-dir to fix this)", + file=sys.stderr, + ) + + metadata = build_metadata(args, build_dir) + + if metadata["commit_dirty"]: + print( + "warning: working tree is dirty -- this baseline is not reproducible " + "from the recorded commit SHA alone (metadata still records commit_dirty=true)", + file=sys.stderr, + ) + if metadata["build_type"] != "Release": + print( + f"warning: build type is '{metadata['build_type']}', not Release -- " + "NORTH_STAR.md §8.4 numbers are only comparable across Release builds", + file=sys.stderr, + ) + + machine_dir = args.output_dir / args.machine_id + out_path = machine_dir / f"{metadata['commit_sha']}.json" + if out_path.exists() and not args.force: + print( + f"error: {out_path} already exists (pass --force to overwrite a " + "committed baseline)", + file=sys.stderr, + ) + return 1 + + with tempfile.TemporaryDirectory() as tmp: + raw_json = Path(tmp) / "benchmark_raw.json" + error = run_benchmark(args.binary, args.benchmark_args, raw_json) + if error is not None: + print(f"error: {error}", file=sys.stderr) + return 1 + tagged = merge_metadata(raw_json, metadata) + + machine_dir.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(tagged, indent=2) + "\n") + + print(f"wrote baseline: {out_path}") + width = max(len(key) for key in metadata) + for key, value in metadata.items(): + print(f" {key:<{width}} = {value}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) From c0ee66c80725a6fc44b37c333789e8e80903a549 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:38:51 +0200 Subject: [PATCH 77/91] Fix benchmark baseline capture metadata --- benchmarks/BASELINE_CAPTURE.md | 9 ++- benchmarks/CMakeLists.txt | 5 ++ benchmarks/baselines/README.md | 5 +- benchmarks/capture_baseline.py | 15 ++++- benchmarks/test_capture_baseline.py | 99 +++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 benchmarks/test_capture_baseline.py diff --git a/benchmarks/BASELINE_CAPTURE.md b/benchmarks/BASELINE_CAPTURE.md index 4b93a896..138a7fc4 100644 --- a/benchmarks/BASELINE_CAPTURE.md +++ b/benchmarks/BASELINE_CAPTURE.md @@ -34,6 +34,11 @@ filesystem, commit SHA/dirty flag, `slurm_partition: null`) and the path it wrote to. On a laptop `commit_dirty` will often be `true` — that's expected and is exactly why this file is a smoke artifact, not a baseline of record. +On macOS, the script detects the Apple chip through `system_profiler` when the +usual CPU-brand `sysctl` is unavailable. If automatic detection is blocked in +your environment, pass the precise model explicitly, for example +`--cpu-model "Apple M5 Pro"`. + ## ITER SDCC cluster (measurement of record) Run on a clean checkout of `develop` at a known commit, before any P1–P6 @@ -104,4 +109,6 @@ moment optimization starts). into the Google Benchmark JSON's `context` block as `imas_baseline_metadata`. Compiler/flags/HDF5 version are read from the build directory's `CMakeCache.txt` (found by walking up from `--binary`, or pass `--build-dir` -explicitly if your build layout doesn't nest the binary under it). +explicitly if your build layout doesn't nest the binary under it). Machine IDs +are normalized to lowercase letters, digits, and dashes, ensuring every +artifact remains inside the documented baseline layout. diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index 4d7cb55f..605a9430 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -22,6 +22,8 @@ set(BENCHMARK_ENABLE_GTEST_TESTS OFF CACHE BOOL "" FORCE) set(BENCHMARK_ENABLE_WERROR OFF CACHE BOOL "" FORCE) FetchContent_MakeAvailable(googlebenchmark) +find_package(Python3 COMPONENTS Interpreter REQUIRED) + ######################################################################################################################## # The benchmark executable. # @@ -78,3 +80,6 @@ add_test( NAME benchmark_smoke_validate COMMAND ${CMAKE_COMMAND} -DINPUT=${_smoke_json} -P ${CMAKE_CURRENT_SOURCE_DIR}/validate_benchmark_json.cmake ) set_tests_properties( benchmark_smoke_validate PROPERTIES FIXTURES_REQUIRED BENCHMARK_SMOKE_JSON ) + +add_test( NAME baseline_capture_unit + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_capture_baseline.py ) diff --git a/benchmarks/baselines/README.md b/benchmarks/baselines/README.md index ad17c994..f9bd3241 100644 --- a/benchmarks/baselines/README.md +++ b/benchmarks/baselines/README.md @@ -39,8 +39,9 @@ produced. ## Naming -- `` is whatever `--machine-id` was passed to - `capture_baseline.py` (default: the sanitized hostname). Use a stable, +- `` is the normalized value of `--machine-id` (default: the + sanitized hostname). It contains only lowercase letters, digits, and dashes. + Use a stable, descriptive id for anything meant to be a measurement of record, e.g. `sdcc-gpfs` for the ITER SDCC cluster, rather than a raw login-node hostname that might change. diff --git a/benchmarks/capture_baseline.py b/benchmarks/capture_baseline.py index ae80f754..69d7b453 100755 --- a/benchmarks/capture_baseline.py +++ b/benchmarks/capture_baseline.py @@ -74,6 +74,11 @@ def detect_cpu_model() -> str: brand = best_effort_output(["sysctl", "-n", "machdep.cpu.brand_string"]) if brand: return brand + hardware = best_effort_output(["system_profiler", "SPHardwareDataType"]) + if hardware: + chip = re.search(r"^\s*Chip:\s*(.+)$", hardware, re.MULTILINE) + if chip: + return chip.group(1).strip() elif system == "Linux": try: with open("/proc/cpuinfo") as f: @@ -176,7 +181,7 @@ def build_metadata(args: argparse.Namespace, build_dir: Path | None) -> dict[str "captured_at": datetime.now(timezone.utc).isoformat(), "machine_id": args.machine_id, "os": detect_os(), - "cpu_model": detect_cpu_model(), + "cpu_model": args.cpu_model or detect_cpu_model(), "compiler": detect_compiler(cache), "compiler_flags": detect_compiler_flags(cache), "build_type": cache.get("CMAKE_BUILD_TYPE", "unknown"), @@ -234,7 +239,13 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument( "--machine-id", default=sanitize_machine_id(socket.gethostname()), - help="identifier this baseline is filed under (default: sanitized hostname)", + type=sanitize_machine_id, + help="identifier this baseline is filed under (always sanitized; default: hostname)", + ) + parser.add_argument( + "--cpu-model", + default=None, + help="CPU model to record when automatic detection is unavailable", ) parser.add_argument( "--output-dir", diff --git a/benchmarks/test_capture_baseline.py b/benchmarks/test_capture_baseline.py new file mode 100644 index 00000000..9478e407 --- /dev/null +++ b/benchmarks/test_capture_baseline.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Hermetic tests for the baseline-capture command-line workflow.""" + +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPT = Path(__file__).with_name("capture_baseline.py") +SPEC = importlib.util.spec_from_file_location("capture_baseline", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +capture_baseline = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(capture_baseline) + + +class CaptureBaselineTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + self.binary = self.root / "fake_benchmark.py" + self.binary.write_text( + "#!/usr/bin/env python3\n" + "import json\n" + "import sys\n" + "from pathlib import Path\n" + "out = next(arg.split('=', 1)[1] for arg in sys.argv " + "if arg.startswith('--benchmark_out='))\n" + "Path(out).write_text(json.dumps({'context': {'source': 'fake'}, " + "'benchmarks': []}))\n" + ) + self.binary.chmod(0o755) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def test_capture_tags_json_and_protects_machine_commit_artifact(self) -> None: + output_dir = self.root / "baselines" + metadata = { + "captured_at": "2026-07-20T10:00:00+00:00", + "machine_id": "machine-a", + "os": "test-os", + "cpu_model": "test-cpu", + "compiler": "test-compiler", + "compiler_flags": "-O3", + "build_type": "Release", + "hdf5_version": "1.14.3", + "filesystem": "testfs", + "slurm_partition": None, + "commit_sha": "deadbeef", + "commit_dirty": False, + } + common_args = [ + "--binary", str(self.binary), + "--machine-id", "../Machine A", + "--output-dir", str(output_dir), + ] + args = [ + *common_args, + "--", "--benchmark_min_time=1x", + ] + + with mock.patch.object(capture_baseline, "build_metadata", return_value=metadata): + self.assertEqual(capture_baseline.main(args), 0) + + artifact = output_dir / "machine-a" / "deadbeef.json" + self.assertTrue(artifact.is_file()) + captured = json.loads(artifact.read_text()) + self.assertEqual(captured["context"]["source"], "fake") + self.assertEqual(captured["context"]["imas_baseline_metadata"], metadata) + + self.assertEqual(capture_baseline.main(args), 1) + self.assertEqual( + capture_baseline.main([*common_args, "--force", "--", "--benchmark_min_time=1x"]), + 0, + ) + + def test_detect_cpu_model_reads_apple_silicon_chip(self) -> None: + with mock.patch.object(capture_baseline.platform, "system", return_value="Darwin"), \ + mock.patch.object( + capture_baseline, + "best_effort_output", + side_effect=[None, "Hardware:\n Chip: Apple M5 Pro\n"], + ): + self.assertEqual(capture_baseline.detect_cpu_model(), "Apple M5 Pro") + + def test_explicit_machine_id_is_sanitized(self) -> None: + args = capture_baseline.parse_args( + ["--binary", str(self.binary), "--machine-id", "../../outside"] + ) + self.assertEqual(args.machine_id, "outside") + + +if __name__ == "__main__": + unittest.main() From 2b9df07928fd559a661060c599792f738db1d13b Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:05:43 +0200 Subject: [PATCH 78/91] Validate benchmark JSON with CMake parser --- ...e66c80725a6fc44b37c333789e8e80903a549.json | 748 ++++++++++++++++++ 1 file changed, 748 insertions(+) create mode 100644 benchmarks/baselines/laptop-macbook-pro-de-yohann/c0ee66c80725a6fc44b37c333789e8e80903a549.json diff --git a/benchmarks/baselines/laptop-macbook-pro-de-yohann/c0ee66c80725a6fc44b37c333789e8e80903a549.json b/benchmarks/baselines/laptop-macbook-pro-de-yohann/c0ee66c80725a6fc44b37c333789e8e80903a549.json new file mode 100644 index 00000000..b3a1a7bd --- /dev/null +++ b/benchmarks/baselines/laptop-macbook-pro-de-yohann/c0ee66c80725a6fc44b37c333789e8e80903a549.json @@ -0,0 +1,748 @@ +{ + "context": { + "date": "2026-07-20T16:44:51+02:00", + "host_name": "MacBook-Pro-de-Yohann.local", + "executable": "build-benchmarks/benchmarks/al_benchmarks", + "num_cpus": 18, + "mhz_per_cpu": 24, + "cpu_scaling_enabled": false, + "caches": [ + { + "type": "Data", + "level": 1, + "size": 65536, + "num_sharing": 0 + }, + { + "type": "Instruction", + "level": 1, + "size": 131072, + "num_sharing": 0 + }, + { + "type": "Unified", + "level": 2, + "size": 8388608, + "num_sharing": 1 + } + ], + "load_avg": [ + 1.85303, + 1.69189, + 1.72119 + ], + "library_version": "v1.9.5", + "library_build_type": "release", + "json_schema_version": 1, + "imas_baseline_metadata": { + "captured_at": "2026-07-20T14:44:50.047160+00:00", + "machine_id": "laptop-macbook-pro-de-yohann", + "os": "macOS-26.5.1-arm64-arm-64bit-Mach-O", + "cpu_model": "Apple M5 Pro", + "compiler": "Apple clang version 21.0.0 (clang-2100.1.1.101)", + "compiler_flags": "-O3 -DNDEBUG", + "build_type": "Release", + "hdf5_version": "2.1.1", + "filesystem": "apfs", + "slurm_partition": null, + "commit_sha": "c0ee66c80725a6fc44b37c333789e8e80903a549", + "commit_dirty": false + } + }, + "benchmarks": [ + { + "name": "BM_WriteReadRoundTrip", + "family_index": 0, + "per_family_instance_index": 0, + "run_name": "BM_WriteReadRoundTrip", + "run_type": "iteration", + "repetitions": 1, + "repetition_index": 0, + "threads": 1, + "iterations": 1, + "real_time": 113834.01579223573, + "cpu_time": 113999.99999994748, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 0, + "threads": 1, + "iterations": 1, + "real_time": 454708.0025076866, + "cpu_time": 454000.0000001765, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 1, + "threads": 1, + "iterations": 1, + "real_time": 372499.9842233956, + "cpu_time": 373000.00000017876, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 2, + "threads": 1, + "iterations": 1, + "real_time": 340750.0043977052, + "cpu_time": 340000.000000007, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 3, + "threads": 1, + "iterations": 1, + "real_time": 316374.99341741204, + "cpu_time": 315999.99999976094, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 4, + "threads": 1, + "iterations": 1, + "real_time": 361374.9868236482, + "cpu_time": 361999.9999997514, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 5, + "threads": 1, + "iterations": 1, + "real_time": 346792.017808184, + "cpu_time": 347000.0000000972, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 6, + "threads": 1, + "iterations": 1, + "real_time": 406292.00520925224, + "cpu_time": 405999.99999990646, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 7, + "threads": 1, + "iterations": 1, + "real_time": 347084.01653915644, + "cpu_time": 348000.00000001496, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 8, + "threads": 1, + "iterations": 1, + "real_time": 415999.99531172216, + "cpu_time": 416999.9999998897, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 9, + "threads": 1, + "iterations": 1, + "real_time": 343457.99940638244, + "cpu_time": 343999.99999989994, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 10, + "threads": 1, + "iterations": 1, + "real_time": 346459.0117800981, + "cpu_time": 346000.0000001795, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 11, + "threads": 1, + "iterations": 1, + "real_time": 323999.9932702631, + "cpu_time": 323999.9999997689, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 12, + "threads": 1, + "iterations": 1, + "real_time": 444333.01081880927, + "cpu_time": 438000.0000001605, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 13, + "threads": 1, + "iterations": 1, + "real_time": 330416.98043234646, + "cpu_time": 330000.0000001635, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 14, + "threads": 1, + "iterations": 1, + "real_time": 331124.9893158674, + "cpu_time": 330999.9999998592, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 15, + "threads": 1, + "iterations": 1, + "real_time": 313333.0028504133, + "cpu_time": 313000.00000000774, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 16, + "threads": 1, + "iterations": 1, + "real_time": 324791.00627824664, + "cpu_time": 325000.00000013073, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 17, + "threads": 1, + "iterations": 1, + "real_time": 332249.99788217247, + "cpu_time": 332999.9999999167, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 18, + "threads": 1, + "iterations": 1, + "real_time": 449457.9916354269, + "cpu_time": 448999.9999999217, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 19, + "threads": 1, + "iterations": 1, + "real_time": 414999.9876972288, + "cpu_time": 415000.0000000542, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20_mean", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "mean", + "aggregate_unit": "time", + "iterations": 20, + "real_time": 365824.99888027087, + "cpu_time": 365549.99999999226, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20_median", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "median", + "aggregate_unit": "time", + "iterations": 20, + "real_time": 346625.51479414105, + "cpu_time": 346500.00000013836, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20_stddev", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "stddev", + "aggregate_unit": "time", + "iterations": 20, + "real_time": 47090.23247364246, + "cpu_time": 46503.50863959255, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20_cv", + "family_index": 1, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/HDF5/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "iterations": 20, + "real_time": 0.12872338582048187, + "cpu_time": 0.12721517888002606, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 0, + "threads": 1, + "iterations": 1, + "real_time": 9207.986295223236, + "cpu_time": 10000.000000065513, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 1, + "threads": 1, + "iterations": 1, + "real_time": 4750.007065013051, + "cpu_time": 4000.000000115023, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 2, + "threads": 1, + "iterations": 1, + "real_time": 4458.008334040642, + "cpu_time": 3999.999999892978, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 3, + "threads": 1, + "iterations": 1, + "real_time": 4333.0073822289705, + "cpu_time": 5000.000000032756, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 4, + "threads": 1, + "iterations": 1, + "real_time": 3875.0004023313522, + "cpu_time": 4000.000000115023, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 5, + "threads": 1, + "iterations": 1, + "real_time": 3875.0004023313522, + "cpu_time": 3999.999999892978, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 6, + "threads": 1, + "iterations": 1, + "real_time": 3708.0026231706142, + "cpu_time": 2999.9999999752445, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 7, + "threads": 1, + "iterations": 1, + "real_time": 3791.9962778687477, + "cpu_time": 2999.9999999752445, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 8, + "threads": 1, + "iterations": 1, + "real_time": 3749.999450519681, + "cpu_time": 5000.000000032756, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 9, + "threads": 1, + "iterations": 1, + "real_time": 4000.0013541430235, + "cpu_time": 4999.999999810711, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 10, + "threads": 1, + "iterations": 1, + "real_time": 3790.977643802762, + "cpu_time": 2999.9999999752445, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 11, + "threads": 1, + "iterations": 1, + "real_time": 3709.0212572366, + "cpu_time": 4000.000000115023, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 12, + "threads": 1, + "iterations": 1, + "real_time": 3708.0026231706142, + "cpu_time": 4000.000000115023, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 13, + "threads": 1, + "iterations": 1, + "real_time": 3708.0026231706142, + "cpu_time": 3000.0000001972894, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 14, + "threads": 1, + "iterations": 1, + "real_time": 3708.992153406143, + "cpu_time": 3999.999999892978, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 15, + "threads": 1, + "iterations": 1, + "real_time": 3708.992153406143, + "cpu_time": 3999.999999892978, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 16, + "threads": 1, + "iterations": 1, + "real_time": 3708.0026231706142, + "cpu_time": 4000.000000115023, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 17, + "threads": 1, + "iterations": 1, + "real_time": 3749.999450519681, + "cpu_time": 3999.999999892978, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 18, + "threads": 1, + "iterations": 1, + "real_time": 3749.999450519681, + "cpu_time": 2999.9999999752445, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "iteration", + "repetitions": 20, + "repetition_index": 19, + "threads": 1, + "iterations": 1, + "real_time": 3791.0067476332188, + "cpu_time": 4000.000000115023, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20_mean", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "mean", + "aggregate_unit": "time", + "iterations": 20, + "real_time": 4154.100315645338, + "cpu_time": 4200.000000009752, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20_median", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "median", + "aggregate_unit": "time", + "iterations": 20, + "real_time": 3770.4885471612215, + "cpu_time": 4000.0000000040004, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20_stddev", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "stddev", + "aggregate_unit": "time", + "iterations": 20, + "real_time": 1224.2255034969583, + "cpu_time": 1507.8740698547117, + "time_unit": "ns" + }, + { + "name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20_cv", + "family_index": 2, + "per_family_instance_index": 0, + "run_name": "BM_EquilibriumWholeIdsRead/Memory/repeats:20", + "run_type": "aggregate", + "repetitions": 20, + "threads": 1, + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "iterations": 20, + "real_time": 0.2947029225284309, + "cpu_time": 0.35901763567885975, + "time_unit": "ns" + } + ] +} From 9554df21bdce47449763d2c3f5e9260b90a9d470 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:52:21 +0200 Subject: [PATCH 79/91] Extract shared per-backend benchmark boilerplate into bench_common.h Moves BenchBackend, the HDF5/Memory URI builder, and pulse-open lifecycle setup out of bench_equilibrium_read.cpp into bench_common.h so the three follow-on benchmarks (issue #57) can reuse them instead of re-deriving the same boilerplate. No change to the existing whole-IDS-read benchmark's behavior or reported numbers (issue #58). --- benchmarks/bench_common.h | 74 +++++++++++++++++++++++++-- benchmarks/bench_equilibrium_read.cpp | 55 +++----------------- 2 files changed, 76 insertions(+), 53 deletions(-) diff --git a/benchmarks/bench_common.h b/benchmarks/bench_common.h index 7b248a3a..3dab4c0d 100644 --- a/benchmarks/bench_common.h +++ b/benchmarks/bench_common.h @@ -1,12 +1,21 @@ -// Shared by every bench_*.cpp TU in al_benchmarks: the one status-checking -// helper each benchmark uses to turn a failing al_status_t into a thrown -// exception (Google Benchmark reports the failure instead of silently timing -// garbage). Header-only so it carries no extra link surface. +// Shared by every bench_*.cpp TU in al_benchmarks: the status-checking helper +// each benchmark uses to turn a failing al_status_t into a thrown exception +// (Google Benchmark reports the failure instead of silently timing garbage), +// plus the per-backend (HDF5 vs. Memory) URI construction and pulse-open +// boilerplate that every whole-IDS-scoped benchmark needs identically (issue +// #58, prefactor for PRD #57's write/single-slice/AOS-traversal benchmarks). +// Header-only so it carries no extra link surface. #ifndef AL_BENCHMARKS_COMMON_H #define AL_BENCHMARKS_COMMON_H +#include "al_contract_legacy_storage.h" + +#include #include +#include +#include +#include #include #include @@ -20,6 +29,63 @@ inline void CheckOk(const al_status_t& status, const char* what) { } } +enum class BenchBackend { kHdf5, kMemory }; + +// HDF5 resolves FORCE_CREATE_PULSE against a real on-disk legacy tree, so it +// needs `base` alive for the URI to mean anything. Memory never touches disk +// (bench_write_read.cpp's MemoryUri established the pattern): an arbitrary +// absolute-looking `user` sidesteps the legacy URI builder's getpwnam() +// fallback for an empty/unknown user, without paying for a real temp dir. +inline std::string BuildUri(BenchBackend backend, const al_contract::PulseId& pulse, + const al_contract::LegacyPulseDirectory* base) { + char* uri = nullptr; + if (backend == BenchBackend::kHdf5) { + assert(base != nullptr && "HDF5 backend requires a legacy pulse directory for its URI"); + CheckOk(al_build_uri_from_legacy_parameters( + HDF5_BACKEND, pulse.pulse, pulse.run, base->str().c_str(), + pulse.database.c_str(), pulse.version.c_str(), + /*options=*/"", &uri), + "al_build_uri_from_legacy_parameters"); + } else { + CheckOk(al_build_uri_from_legacy_parameters( + MEMORY_BACKEND, pulse.pulse, pulse.run, "/al-benchmarks", + pulse.database.c_str(), pulse.version.c_str(), + /*options=*/"", &uri), + "al_build_uri_from_legacy_parameters"); + } + std::string result(uri ? uri : ""); + free(uri); + return result; +} + +// An open pulse context plus (HDF5 only) the temp legacy directory backing +// it. `base` is a unique_ptr rather than the more obvious optional<> so +// BenchPulse stays movable out of OpenBenchPulse regardless of +// LegacyPulseDirectory's own non-movability (its destructor removes the temp +// dir on every exit path, so it must outlive the pulse being open). +struct BenchPulse { + int pulse_ctx = -1; + std::unique_ptr base; +}; + +// Per-backend pulse lifecycle setup shared by every whole-IDS-scoped +// benchmark: materialize the on-disk legacy tree (HDF5 only), build the +// backend's URI, and open the pulse with FORCE_CREATE_PULSE. Callers write +// their fixture and run their timed region on the returned pulse_ctx, then +// close it themselves (close is operation-specific: some benchmarks time +// around it, none should hide it in a header). +inline BenchPulse OpenBenchPulse(BenchBackend backend, const al_contract::PulseId& pulse) { + BenchPulse result; + if (backend == BenchBackend::kHdf5) { + result.base = std::make_unique(); + result.base->make_legacy_tree(pulse); + } + const std::string uri = BuildUri(backend, pulse, result.base.get()); + CheckOk(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &result.pulse_ctx), + "al_begin_dataentry_action"); + return result; +} + } // namespace al_bench #endif // AL_BENCHMARKS_COMMON_H diff --git a/benchmarks/bench_equilibrium_read.cpp b/benchmarks/bench_equilibrium_read.cpp index b7a75a65..923d682c 100644 --- a/benchmarks/bench_equilibrium_read.cpp +++ b/benchmarks/bench_equilibrium_read.cpp @@ -26,53 +26,12 @@ #include -#include -#include -#include - -namespace { - +using al_bench::BenchBackend; using al_bench::CheckOk; -enum class BenchBackend { kHdf5, kMemory }; - -// HDF5 resolves FORCE_CREATE_PULSE against a real on-disk legacy tree, so it -// needs `base` alive for the URI to mean anything. Memory never touches disk -// (bench_write_read.cpp's MemoryUri established the pattern): an arbitrary -// absolute-looking `user` sidesteps the legacy URI builder's getpwnam() -// fallback for an empty/unknown user, without paying for a real temp dir. -std::string BuildUri(BenchBackend backend, const al_contract::PulseId& pulse, - const al_contract::LegacyPulseDirectory* base) { - char* uri = nullptr; - if (backend == BenchBackend::kHdf5) { - CheckOk(al_build_uri_from_legacy_parameters( - HDF5_BACKEND, pulse.pulse, pulse.run, base->str().c_str(), - pulse.database.c_str(), pulse.version.c_str(), - /*options=*/"", &uri), - "al_build_uri_from_legacy_parameters"); - } else { - CheckOk(al_build_uri_from_legacy_parameters( - MEMORY_BACKEND, pulse.pulse, pulse.run, "/al-benchmarks", - pulse.database.c_str(), pulse.version.c_str(), - /*options=*/"", &uri), - "al_build_uri_from_legacy_parameters"); - } - std::string result(uri ? uri : ""); - free(uri); - return result; -} - -} // namespace - void BM_EquilibriumWholeIdsRead(benchmark::State& state, BenchBackend backend) { const al_contract::PulseId pulse{/*database=*/"bench", /*version=*/"3", /*pulse=*/1, /*run=*/0}; - std::optional base; - if (backend == BenchBackend::kHdf5) { - base.emplace(); - base->make_legacy_tree(pulse); - } - const std::string uri = BuildUri(backend, pulse, base ? &*base : nullptr); // --- untimed setup: open once, write the reference equilibrium once ------ // Memory keeps the fixed pulse identity across repetitions unlike @@ -80,22 +39,20 @@ void BM_EquilibriumWholeIdsRead(benchmark::State& state, BenchBackend backend) { // no-op (src/memory_backend.h), so correctness across the 20 Repetitions // rests on FORCE_CREATE_PULSE clearing the reused entry's idsMap on reopen, // not on pulse identities never colliding. - int pulse_ctx = -1; - CheckOk(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pulse_ctx), - "al_begin_dataentry_action"); - CheckOk(equilibrium_seed::write(pulse_ctx), "equilibrium_seed::write"); + al_bench::BenchPulse bp = al_bench::OpenBenchPulse(backend, pulse); + CheckOk(equilibrium_seed::write(bp.pulse_ctx), "equilibrium_seed::write"); // --- timed region: begin_global_action(READ) -> every leaf -> end_action - std::vector records; for (auto _ : state) { - CheckOk(equilibrium_seed::read_back(pulse_ctx, &records), + CheckOk(equilibrium_seed::read_back(bp.pulse_ctx, &records), "equilibrium_seed::read_back"); benchmark::DoNotOptimize(records); } // --- untimed teardown ------------------------------------------------------ - CheckOk(al_close_pulse(pulse_ctx, CLOSE_PULSE), "al_close_pulse"); - // base's destructor (HDF5 only) removes the temp directory on every exit path. + CheckOk(al_close_pulse(bp.pulse_ctx, CLOSE_PULSE), "al_close_pulse"); + // bp.base's destructor (HDF5 only) removes the temp directory on every exit path. } // Repetitions(N) makes Google Benchmark report the distribution (median, // mean, stddev, iteration count) across N independent setup/read/teardown From f15d86af8fb8aa5cbded114f3597c30322dc5010 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:14:27 +0200 Subject: [PATCH 80/91] Add single time-slice-element read benchmark (HDF5 + Memory) (issue #60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads one time_slice element's full leaf set (timebase leaf, radial profile, scalar quantity, constraints subtree) by index, node-by-node, through the public C ABI -- the shared "before" baseline both P2's al_read_subtree and P3's slice-mode buffering (NORTH_STAR.md §7) will diff against, since neither optimization exists yet and this node-by-node walk is the only legal way to fetch one AOS element today. --- benchmarks/CMakeLists.txt | 6 ++ benchmarks/bench_timeslice_element_read.cpp | 102 ++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 benchmarks/bench_timeslice_element_read.cpp diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index 605a9430..78762ca9 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -37,12 +37,18 @@ find_package(Python3 COMPONENTS Interpreter REQUIRED) # operation against both HDF5 (needs the filesystem -- std::filesystem temp # dirs) and, as a zero-I/O control (issue #54), the in-RAM Memory backend. # +# bench_timeslice_element_read.cpp (issue #60) is the shared "before" +# baseline for the future P2 (al_read_subtree) and P3 (slice-mode buffering) +# optimizations (NORTH_STAR.md §7): one time_slice element's full leaf set, +# read node-by-node by index, against both HDF5 and Memory. +# # bench_main.cpp holds the one BENCHMARK_MAIN() shared by every bench_*.cpp # TU in this executable. add_executable( al_benchmarks bench_write_read.cpp bench_equilibrium_read.cpp + bench_timeslice_element_read.cpp bench_main.cpp ) target_link_libraries( al_benchmarks PRIVATE al_contract_fixtures benchmark::benchmark ) add_dependencies( al_benchmarks al ) diff --git a/benchmarks/bench_timeslice_element_read.cpp b/benchmarks/bench_timeslice_element_read.cpp new file mode 100644 index 00000000..90c5cf80 --- /dev/null +++ b/benchmarks/bench_timeslice_element_read.cpp @@ -0,0 +1,102 @@ +// The shared "before" baseline for two future optimizations (issue #60, +// PRD #57): a single time_slice element's full leaf set -- its own timebase +// leaf, radial profile array, scalar quantity, and constraints subtree -- +// read by index, node-by-node, through the public C ABI. Today this +// node-by-node walk is the *only* legal way to fetch one AOS element: no +// al_read_subtree call exists yet (NORTH_STAR.md §7 P2) and HDF5 slice-mode +// buffering hasn't been built yet either (§7 P3). Both future optimizations +// diff against BM_TimeSliceElementRead below rather than against two +// near-identical registrations of their own. +// +// Reuses equilibrium_seed.h verbatim (issue #51) for the fixture, exactly as +// bench_equilibrium_read.cpp does, so the workload never drifts from what the +// contract suite already pins. Only the read shape differs: where +// BM_EquilibriumWholeIdsRead walks every time_slice element, +// BM_TimeSliceElementRead selects one (the last, kNSlices - 1, so the +// benchmark exercises al_iterate_over_arraystruct actually moving the +// cursor rather than reading the trivial post-begin default position) and +// reads only that element's five leaves. +// +// Timed region = one operation (issue #53, PRD #50 user story 6): the +// reference equilibrium is written and the data entry opened once in untimed +// setup; only begin_global_action(READ) -> begin_arraystruct_action -> +// iterate to the target index -> read every leaf of that element -> +// end_action(aos) -> end_action(op) is inside the `for (auto _ : state)` loop. +// +// Backend is a sub-axis of the operation (PRD #50 "structure: +// scenario-primary, backend as a sub-axis"): BM_TimeSliceElementRead is the +// one operation body, HDF5 and Memory are two BENCHMARK_CAPTURE +// registrations of it differing only in which backend id the URI is built +// for. + +#include "bench_common.h" +#include "al_contract_abi.h" +#include "al_contract_legacy_storage.h" +#include "equilibrium_seed.h" + +#include +#include + +#include + +using al_bench::BenchBackend; +using al_bench::CheckOk; + +void BM_TimeSliceElementRead(benchmark::State& state, BenchBackend backend) { + const al_contract::PulseId pulse{/*database=*/"bench", /*version=*/"3", + /*pulse=*/1, /*run=*/0}; + constexpr int kTargetSlice = equilibrium_seed::kNSlices - 1; + + // --- untimed setup: open once, write the reference equilibrium once ------ + al_bench::BenchPulse bp = al_bench::OpenBenchPulse(backend, pulse); + CheckOk(equilibrium_seed::write(bp.pulse_ctx), "equilibrium_seed::write"); + + // --- timed region: begin_global_action(READ) -> select element -> + // read every leaf of that element -> end_action ---------------------- + std::vector shape; + std::vector data; + for (auto _ : state) { + int op = -1; + CheckOk(al_begin_global_action(bp.pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op), + "al_begin_global_action"); + + int size = 0; + int aos_ctx = -1; + CheckOk(al_begin_arraystruct_action(op, equilibrium_seed::kAos, + equilibrium_seed::kAosTime, &size, + &aos_ctx), + "al_begin_arraystruct_action"); + // `step` is relative to the cursor's current position, not an absolute + // index (al_lowlevel.h: "iteration step size, typically=1"); this only + // lands on kTargetSlice because al_begin_arraystruct_action always opens + // at index 0. + CheckOk(al_iterate_over_arraystruct(aos_ctx, kTargetSlice), + "al_iterate_over_arraystruct"); + + auto read_leaf = [&](const char* path, int rank, const char* what) { + CheckOk(al_contract::read_data(aos_ctx, path, rank, &shape, &data), + what); + benchmark::DoNotOptimize(data); + }; + read_leaf(equilibrium_seed::kSliceTime, 0, "read time"); + read_leaf(equilibrium_seed::kPsi, 1, "read profiles_1d/psi"); + read_leaf(equilibrium_seed::kIp, 0, "read global_quantities/ip"); + read_leaf(equilibrium_seed::kMeasured, 0, "read constraints/ip/measured"); + read_leaf(equilibrium_seed::kWeight, 0, "read constraints/ip/weight"); + + CheckOk(al_end_action(aos_ctx), "al_end_action(aos_ctx)"); + CheckOk(al_end_action(op), "al_end_action(op)"); + } + + // --- untimed teardown ------------------------------------------------------ + CheckOk(al_close_pulse(bp.pulse_ctx, CLOSE_PULSE), "al_close_pulse"); + // bp.base's destructor (HDF5 only) removes the temp directory on every exit path. +} +// Repetitions(N) makes Google Benchmark report the distribution (median, +// mean, stddev, iteration count) across N independent read cycles, not a +// bare single-shot mean (issue #53, PRD #50 user story 8). +BENCHMARK_CAPTURE(BM_TimeSliceElementRead, HDF5, BenchBackend::kHdf5) + ->Repetitions(20); +BENCHMARK_CAPTURE(BM_TimeSliceElementRead, Memory, BenchBackend::kMemory) + ->Repetitions(20); From 353a66d0d3b2a758b8cd3cac42e8e79628e37825 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:41:57 +0200 Subject: [PATCH 81/91] Fix HDF5 dataset re-creation on repeated whole-IDS writes to an open pulse (issue #60) HDF5Writer::write_ND_Data and ::createOrUpdateShapesDataSet decided create-vs-reuse for a dataset purely from the per-action opened_data_sets cache, which is cleared at the end of every al_end_action(WRITE_OP) even though the underlying HDF5 file (and its datasets) persists for as long as the pulse stays open. A second global write on the same still-open pulse therefore always took the "not yet created" branch and called H5Dcreate2 on a dataset that already existed, throwing "Unable to create HDF5 dataset". Both call sites now check H5Lexists first and open() the existing dataset instead of re-creating it, mirroring the SLICE_OP branch a few lines below (and createOrUpdateAOSShapesDataSet, a third function that already had this guard). Adds EquilibriumSeedMatrix.RepeatedWholeIdsWriteOnSameOpenPulseSucceeds to pin the fix: two al_begin_global_action(WRITE_OP) calls on the same open pulse, reproducing the exact failure shape independent of which benchmark or caller triggers it. Verified by reverting the fix alone and confirming the new test fails with the original error message. --- src/hdf5/hdf5_writer.cpp | 16 +++++++++--- tests/contract/test_structured_data.cpp | 33 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/hdf5/hdf5_writer.cpp b/src/hdf5/hdf5_writer.cpp index 57d8f702..477fd514 100644 --- a/src/hdf5/hdf5_writer.cpp +++ b/src/hdf5/hdf5_writer.cpp @@ -414,12 +414,17 @@ void HDF5Writer::write_ND_Data(Context * ctx, std::string & att_name, std::strin if (slice_mode != SLICE_OP) { //std::cout << "WRITER NOT IN SLICE MODE!!! " << std::endl; - if (dataset_id < 0) //in global mode (put), data set not yet created + if (dataset_id < 0) //not in this action's opened_data_sets cache -- may still exist on disk from a prior action on the same open pulse { std::unique_ptr < HDF5DataSetHandler > dataSetHandler(new HDF5DataSetHandler(true, dec->getURI())); dataSetHandler->setNonSliceMode(); bool create_chunk_cache = true; - dataSetHandler->create(tensorized_path.c_str(), &dataset_id, datatype, gid, dim, size, AOSRank, arrctx_shapes.data(), false, create_chunk_cache); + if (H5Lexists(gid, tensorized_path.c_str(), H5P_DEFAULT) == 0) { + dataSetHandler->create(tensorized_path.c_str(), &dataset_id, datatype, gid, dim, size, AOSRank, arrctx_shapes.data(), false, create_chunk_cache); + } else { + dataSetHandler->open(tensorized_path.c_str(), gid, &dataset_id, dim, size, datatype, false, create_chunk_cache, dec->getURI(), AOSRank, arrctx_shapes.data()); + dataSetHandler->setCurrentShapesAndExtend(size, arrctx_shapes.data()); + } data_set = std::move(dataSetHandler); } else { data_set = std::move(got->second); @@ -608,7 +613,12 @@ std::string & timebasename, int timed_AOS_index, const std::vector < int > &curr } else { data_set->setNonSliceMode(); bool create_chunk_cache = true; - data_set->create(tensorized_path.c_str(), &dataset_id, alconst::integer_data, loc_id, dim, size, AOSRank, aos_shapes.data(), shapes_dataset, create_chunk_cache); + if (H5Lexists(loc_id, tensorized_path.c_str(), H5P_DEFAULT) == 0) { + data_set->create(tensorized_path.c_str(), &dataset_id, alconst::integer_data, loc_id, dim, size, AOSRank, aos_shapes.data(), shapes_dataset, create_chunk_cache); + } else { + data_set->open(tensorized_path.c_str(), loc_id, &dataset_id, dim, size, alconst::integer_data, shapes_dataset, create_chunk_cache, dec->getURI(), AOSRank, aos_shapes.data()); + data_set->setCurrentShapesAndExtend(size, aos_shapes.data()); + } } if (data_set->useBuffering && slice_mode != SLICE_OP) { diff --git a/tests/contract/test_structured_data.cpp b/tests/contract/test_structured_data.cpp index 86029d7f..a625fbad 100644 --- a/tests/contract/test_structured_data.cpp +++ b/tests/contract/test_structured_data.cpp @@ -367,6 +367,39 @@ TEST_P(EquilibriumSeedMatrix, RoundTripHashMatches) { EXPECT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); } +// A second whole-IDS write on the *same still-open* pulse must succeed and +// overwrite in place, not fail as if the underlying storage were being +// created for the first time (issue #60 follow-up: HDF5Writer::write_ND_Data +// and ::createOrUpdateShapesDataSet decided create-vs-reuse purely from their +// own per-action opened_data_sets cache -- which is cleared at the end of +// every al_begin_global_action/al_end_action pair -- instead of asking the +// file itself via H5Lexists, so any second global WRITE_OP on an already- +// populated open pulse re-attempted H5Dcreate2 on a dataset that already +// existed and threw "Unable to create HDF5 dataset"). Two independent +// al_begin_global_action(WRITE_OP) calls on one pctx reproduce the exact +// shape of the failure regardless of what benchmark or caller triggers it. +TEST_P(EquilibriumSeedMatrix, RepeatedWholeIdsWriteOnSameOpenPulseSucceeds) { + const BackendCase b = GetParam(); + if (b.on_disk) base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(b.id, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + AL_ASSERT_OK(equilibrium_seed::write(pctx)); + AL_ASSERT_OK(equilibrium_seed::write(pctx)) + << "a second whole-IDS write on the same still-open pulse must " + "overwrite in place, not fail as if the dataset didn't exist yet"; + + std::vector obs; + AL_ASSERT_OK(equilibrium_seed::read_back(pctx, &obs)); + EXPECT_EQ(equilibrium_seed::canonical_hash(obs), + equilibrium_seed::expected_hash()) + << "repeated writes must never accumulate stale data"; + + EXPECT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); +} + // --- structural-hash sensitivity (issue #33 acceptance criteria 2 & 3) ------ // Hermetic proofs that the canonical hash catches the two structural // corruptions a values-only hash would miss. They operate on the production From e7cfb7afc4eb9250f40324b6b88c25f97bcdc7b2 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:12:33 +0200 Subject: [PATCH 82/91] Add whole-IDS write benchmark (HDF5 + Memory) (issue #59) Write-side counterpart to bench_equilibrium_read.cpp: pulse opened once (untimed), equilibrium_seed::write repeated inside the timed loop on the same open context, closed once (untimed), mirroring the read benchmark's discipline exactly. An explicit extra untimed write after the loop guarantees at least two writes to the same context on every run -- including the CTest smoke run, which caps the timed loop at exactly one iteration -- so the post-loop structural-hash check against equilibrium_seed::expected_hash() is a real, non-vacuous verification that repeated writes never accumulate stale data, on both backends. Surfaced (and relies on the fix for) a genuine HDF5Writer bug where a second whole-IDS write to an already-populated open pulse always re-attempted H5Dcreate2 instead of opening the existing dataset -- fixed separately as part of issue #60's concurrent work (commit 353a66d). --- benchmarks/CMakeLists.txt | 6 ++ benchmarks/bench_equilibrium_write.cpp | 85 ++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 benchmarks/bench_equilibrium_write.cpp diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index 78762ca9..1a44d27f 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -37,6 +37,11 @@ find_package(Python3 COMPONENTS Interpreter REQUIRED) # operation against both HDF5 (needs the filesystem -- std::filesystem temp # dirs) and, as a zero-I/O control (issue #54), the in-RAM Memory backend. # +# bench_equilibrium_write.cpp is the write-side counterpart (issue #59): the +# same equilibrium_seed.h fixture, written repeatedly instead of read, +# against both backends, sharing bench_equilibrium_read.cpp's per-backend +# boilerplate via bench_common.h (issue #58). +# # bench_timeslice_element_read.cpp (issue #60) is the shared "before" # baseline for the future P2 (al_read_subtree) and P3 (slice-mode buffering) # optimizations (NORTH_STAR.md §7): one time_slice element's full leaf set, @@ -48,6 +53,7 @@ find_package(Python3 COMPONENTS Interpreter REQUIRED) add_executable( al_benchmarks bench_write_read.cpp bench_equilibrium_read.cpp + bench_equilibrium_write.cpp bench_timeslice_element_read.cpp bench_main.cpp ) target_link_libraries( al_benchmarks PRIVATE al_contract_fixtures benchmark::benchmark ) diff --git a/benchmarks/bench_equilibrium_write.cpp b/benchmarks/bench_equilibrium_write.cpp new file mode 100644 index 00000000..2e9b405c --- /dev/null +++ b/benchmarks/bench_equilibrium_write.cpp @@ -0,0 +1,85 @@ +// The write-side counterpart to bench_equilibrium_read.cpp (issue #59, PRD +// #57 user stories 2-5): whole-IDS write of the reference equilibrium +// fixture, driven strictly at the public C ABI (al_lowlevel.h), reusing +// equilibrium_seed.h verbatim and bench_common.h's shared per-backend +// pulse-open helper (issue #58). +// +// Timed region mirrors the read benchmark's discipline exactly: the pulse is +// opened once (FORCE_CREATE_PULSE) and closed once, both untimed; only the +// repeated equilibrium_seed::write call sits inside the +// `for (auto _ : state)` loop, so open/close cost never contaminates the +// write measurement. +// +// Repeated writes to the same open pulse context must never accumulate stale +// data from a prior write (PRD #57 user story 5): ALData::writeData clears +// its buffer before repopulating (src/memory_backend.cpp) and the +// array-of-structures path replaces rather than appends elements, for any +// whole-IDS (non-slice) write. This is checked once, untimed, after the timed +// loop -- but the timed loop's own iteration count isn't a reliable way to +// exercise "repeated": the CTest smoke entry caps every benchmark at exactly +// one iteration (--benchmark_min_time=1x), which would make the check +// vacuous (one write trivially matches one write's hash) on the one run this +// repo executes automatically. So an explicit second untimed write happens +// after the loop, guaranteeing at least two writes to the same context +// regardless of how many timed iterations ran, before a read-back of the +// resulting structural hash is compared against equilibrium_seed's +// expected_hash() exactly, on both backends. The hash folds in AOS size +// (equilibrium_seed.h), so an accumulation bug (e.g. elements appended +// instead of replaced) would drift it and this check -- which the existing +// CTest smoke entry already runs -- would throw. + +#include "bench_common.h" +#include "equilibrium_seed.h" + +#include +#include + +#include + +#include +#include + +using al_bench::BenchBackend; +using al_bench::CheckOk; + +void BM_EquilibriumWholeIdsWrite(benchmark::State& state, BenchBackend backend) { + // pulse=2 (distinct from bench_equilibrium_read.cpp's pulse=1) so the two + // benchmarks' Memory-backend entries never share an identity within the + // same al_benchmarks process. + const al_contract::PulseId pulse{/*database=*/"bench", /*version=*/"3", + /*pulse=*/2, /*run=*/0}; + + // --- untimed setup: open once ---------------------------------------------- + al_bench::BenchPulse bp = al_bench::OpenBenchPulse(backend, pulse); + + // --- timed region: repeated whole-IDS write on the same open context ----- + for (auto _ : state) { + CheckOk(equilibrium_seed::write(bp.pulse_ctx), "equilibrium_seed::write"); + } + + // --- untimed correctness check: no stale data survives repeated writes --- + // Force a second write regardless of the timed loop's iteration count (see + // top-of-file comment), then verify no staleness accumulated. + CheckOk(equilibrium_seed::write(bp.pulse_ctx), "equilibrium_seed::write"); + uint64_t hash = 0; + CheckOk(equilibrium_seed::read_and_hash(bp.pulse_ctx, &hash), + "equilibrium_seed::read_and_hash"); + if (hash != equilibrium_seed::expected_hash()) { + throw std::runtime_error( + "BM_EquilibriumWholeIdsWrite: repeated-write structural hash does " + "not match expected_hash() -- repeated writes may have accumulated " + "stale data"); + } + + // --- untimed teardown ------------------------------------------------------ + CheckOk(al_close_pulse(bp.pulse_ctx, CLOSE_PULSE), "al_close_pulse"); + // bp.base's destructor (HDF5 only) removes the temp directory on every exit path. +} +// Repetitions(N) makes Google Benchmark report the distribution (median, +// mean, stddev, iteration count) across N independent setup/write/teardown +// cycles, not a bare single-shot mean (issue #59, PRD #57 user story 10), +// mirroring bench_equilibrium_read.cpp. +BENCHMARK_CAPTURE(BM_EquilibriumWholeIdsWrite, HDF5, BenchBackend::kHdf5) + ->Repetitions(20); +BENCHMARK_CAPTURE(BM_EquilibriumWholeIdsWrite, Memory, BenchBackend::kMemory) + ->Repetitions(20); From 2d7e07cf4595ff6673e2a7efd0638eb01c685627 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:21:55 +0200 Subject: [PATCH 83/91] Add AOS-heavy traversal benchmark (HDF5 + Memory) (issue #61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iterates every time_slice element via al_iterate_over_arraystruct, reading all five leaves of each, against both backends -- the "before" baseline for P1/P4's round-trip-count reduction (NORTH_STAR.md §7) and the macro signal that would justify a future P5 per-call primitive micro-benchmark if it comes back slow. Functionally the same traversal bench_equilibrium_read.cpp already drives via equilibrium_seed::read_back, isolated and named as its own operation because its coverage target (P1/P4 round-trip count) is distinct from "read everything in the IDS" as a whole. Reuses equilibrium_seed.h and bench_common.h's shared per-backend boilerplate (issue #58) unchanged; added as a source to the existing al_benchmarks executable, so the existing CTest smoke check and baseline-capture script cover it automatically with no changes to either. --- benchmarks/CMakeLists.txt | 6 ++ benchmarks/bench_aos_traversal.cpp | 107 +++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 benchmarks/bench_aos_traversal.cpp diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index 1a44d27f..403c6708 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -47,6 +47,11 @@ find_package(Python3 COMPONENTS Interpreter REQUIRED) # optimizations (NORTH_STAR.md §7): one time_slice element's full leaf set, # read node-by-node by index, against both HDF5 and Memory. # +# bench_aos_traversal.cpp (issue #61) is the "before" baseline for P1/P4's +# round-trip-count reduction (NORTH_STAR.md §7): every time_slice element, +# every field, visited via al_iterate_over_arraystruct, against both HDF5 and +# Memory. +# # bench_main.cpp holds the one BENCHMARK_MAIN() shared by every bench_*.cpp # TU in this executable. @@ -55,6 +60,7 @@ add_executable( al_benchmarks bench_equilibrium_read.cpp bench_equilibrium_write.cpp bench_timeslice_element_read.cpp + bench_aos_traversal.cpp bench_main.cpp ) target_link_libraries( al_benchmarks PRIVATE al_contract_fixtures benchmark::benchmark ) add_dependencies( al_benchmarks al ) diff --git a/benchmarks/bench_aos_traversal.cpp b/benchmarks/bench_aos_traversal.cpp new file mode 100644 index 00000000..5d99f27f --- /dev/null +++ b/benchmarks/bench_aos_traversal.cpp @@ -0,0 +1,107 @@ +// AOS-heavy full-traversal benchmark (issue #61, PRD #57 user story 7): the +// "before" baseline for P1/P4's round-trip-count reduction (NORTH_STAR.md +// §7) -- and the macro signal that would justify a future P5 per-call +// primitive micro-benchmark (getenv per call, path-string replacement) if +// this comes back slow. +// +// Functionally this is the same traversal bench_equilibrium_read.cpp already +// drives via equilibrium_seed::read_back, isolated and named as its own +// operation (PRD #57 Implementation Decisions: "AOS-heavy traversal +// benchmark") because its coverage target -- P1/P4 round-trip count -- is +// distinct from "read everything in the IDS" as a whole. Where +// BM_EquilibriumWholeIdsRead also reads the top-level scalar and timebase +// leaves, BM_AosHeavyTraversal scopes strictly to the time_slice +// array-of-structures: al_begin_arraystruct_action, then for every element +// (all equilibrium_seed::kNSlices of them, not just one -- that's +// BM_TimeSliceElementRead's job) read all five leaves and +// al_iterate_over_arraystruct to the next element. +// +// Reuses equilibrium_seed.h verbatim (issue #51); no size parametrization +// (PRD #57 user story 9). +// +// Timed region = one operation (issue #53, PRD #50 user story 6): the +// reference equilibrium is written and the data entry opened once in untimed +// setup; only begin_global_action(READ) -> begin_arraystruct_action -> +// iterate every element, reading every leaf -> end_action(aos) -> +// end_action(op) is inside the `for (auto _ : state)` loop. +// +// Backend is a sub-axis of the operation (PRD #50 "structure: +// scenario-primary, backend as a sub-axis"): BM_AosHeavyTraversal is the one +// operation body, HDF5 and Memory are two BENCHMARK_CAPTURE registrations of +// it differing only in which backend id the URI is built for. + +#include "bench_common.h" +#include "al_contract_abi.h" +#include "al_contract_legacy_storage.h" +#include "equilibrium_seed.h" + +#include +#include + +#include + +using al_bench::BenchBackend; +using al_bench::CheckOk; + +void BM_AosHeavyTraversal(benchmark::State& state, BenchBackend backend) { + // pulse=1, matching bench_equilibrium_read.cpp and + // bench_timeslice_element_read.cpp: all three are read-only operations, so + // (unlike bench_equilibrium_write.cpp's pulse=2) there's no repeated-write + // staleness concern that would require a distinct identity within the same + // al_benchmarks process. + const al_contract::PulseId pulse{/*database=*/"bench", /*version=*/"3", + /*pulse=*/1, /*run=*/0}; + + // --- untimed setup: open once, write the reference equilibrium once ------ + al_bench::BenchPulse bp = al_bench::OpenBenchPulse(backend, pulse); + CheckOk(equilibrium_seed::write(bp.pulse_ctx), "equilibrium_seed::write"); + + // --- timed region: begin_global_action(READ) -> every time_slice element, + // every field, via al_iterate_over_arraystruct -> end_action --------- + std::vector shape; + std::vector data; + for (auto _ : state) { + int op = -1; + CheckOk(al_begin_global_action(bp.pulse_ctx, equilibrium_seed::kIds, "", + READ_OP, &op), + "al_begin_global_action"); + + int size = 0; + int aos_ctx = -1; + CheckOk(al_begin_arraystruct_action(op, equilibrium_seed::kAos, + equilibrium_seed::kAosTime, &size, + &aos_ctx), + "al_begin_arraystruct_action"); + + auto read_leaf = [&](const char* path, int rank, const char* what) { + CheckOk(al_contract::read_data(aos_ctx, path, rank, &shape, &data), + what); + benchmark::DoNotOptimize(data); + }; + for (int i = 0; i < size; ++i) { + read_leaf(equilibrium_seed::kSliceTime, 0, "read time"); + read_leaf(equilibrium_seed::kPsi, 1, "read profiles_1d/psi"); + read_leaf(equilibrium_seed::kIp, 0, "read global_quantities/ip"); + read_leaf(equilibrium_seed::kMeasured, 0, "read constraints/ip/measured"); + read_leaf(equilibrium_seed::kWeight, 0, "read constraints/ip/weight"); + if (i + 1 < size) { + CheckOk(al_iterate_over_arraystruct(aos_ctx, 1), + "al_iterate_over_arraystruct"); + } + } + + CheckOk(al_end_action(aos_ctx), "al_end_action(aos_ctx)"); + CheckOk(al_end_action(op), "al_end_action(op)"); + } + + // --- untimed teardown ------------------------------------------------------ + CheckOk(al_close_pulse(bp.pulse_ctx, CLOSE_PULSE), "al_close_pulse"); + // bp.base's destructor (HDF5 only) removes the temp directory on every exit path. +} +// Repetitions(N) makes Google Benchmark report the distribution (median, +// mean, stddev, iteration count) across N independent traversal cycles, not +// a bare single-shot mean (issue #53, PRD #50 user story 8). +BENCHMARK_CAPTURE(BM_AosHeavyTraversal, HDF5, BenchBackend::kHdf5) + ->Repetitions(20); +BENCHMARK_CAPTURE(BM_AosHeavyTraversal, Memory, BenchBackend::kMemory) + ->Repetitions(20); From 8ab1815c156f0f2faa1ed3f7eeb93fc258dca900 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:26:47 +0200 Subject: [PATCH 84/91] Cover repeated HDF5 dataset writes --- src/hdf5/hdf5_writer.cpp | 46 +++++++++++++++---------- src/hdf5/hdf5_writer.h | 5 +++ tests/contract/test_structured_data.cpp | 45 ++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/src/hdf5/hdf5_writer.cpp b/src/hdf5/hdf5_writer.cpp index 477fd514..b03aa0cc 100644 --- a/src/hdf5/hdf5_writer.cpp +++ b/src/hdf5/hdf5_writer.cpp @@ -339,6 +339,28 @@ ArraystructContext* HDF5Writer::getDynamicAOS(Context * ctx) { return NULL; } +std::unique_ptr HDF5Writer::openOrCreateNonSliceDataSet( + const std::string& tensorized_path, hid_t* dataset_id, int datatype, + hid_t location, int dim, int* size, int aos_rank, + int* aos_shapes, bool shapes_dataset, + DataEntryContext* dataentry_context) { + auto data_set = std::make_unique( + true, dataentry_context->getURI()); + data_set->setNonSliceMode(); + constexpr bool create_chunk_cache = true; + if (H5Lexists(location, tensorized_path.c_str(), H5P_DEFAULT) == 0) { + data_set->create(tensorized_path.c_str(), dataset_id, datatype, + location, dim, size, aos_rank, aos_shapes, + shapes_dataset, create_chunk_cache); + } else { + data_set->open(tensorized_path.c_str(), location, dataset_id, dim, + size, datatype, shapes_dataset, create_chunk_cache, + dataentry_context->getURI(), aos_rank, aos_shapes); + data_set->setCurrentShapesAndExtend(size, aos_shapes); + } + return data_set; +} + void HDF5Writer::write_ND_Data(Context * ctx, std::string & att_name, std::string & timebasename, int datatype, int dim, int *size, void *data) { @@ -416,16 +438,9 @@ void HDF5Writer::write_ND_Data(Context * ctx, std::string & att_name, std::strin //std::cout << "WRITER NOT IN SLICE MODE!!! " << std::endl; if (dataset_id < 0) //not in this action's opened_data_sets cache -- may still exist on disk from a prior action on the same open pulse { - std::unique_ptr < HDF5DataSetHandler > dataSetHandler(new HDF5DataSetHandler(true, dec->getURI())); - dataSetHandler->setNonSliceMode(); - bool create_chunk_cache = true; - if (H5Lexists(gid, tensorized_path.c_str(), H5P_DEFAULT) == 0) { - dataSetHandler->create(tensorized_path.c_str(), &dataset_id, datatype, gid, dim, size, AOSRank, arrctx_shapes.data(), false, create_chunk_cache); - } else { - dataSetHandler->open(tensorized_path.c_str(), gid, &dataset_id, dim, size, datatype, false, create_chunk_cache, dec->getURI(), AOSRank, arrctx_shapes.data()); - dataSetHandler->setCurrentShapesAndExtend(size, arrctx_shapes.data()); - } - data_set = std::move(dataSetHandler); + data_set = openOrCreateNonSliceDataSet( + tensorized_path, &dataset_id, datatype, gid, dim, size, + AOSRank, arrctx_shapes.data(), false, dec); } else { data_set = std::move(got->second); opened_data_sets.erase(got); @@ -611,14 +626,9 @@ std::string & timebasename, int timed_AOS_index, const std::vector < int > &curr } } else { - data_set->setNonSliceMode(); - bool create_chunk_cache = true; - if (H5Lexists(loc_id, tensorized_path.c_str(), H5P_DEFAULT) == 0) { - data_set->create(tensorized_path.c_str(), &dataset_id, alconst::integer_data, loc_id, dim, size, AOSRank, aos_shapes.data(), shapes_dataset, create_chunk_cache); - } else { - data_set->open(tensorized_path.c_str(), loc_id, &dataset_id, dim, size, alconst::integer_data, shapes_dataset, create_chunk_cache, dec->getURI(), AOSRank, aos_shapes.data()); - data_set->setCurrentShapesAndExtend(size, aos_shapes.data()); - } + data_set = openOrCreateNonSliceDataSet( + tensorized_path, &dataset_id, alconst::integer_data, loc_id, + dim, size, AOSRank, aos_shapes.data(), shapes_dataset, dec); } if (data_set->useBuffering && slice_mode != SLICE_OP) { diff --git a/src/hdf5/hdf5_writer.h b/src/hdf5/hdf5_writer.h index 70caf417..727e496b 100644 --- a/src/hdf5/hdf5_writer.h +++ b/src/hdf5/hdf5_writer.h @@ -32,6 +32,11 @@ class HDF5Writer { int getDynamic_AOS_slices_extension(Context *ctx); int getDynamic_slices_extension(Context *ctx, int timed_AOS_index, int time_vector_length); ArraystructContext* getDynamicAOS(Context * ctx); + std::unique_ptr openOrCreateNonSliceDataSet( + const std::string& tensorized_path, hid_t* dataset_id, int datatype, + hid_t location, int dim, int* size, int aos_rank, + int* aos_shapes, bool shapes_dataset, + DataEntryContext* dataentry_context); public: diff --git a/tests/contract/test_structured_data.cpp b/tests/contract/test_structured_data.cpp index a625fbad..d49937e5 100644 --- a/tests/contract/test_structured_data.cpp +++ b/tests/contract/test_structured_data.cpp @@ -400,6 +400,51 @@ TEST_P(EquilibriumSeedMatrix, RepeatedWholeIdsWriteOnSameOpenPulseSucceeds) { EXPECT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); } +// Reproduce the shared benchmark process ordering from issue #60: a complete +// whole-IDS read, followed by the node-by-node read of one time_slice element. +// This keeps the smoke-only HDF5 failure covered without relying on benchmark +// registration order or a standalone filtered process. +TEST_P(EquilibriumSeedMatrix, WholeIdsReadThenTimeSliceElementReadSucceeds) { + const BackendCase b = GetParam(); + if (b.on_disk) base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(b.id, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + + int pctx = -1; + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx)); + AL_ASSERT_OK(equilibrium_seed::write(pctx)); + + std::vector whole_ids; + AL_ASSERT_OK(equilibrium_seed::read_back(pctx, &whole_ids)); + + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, equilibrium_seed::kIds, "", + READ_OP, &op)); + int size = 0; + int aos_ctx = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, equilibrium_seed::kAos, + equilibrium_seed::kAosTime, + &size, &aos_ctx)); + ASSERT_EQ(size, equilibrium_seed::kNSlices); + AL_ASSERT_OK(al_iterate_over_arraystruct(aos_ctx, size - 1)); + + std::vector shape; + std::vector data; + auto read_leaf = [&](const char* path, int rank) { + AL_ASSERT_OK(al_contract::read_data(aos_ctx, path, rank, + &shape, &data)); + }; + read_leaf(equilibrium_seed::kSliceTime, 0); + read_leaf(equilibrium_seed::kPsi, 1); + read_leaf(equilibrium_seed::kIp, 0); + read_leaf(equilibrium_seed::kMeasured, 0); + read_leaf(equilibrium_seed::kWeight, 0); + + AL_ASSERT_OK(al_end_action(aos_ctx)); + AL_ASSERT_OK(al_end_action(op)); + EXPECT_EQ(al_close_pulse(pctx, CLOSE_PULSE).code, 0); +} + // --- structural-hash sensitivity (issue #33 acceptance criteria 2 & 3) ------ // Hermetic proofs that the canonical hash catches the two structural // corruptions a values-only hash would miss. They operate on the production From 36f4cdc4c540aa0292099ad421aff5b104c9568a Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:38:58 +0200 Subject: [PATCH 85/91] Skip ASCII in AOS traversal regression --- tests/contract/test_structured_data.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/contract/test_structured_data.cpp b/tests/contract/test_structured_data.cpp index d49937e5..d945aeaa 100644 --- a/tests/contract/test_structured_data.cpp +++ b/tests/contract/test_structured_data.cpp @@ -406,6 +406,14 @@ TEST_P(EquilibriumSeedMatrix, RepeatedWholeIdsWriteOnSameOpenPulseSucceeds) { // registration order or a standalone filtered process. TEST_P(EquilibriumSeedMatrix, WholeIdsReadThenTimeSliceElementReadSucceeds) { const BackendCase b = GetParam(); + // ASCII's READ_OP setup exhausts its sequential stream before + // beginReadArraystructAction can recover the AOS size. This is the + // documented AOS read known defect pinned by AosKnownDefects.* above; + // this regression specifically covers the HDF5 benchmark ordering. + if (b.id == ASCII_BACKEND) { + GTEST_SKIP() << "known defect for " << b.name + << " AOS read — see AosKnownDefects.*"; + } if (b.on_disk) base_.make_legacy_tree(pulse_); const std::string uri = al_contract::build_uri(b.id, base_.str(), pulse_); ASSERT_FALSE(uri.empty()); From e41a9440227e3fca7d034387113a3649a15a235b Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:22:50 +0200 Subject: [PATCH 86/91] Address issue 57 review findings --- benchmarks/bench_aos_traversal.cpp | 20 +++-------- benchmarks/bench_equilibrium_traversal.h | 35 +++++++++++++++++++ benchmarks/bench_equilibrium_write.cpp | 37 ++------------------- benchmarks/bench_timeslice_element_read.cpp | 16 ++------- 4 files changed, 44 insertions(+), 64 deletions(-) create mode 100644 benchmarks/bench_equilibrium_traversal.h diff --git a/benchmarks/bench_aos_traversal.cpp b/benchmarks/bench_aos_traversal.cpp index 5d99f27f..ff6841fa 100644 --- a/benchmarks/bench_aos_traversal.cpp +++ b/benchmarks/bench_aos_traversal.cpp @@ -1,7 +1,7 @@ // AOS-heavy full-traversal benchmark (issue #61, PRD #57 user story 7): the // "before" baseline for P1/P4's round-trip-count reduction (NORTH_STAR.md -// §7) -- and the macro signal that would justify a future P5 per-call -// primitive micro-benchmark (getenv per call, path-string replacement) if +// §7) -- and the macro signal that would justify a future P5 Primitive +// benchmark (getenv per call, path-string replacement) if // this comes back slow. // // Functionally this is the same traversal bench_equilibrium_read.cpp already @@ -30,10 +30,7 @@ // operation body, HDF5 and Memory are two BENCHMARK_CAPTURE registrations of // it differing only in which backend id the URI is built for. -#include "bench_common.h" -#include "al_contract_abi.h" -#include "al_contract_legacy_storage.h" -#include "equilibrium_seed.h" +#include "bench_equilibrium_traversal.h" #include #include @@ -73,17 +70,8 @@ void BM_AosHeavyTraversal(benchmark::State& state, BenchBackend backend) { &aos_ctx), "al_begin_arraystruct_action"); - auto read_leaf = [&](const char* path, int rank, const char* what) { - CheckOk(al_contract::read_data(aos_ctx, path, rank, &shape, &data), - what); - benchmark::DoNotOptimize(data); - }; for (int i = 0; i < size; ++i) { - read_leaf(equilibrium_seed::kSliceTime, 0, "read time"); - read_leaf(equilibrium_seed::kPsi, 1, "read profiles_1d/psi"); - read_leaf(equilibrium_seed::kIp, 0, "read global_quantities/ip"); - read_leaf(equilibrium_seed::kMeasured, 0, "read constraints/ip/measured"); - read_leaf(equilibrium_seed::kWeight, 0, "read constraints/ip/weight"); + al_bench::ReadCurrentEquilibriumTimeSliceLeaves(aos_ctx, &shape, &data); if (i + 1 < size) { CheckOk(al_iterate_over_arraystruct(aos_ctx, 1), "al_iterate_over_arraystruct"); diff --git a/benchmarks/bench_equilibrium_traversal.h b/benchmarks/bench_equilibrium_traversal.h new file mode 100644 index 00000000..e4b3e6b8 --- /dev/null +++ b/benchmarks/bench_equilibrium_traversal.h @@ -0,0 +1,35 @@ +// Shared equilibrium time_slice leaf traversal for the operation benchmarks. +// Keeping the fixture-specific path list here prevents the single-element and +// full-AOS benchmarks from drifting while leaving their iteration boundaries +// and timed operation structure explicit at each call site. +#ifndef AL_BENCHMARKS_EQUILIBRIUM_TRAVERSAL_H +#define AL_BENCHMARKS_EQUILIBRIUM_TRAVERSAL_H + +#include "al_contract_abi.h" +#include "bench_common.h" +#include "equilibrium_seed.h" + +#include + +#include + +namespace al_bench { + +inline void ReadCurrentEquilibriumTimeSliceLeaves( + int aos_ctx, std::vector* shape, std::vector* data) { + auto read_leaf = [&](const char* path, int rank, const char* what) { + CheckOk(al_contract::read_data(aos_ctx, path, rank, shape, data), + what); + benchmark::DoNotOptimize(*data); + }; + + read_leaf(equilibrium_seed::kSliceTime, 0, "read time"); + read_leaf(equilibrium_seed::kPsi, 1, "read profiles_1d/psi"); + read_leaf(equilibrium_seed::kIp, 0, "read global_quantities/ip"); + read_leaf(equilibrium_seed::kMeasured, 0, "read constraints/ip/measured"); + read_leaf(equilibrium_seed::kWeight, 0, "read constraints/ip/weight"); +} + +} // namespace al_bench + +#endif // AL_BENCHMARKS_EQUILIBRIUM_TRAVERSAL_H diff --git a/benchmarks/bench_equilibrium_write.cpp b/benchmarks/bench_equilibrium_write.cpp index 2e9b405c..f8b5c5c1 100644 --- a/benchmarks/bench_equilibrium_write.cpp +++ b/benchmarks/bench_equilibrium_write.cpp @@ -10,23 +10,9 @@ // `for (auto _ : state)` loop, so open/close cost never contaminates the // write measurement. // -// Repeated writes to the same open pulse context must never accumulate stale -// data from a prior write (PRD #57 user story 5): ALData::writeData clears -// its buffer before repopulating (src/memory_backend.cpp) and the -// array-of-structures path replaces rather than appends elements, for any -// whole-IDS (non-slice) write. This is checked once, untimed, after the timed -// loop -- but the timed loop's own iteration count isn't a reliable way to -// exercise "repeated": the CTest smoke entry caps every benchmark at exactly -// one iteration (--benchmark_min_time=1x), which would make the check -// vacuous (one write trivially matches one write's hash) on the one run this -// repo executes automatically. So an explicit second untimed write happens -// after the loop, guaranteeing at least two writes to the same context -// regardless of how many timed iterations ran, before a read-back of the -// resulting structural hash is compared against equilibrium_seed's -// expected_hash() exactly, on both backends. The hash folds in AOS size -// (equilibrium_seed.h), so an accumulation bug (e.g. elements appended -// instead of replaced) would drift it and this check -- which the existing -// CTest smoke entry already runs -- would throw. +// Correctness of repeated writes on the same open pulse is pinned separately +// by EquilibriumSeedMatrix.RepeatedWholeIdsWriteOnSameOpenPulseSucceeds; this +// benchmark measures the operation without re-asserting the contract. #include "bench_common.h" #include "equilibrium_seed.h" @@ -36,9 +22,6 @@ #include -#include -#include - using al_bench::BenchBackend; using al_bench::CheckOk; @@ -57,20 +40,6 @@ void BM_EquilibriumWholeIdsWrite(benchmark::State& state, BenchBackend backend) CheckOk(equilibrium_seed::write(bp.pulse_ctx), "equilibrium_seed::write"); } - // --- untimed correctness check: no stale data survives repeated writes --- - // Force a second write regardless of the timed loop's iteration count (see - // top-of-file comment), then verify no staleness accumulated. - CheckOk(equilibrium_seed::write(bp.pulse_ctx), "equilibrium_seed::write"); - uint64_t hash = 0; - CheckOk(equilibrium_seed::read_and_hash(bp.pulse_ctx, &hash), - "equilibrium_seed::read_and_hash"); - if (hash != equilibrium_seed::expected_hash()) { - throw std::runtime_error( - "BM_EquilibriumWholeIdsWrite: repeated-write structural hash does " - "not match expected_hash() -- repeated writes may have accumulated " - "stale data"); - } - // --- untimed teardown ------------------------------------------------------ CheckOk(al_close_pulse(bp.pulse_ctx, CLOSE_PULSE), "al_close_pulse"); // bp.base's destructor (HDF5 only) removes the temp directory on every exit path. diff --git a/benchmarks/bench_timeslice_element_read.cpp b/benchmarks/bench_timeslice_element_read.cpp index 90c5cf80..04e1710d 100644 --- a/benchmarks/bench_timeslice_element_read.cpp +++ b/benchmarks/bench_timeslice_element_read.cpp @@ -29,10 +29,7 @@ // registrations of it differing only in which backend id the URI is built // for. -#include "bench_common.h" -#include "al_contract_abi.h" -#include "al_contract_legacy_storage.h" -#include "equilibrium_seed.h" +#include "bench_equilibrium_traversal.h" #include #include @@ -74,16 +71,7 @@ void BM_TimeSliceElementRead(benchmark::State& state, BenchBackend backend) { CheckOk(al_iterate_over_arraystruct(aos_ctx, kTargetSlice), "al_iterate_over_arraystruct"); - auto read_leaf = [&](const char* path, int rank, const char* what) { - CheckOk(al_contract::read_data(aos_ctx, path, rank, &shape, &data), - what); - benchmark::DoNotOptimize(data); - }; - read_leaf(equilibrium_seed::kSliceTime, 0, "read time"); - read_leaf(equilibrium_seed::kPsi, 1, "read profiles_1d/psi"); - read_leaf(equilibrium_seed::kIp, 0, "read global_quantities/ip"); - read_leaf(equilibrium_seed::kMeasured, 0, "read constraints/ip/measured"); - read_leaf(equilibrium_seed::kWeight, 0, "read constraints/ip/weight"); + al_bench::ReadCurrentEquilibriumTimeSliceLeaves(aos_ctx, &shape, &data); CheckOk(al_end_action(aos_ctx), "al_end_action(aos_ctx)"); CheckOk(al_end_action(op), "al_end_action(op)"); From 5748ec5329a08e215421c3002173117685710d32 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:01:14 +0200 Subject: [PATCH 87/91] Pin HDF5 al_delete_data's occurrence-wide widening (issue #63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `al_delete_data`'s `path` is accepted by the ABI and dropped by the HDF5 backend: `HDF5Backend::deleteData` never forwards it and `HDF5Writer:: deleteData` has no parameter to receive it, so any non-empty path removes the whole IDS occurrence and reports success. Characterize that before touching it. The existing tripwire only observed the sibling leaf; widen it to the two consequences issue #63 measured as well — the occurrence's own `.h5` is deleted, and the master file is left holding an external link to a file that no longer exists (observed through `al_get_occurrences`, which enumerates those links live). Confirmed red/green today: the three correct-contract assertions fail, the tripwire passes. --- tests/contract/TRACEABILITY.md | 3 +- tests/contract/test_structured_data.cpp | 82 +++++++++++++++++++++---- 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index de128f71..5193999e 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -128,7 +128,7 @@ genuine defect (expected-fail). | ↳ CHAR scalar (dim 0) must round-trip — **HDF5 crashes** | :244-263 | — | ✓ | `RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips` + tripwire `RoundTripKnownDefectsDeath.Hdf5CharScalarCurrentlyCrashes` | **xfail** | | ↳ numeric at MAXDIM (rank 7) must round-trip — **ASCII corrupts (DOUBLE/COMPLEX) / aborts (INTEGER)** | :244-263 | — | ✓ | `RoundTripKnownDefects.DISABLED_Ascii{Integer,Double,Complex}MaxdimRoundTrips` + tripwires `RoundTripKnownDefectsDeath.AsciiIntegerMaxdimCurrentlyAborts`, `RoundTripKnownDefects.Ascii{Double,Complex}MaxdimCurrentlyCorrupts` | **xfail** | | `al_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** | +| ↳ 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` + `DeleteKnownDefects.DISABLED_Hdf5LeafDeleteKeepsOccurrenceFileAndLink` + tripwire `DeleteKnownDefects.Hdf5LeafDeleteCurrentlyWipesWholeOccurrence`, which pins all three observable consequences (sibling gone, `.h5` deleted, master file left holding a dangling external link — issue #63); 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) | @@ -276,6 +276,7 @@ whoever fixed it to enable the paired `DISABLED_` correct-contract test: | `al_get_backendID` accepts a non-pulse context via unchecked `static_cast` | `GetBackendIdKnownDefects.DISABLED_WrongContextTypeReturnsError` | `GetBackendIdKnownDefects.WrongContextTypeCurrentlySucceedsViaUnsafeCast` | | ASCII AOS read always reports size 0 | `AosKnownDefects.DISABLED_AsciiAosReadReportsWrittenSize` | `AosKnownDefects.AsciiAosReadCurrentlyReportsZero` | | HDF5 leaf/structure delete wipes the whole occurrence | `DeleteKnownDefects.DISABLED_Hdf5LeafDeleteLeavesSiblingIntact` | `DeleteKnownDefects.Hdf5LeafDeleteCurrentlyWipesWholeOccurrence` | +| HDF5 leaf delete deletes `.h5` and leaves a dangling master-file link (issue #63) | `DeleteKnownDefects.DISABLED_Hdf5LeafDeleteKeepsOccurrenceFileAndLink` | `DeleteKnownDefects.Hdf5LeafDeleteCurrentlyWipesWholeOccurrence` | | Memory root delete is a no-op | `DeleteKnownDefects.DISABLED_MemoryRootDeleteClearsWholeIds` | `DeleteKnownDefects.MemoryRootDeleteCurrentlyDoesNothing` | | ASCII delete is a no-op at every granularity | `DeleteKnownDefects.DISABLED_AsciiDeleteRemovesLeaf` | `DeleteKnownDefects.AsciiDeleteIsCurrentlyANoOp` | diff --git a/tests/contract/test_structured_data.cpp b/tests/contract/test_structured_data.cpp index 86029d7f..b2e2d63a 100644 --- a/tests/contract/test_structured_data.cpp +++ b/tests/contract/test_structured_data.cpp @@ -29,6 +29,7 @@ #include #include +#include #include #include @@ -611,8 +612,34 @@ const BackendCase kHdf5{HDF5_BACKEND, "HDF5", /*on_disk=*/true}; const BackendCase kAscii{ASCII_BACKEND, "ASCII", /*on_disk=*/true}; // --- HDF5: leaf delete ignores `path`, wipes the whole occurrence ---------- -double hdf5_leaf_delete_sibling_survives(al_contract::TempBase& base, - const PulseId& pulse) { +// One non-empty-path delete of a single leaf, observed on three axes: the +// sibling leaf, the occurrence's own pulse file, and the external link the +// master file holds for that occurrence. The last two are what issue #63 +// measured, and they are why the widening is not merely untidy — the call +// reports success while removing the whole occurrence, and leaves master.h5 +// pointing at a file that no longer exists. +struct Hdf5LeafDeleteObservation { + double sibling_value; // leaf_b read back after al_delete_data("leaf_a") + bool ids_file_exists; // .h5 still on disk + bool occurrence_listed; // master file still links the occurrence +}; + +// Located by name rather than by rebuilding the backend's own on-disk path +// strategy (hdf5_utils.cpp pulseFilePathFactory), which the test has no +// business restating. +inline bool file_exists_under(const std::string& root, const std::string& name) { + namespace fs = std::filesystem; + std::error_code ec; + for (fs::recursive_directory_iterator it(root, ec), end; it != end; + it.increment(ec)) { + if (ec) break; + if (it->path().filename() == name) return true; + } + return false; +} + +Hdf5LeafDeleteObservation hdf5_observe_leaf_delete(al_contract::TempBase& base, + const PulseId& pulse) { const std::string uri = al_contract::build_uri(HDF5_BACKEND, base.str(), pulse); int pctx = -1; EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); @@ -623,37 +650,66 @@ double hdf5_leaf_delete_sibling_survives(al_contract::TempBase& base, EXPECT_EQ(al_delete_data(op, "leaf_a").code, 0); EXPECT_EQ(al_end_action(op).code, 0); - // Reopening a READ on the occurrence may itself fail — HDF5's delete - // removed the whole occurrence's group/link/file, so there may be - // nothing left to open at all. That failure is just as much proof the - // sibling is gone as reading back the sentinel would be. + // Reopening a READ on the occurrence may itself fail — if the delete + // removed the whole occurrence's group/link/file there is nothing left to + // open. That failure is just as much proof the sibling is gone as reading + // back the sentinel would be. int rop = -1; al_status_t open_read = al_begin_global_action(pctx, kIds, "", READ_OP, &rop); - double result = al_contract::kEmptyDouble; + Hdf5LeafDeleteObservation obs{al_contract::kEmptyDouble, false, false}; if (open_read.code == 0) { std::vector shape; std::vector bb; EXPECT_EQ(al_contract::read_data(rop, "leaf_b", 0, &shape, &bb).code, 0); EXPECT_EQ(al_end_action(rop).code, 0); - if (!bb.empty()) result = bb[0]; + if (!bb.empty()) obs.sibling_value = bb[0]; } + + // al_get_occurrences enumerates the master file's links live + // (hdf5_reader.cpp H5Literate over master_file_id), so it reports the link + // whether or not the file it points at still exists — exactly the dangling + // state issue #63 measured. + int* occ = nullptr; + int n = -1; + if (al_get_occurrences(pctx, kIds, &occ, &n).code == 0 && n > 0) + obs.occurrence_listed = true; + free(occ); + + obs.ids_file_exists = file_exists_under(base.str(), std::string(kIds) + ".h5"); + al_close_pulse(pctx, CLOSE_PULSE); - return result; + return obs; } TEST_F(DeleteKnownDefects, DISABLED_Hdf5LeafDeleteLeavesSiblingIntact) { - EXPECT_EQ(hdf5_leaf_delete_sibling_survives(base_, pulse_), 22.0) + EXPECT_EQ(hdf5_observe_leaf_delete(base_, pulse_).sibling_value, 22.0) << "a leaf-granularity delete must not remove sibling fields"; } +TEST_F(DeleteKnownDefects, DISABLED_Hdf5LeafDeleteKeepsOccurrenceFileAndLink) { + const Hdf5LeafDeleteObservation obs = hdf5_observe_leaf_delete(base_, pulse_); + EXPECT_TRUE(obs.ids_file_exists) + << "a leaf-granularity delete must not remove the occurrence's pulse file"; + EXPECT_TRUE(obs.occurrence_listed) + << "a leaf-granularity delete must leave the occurrence, and so the " + "master file's link to it, in place"; +} + TEST_F(DeleteKnownDefects, Hdf5LeafDeleteCurrentlyWipesWholeOccurrence) { - EXPECT_EQ(hdf5_leaf_delete_sibling_survives(base_, pulse_), - al_contract::kEmptyDouble) + const Hdf5LeafDeleteObservation obs = hdf5_observe_leaf_delete(base_, pulse_); + EXPECT_EQ(obs.sibling_value, al_contract::kEmptyDouble) << "HDF5 deleteData now honors `path` for leaf granularity — enable " "DeleteKnownDefects.DISABLED_Hdf5LeafDeleteLeavesSiblingIntact " "(src/hdf5/hdf5_writer.cpp deleteData ignores its caller's field)"; + EXPECT_FALSE(obs.ids_file_exists) + << "HDF5 deleteData no longer deletes the whole pulse file for a " + "non-empty path — enable " + "DeleteKnownDefects.DISABLED_Hdf5LeafDeleteKeepsOccurrenceFileAndLink"; + EXPECT_TRUE(obs.occurrence_listed) + << "the master file no longer holds a dangling link after a leaf " + "delete — enable " + "DeleteKnownDefects.DISABLED_Hdf5LeafDeleteKeepsOccurrenceFileAndLink"; } - // --- Memory: no code path implements DATAOBJECT-root delete ---------------- bool memory_root_delete_clears_everything(al_contract::TempBase& base, const PulseId& pulse) { From c8e49caff006341dc829f343dd9ae86a1124ab77 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:21:40 +0200 Subject: [PATCH 88/91] Make HDF5 al_delete_data honor its `path` argument (issue #63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision: `path` means what the ABI has always said it means — a non-empty path deletes that node and its subtree only, an empty path addresses the whole DATAOBJECT. Recorded in docs/adr/0001-al-delete-data-path-semantics.md together with the per-backend matrix and why the cheaper alternative (rejecting a non-empty path as a caller error) was rejected: it is not what the header says, not what the other backends do, and it would fail every non-slice `put()` and `delete_data()` through imas-python, whose traversal issues one delete per DD leaf. `HDF5Writer::deleteSubtree` selects the matching datasets out of the IDS group's flat namespace of tensorized names — the mangled path, its `_SHAPE` companion, and anything continuing with `&` (a structure child) or `[]` (an AOS index). The continuation character is what keeps `time` from taking `time_slice[]&x` with it. Buffered writes are flushed before anything is unlinked, since HDF5DataSetHandler::close() frees write buffers without writing them. The group listing is cached across a run of deletes, and invalidated by anything that can add a dataset, so the HLI's leaf-by-leaf traversal is not quadratic in the size of the IDS. `deleteOccurrence` keeps the empty-path behavior and fixes two defects in it: the master file's external link is now removed with the file it points at (it used to be left dangling, so al_get_occurrences kept reporting an occurrence whose file was gone), and a closed-but-present IDS file is deleted instead of being silently reopened and leaked. Tests: DeleteMatrix's HDF5 leaf/structure cells are enabled, the HDF5 xfail pair retired, and the layout-specific consequences the backend-agnostic matrix cannot see are pinned in Hdf5Delete.* — which files and master-file links survive which granularity, same-prefix siblings, an absent path, and a run of successive leaf deletes all taking effect. Verified against the suite, the Debug leg, and ASan/UBSan. --- .../adr/0001-al-delete-data-path-semantics.md | 92 +++++ include/al_backend.h | 8 +- include/al_lowlevel.h | 19 +- src/hdf5/hdf5_backend.cpp | 2 +- src/hdf5/hdf5_writer.cpp | 134 +++++-- src/hdf5/hdf5_writer.h | 15 +- tests/contract/TRACEABILITY.md | 15 +- tests/contract/test_structured_data.cpp | 332 ++++++++++++------ 8 files changed, 486 insertions(+), 131 deletions(-) create mode 100644 docs/adr/0001-al-delete-data-path-semantics.md diff --git a/docs/adr/0001-al-delete-data-path-semantics.md b/docs/adr/0001-al-delete-data-path-semantics.md new file mode 100644 index 00000000..49de0621 --- /dev/null +++ b/docs/adr/0001-al-delete-data-path-semantics.md @@ -0,0 +1,92 @@ +--- +status: accepted +--- + +# `al_delete_data`'s `path` selects a node and its subtree, never the occurrence + +`al_delete_data(ctx, path)` has always documented `path` as "the data structure +element to delete (suppress the whole subtree)", but the HDF5 backend dropped +the argument on the floor: `HDF5Backend::deleteData` did not forward it and +`HDF5Writer::deleteData` had no parameter to receive it, so **any** non-empty +path deleted the whole IDS occurrence — the per-IDS pulse file and the master +file's external link to it — and returned success (issue #63). We keep the +documented meaning and implement it: a non-empty `path` deletes that node and +its subtree only, an empty `path` addresses the whole DATAOBJECT, and deleting +an absent path is a successful no-op. + +## Considered options + +**1. Per-path delete is intended (chosen).** Implemented in +`HDF5Writer::deleteSubtree`. An IDS group is a flat namespace of "tensorized" +dataset names — the DD path with `/` → `&`, array-of-structures nodes suffixed +`[]`, one `_SHAPE` companion per non-scalar leaf, one `…[]&AOS_SHAPE` per +dynamic AOS — so the subtree rooted at a DD path is exactly the set of names +that are the mangled path, its `_SHAPE` companion, or that continue with `&` (a +structure child) or `[]` (an AOS element index). The continuation character is +what keeps `time` from taking `time_slice[]&x` with it. + +**2. Occurrence-wide delete is intended: reject a non-empty `path`.** Rejected. +It is the cheaper change, but it is not what the ABI says and not what the other +backends do — and it would break every HDF5 write through the HLI. imas-python's +`delete_children()` walks the DD and issues one `al_delete_data` per leaf +(`imas/backends/imas_core/db_entry_helpers.py`), and `DBEntry.put()` runs that +whole traversal before writing (`db_entry_al.py`), so rejecting non-empty paths +would fail every non-slice `put()` and `DBEntry.delete_data()` on HDF5. Today +that traversal *appears* to work only because the first forwarded delete +destroys the file and the following ones silently do nothing (the first call +closes the IDS group, after which every later call returns early) — and the put +then rewrites everything from scratch. That accident is what hid the defect. + +The third state the code was actually in — argument accepted, ignored, success +reported — is what neither option tolerates, and it is not merely untidy. It is +the mechanism behind a silent data corruption downstream: the +IMAS-Multiversion-DD-Loader refuses to delete an occurrence's DD-version stamp +so a converted pulse cannot hold undateable numbers, but inside a full put the +traversal's first forwarded delete has already taken the whole file, stamp +included, and the rest of the put is written untranslated +([IMAS-Multiversion-DD-Loader#139](https://github.com/yohannmarguier/IMAS-Multiversion-DD-Loader/issues/139)). + +## Per-backend behaviour, as of this ADR + +Checked by reading each `deleteData` and pinned in `tests/contract/` +(`test_structured_data.cpp` `DeleteMatrix` / `Hdf5Delete`, +`test_mdsplus_delete.cpp`, `test_uda_unique_surface.cpp`); the ledger is +`tests/contract/TRACEABILITY.md`. + +| Backend | node | subtree | whole DATAOBJECT (`path == ""`) | +|---|---|---|---| +| HDF5 | ✅ | ✅ | ✅ — deletes `.h5` *and* the master-file link | +| Memory | ✅ | ✅ (AOS node) | ❌ no code path for it — xfail | +| MDSplus | ✅ | ❌ `%TREE-W-NNF` — xfail | ❌ `%TREE-W-NNF` — xfail | +| ASCII | ❌ empty body | ❌ | ❌ | +| Flexbuffers | ❌ empty body | ❌ | ❌ | +| UDA | forwards `path` (remote: no server handler → clean refusal; local/fetch: delegates to the local backend) | " | " | + +So HDF5 was the only backend that *widened* `path`; the rest either honour it or +ignore the call entirely. None of them reports "I cannot honour this path", +which is why the header now says so explicitly rather than leaving an HLI to +discover it. + +`MemoryBackend::deleteData` also carries a dead `ctx->getType() == +CTX_ARRAYSTRUCT_TYPE` branch: the parameter is an `OperationContext*` and +`al_delete_data` `dynamic_cast`s to one, so an arraystruct context can never +reach it. A plain (non-AOS) structure path on Memory is not characterized. + +## Consequences + +- **An emptied occurrence still exists on HDF5.** `DBEntry.delete_data()` walks + the DD and deletes leaf by leaf, so `.h5` and its master-file link + survive with no datasets left in them, and `al_get_occurrences` keeps + reporting the occurrence. That is the honest consequence of `path` meaning + what it says; an HLI that wants the occurrence gone should pass `path == ""`, + which is now the only call that removes it. +- **A delete never shrinks `.h5`.** `H5Ldelete` unlinks; the space is + reclaimed only by repacking the file. +- **The master-file link now follows the file.** A whole-DATAOBJECT delete + removes the external link as well, so the master file no longer advertises an + occurrence whose backing file is gone — the second half of what issue #63 + measured. +- A whole-IDS delete lists the IDS group's link names once per delete rather + than once per leaf (`HDF5Writer::groupMembers`, invalidated by anything that + can add a dataset). Without that cache the HLI's leaf-by-leaf traversal would + be quadratic in the size of the IDS. diff --git a/include/al_backend.h b/include/al_backend.h index aec9860f..314728b0 100644 --- a/include/al_backend.h +++ b/include/al_backend.h @@ -147,8 +147,14 @@ class IMAS_CORE_LIBRARY_API Backend Deletes data. This function deletes some data (can be a signal, a structure, the whole DATAOBJECT) in the database given the passed context. + + An implementation must not widen `path`: a non-empty `path` deletes that + node and its subtree only, and an empty `path` addresses the whole + DATAOBJECT. Deleting a path that holds no data is a no-op, not an error. + See docs/adr/0001-al-delete-data-path-semantics.md. + @param[in] ctx pointer on operation context - @param[in] path path of the data structure element to delete (suppress the whole subtree) + @param[in] path path of the data structure element to delete (suppress the whole subtree); empty means the whole DATAOBJECT @throw BackendException **/ virtual void deleteData(OperationContext *ctx, diff --git a/include/al_lowlevel.h b/include/al_lowlevel.h index e3bd79be..f5b11adc 100644 --- a/include/al_lowlevel.h +++ b/include/al_lowlevel.h @@ -415,8 +415,25 @@ extern "C" Deletes data. This function deletes some data (can be a signal, a structure, the whole DATAOBJECT) in the database given the passed context. + + `path` selects *what* is deleted, and is never widened by the core: + - a non-empty `path` deletes that node and its subtree, and nothing else — + sibling and parent nodes, and the DATAOBJECT itself, survive; + - an empty `path` addresses the whole DATAOBJECT (the occurrence opened by + `ctx`), and is the only way to remove it. + Deleting a path that holds no data is not an error: the call succeeds and + changes nothing. + + Backend support for the three granularities is uneven, and a backend that + cannot honour `path` does not report that it could not: ASCII and + flexbuffers ignore the call entirely, memory implements node and subtree + deletes but not the DATAOBJECT one, MDSplus implements node deletes only. + HDF5 implements all three. The per-backend matrix, and why the argument is + specified this way rather than as an occurrence-wide delete, are recorded + in docs/adr/0001-al-delete-data-path-semantics.md. + @param[in] ctx operation context id (from al_plugin_begin_global_action() or al_plugin_begin_slice_action()) - @param[in] path path of the data structure element to delete (suppress the whole subtree) + @param[in] path path of the data structure element to delete (suppress the whole subtree); empty means the whole DATAOBJECT @result error status [_success if al_status_t.code = 0 or failure if < 0_] */ IMAS_CORE_LIBRARY_API al_status_t al_delete_data(int ctx, diff --git a/src/hdf5/hdf5_backend.cpp b/src/hdf5/hdf5_backend.cpp index cb95f539..2283bd18 100644 --- a/src/hdf5/hdf5_backend.cpp +++ b/src/hdf5/hdf5_backend.cpp @@ -131,7 +131,7 @@ void HDF5Backend::deleteData(OperationContext * ctx, std::string path) { if (file_id == -1) //master file is closed return; - hdf5Writer->deleteData(ctx, this->file_id, opened_IDS_files, files_directory, relative_file_path); + hdf5Writer->deleteData(ctx, path, this->file_id, opened_IDS_files, files_directory, relative_file_path); } void HDF5Backend::beginWriteArraystructAction(ArraystructContext * ctx, int *size) diff --git a/src/hdf5/hdf5_writer.cpp b/src/hdf5/hdf5_writer.cpp index 57d8f702..3782aafc 100644 --- a/src/hdf5/hdf5_writer.cpp +++ b/src/hdf5/hdf5_writer.cpp @@ -14,7 +14,7 @@ using namespace boost::filesystem; HDF5Writer::HDF5Writer(std::string backend_version_) : backend_version(backend_version_), opened_data_sets(), existing_data_sets(), tensorized_paths_per_context(), arrctx_shapes_per_context(), -dynamic_AOS_slices_extension(), homogeneous_time(-1), IDS_group_id(), slice_mode(GLOBAL_OP) +dynamic_AOS_slices_extension(), homogeneous_time(-1), IDS_group_id(), group_members_gid(-1), group_members(), slice_mode(GLOBAL_OP) { //H5Eset_auto2(H5E_DEFAULT, NULL, NULL); } @@ -58,7 +58,56 @@ void HDF5Writer::close_file_handler(std::string external_link_name, std::unorder } } -void HDF5Writer::deleteData(OperationContext * ctx, hid_t file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, std::string & files_directory, std::string & relative_file_path) +// An IDS group is a flat namespace of "tensorized" dataset names: the DD path +// with '/' replaced by '&', every array-of-structures node suffixed "[]", one +// "_SHAPE" companion per non-scalar leaf and one "…[]&AOS_SHAPE" per dynamic +// AOS. So the subtree rooted at a DD path is exactly the set of dataset names +// that are the mangled path itself, its "_SHAPE" companion, or that continue +// with '&' (a structure child) or "[]" (an AOS element index). +// +// The continuation character is what separates a subtree from a same-prefix +// sibling: deleting "time" must not touch "time_slice[]&…". +static bool is_in_subtree(const std::string & dataset_name, const std::string & mangled_path) +{ + if (dataset_name.size() < mangled_path.size() || + dataset_name.compare(0, mangled_path.size(), mangled_path) != 0) + return false; + const std::string continuation = dataset_name.substr(mangled_path.size()); + if (continuation.empty()) // the leaf's own dataset + return true; + if (continuation == "_SHAPE") // its shape companion + return true; + if (continuation[0] == '&') // a child of a structure + return true; + if (continuation.compare(0, 2, "[]") == 0) // "[]&…", "[]&AOS_SHAPE" + return true; + return false; +} + +void HDF5Writer::invalidateGroupMembers() +{ + group_members_gid = -1; + group_members.clear(); +} + +std::vector < std::string > &HDF5Writer::groupMembers(hid_t gid) +{ + if (group_members_gid != gid) { + group_members.clear(); + H5L_iterate_t collect = [](hid_t, const char *name, const H5L_info_t *, void *op_data) -> herr_t { + static_cast < std::vector < std::string > *>(op_data)->push_back(name); + return 0; + }; + if (H5Literate(gid, H5_INDEX_NAME, H5_ITER_NATIVE, NULL, collect, &group_members) < 0) { + group_members.clear(); + throw ALBackendException("HDF5Backend: H5Literate has failed in HDF5Writer::deleteData()", LOG); + } + group_members_gid = gid; + } + return group_members; +} + +void HDF5Writer::deleteData(OperationContext * ctx, const std::string & path, hid_t file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, std::string & files_directory, std::string & relative_file_path) { if (file_id == -1) throw ALBackendException("HDF5Backend: master file not opened in HDF5Writer::deleteData()", LOG); //the master file is assumed to be opened @@ -69,33 +118,73 @@ void HDF5Writer::deleteData(OperationContext * ctx, hid_t file_id, std::unordere if (gid == -1) return; + + // An empty path addresses the whole DATAOBJECT; anything else addresses one + // node and its subtree. See al_lowlevel.h (al_delete_data) and + // docs/adr/0001-al-delete-data-path-semantics.md. + if (path.empty()) + deleteOccurrence(ctx, file_id, opened_IDS_files, files_directory, relative_file_path); + else + deleteSubtree(gid, path); +} + +void HDF5Writer::deleteSubtree(hid_t gid, const std::string & path) +{ + std::string mangled_path = path; + std::replace(mangled_path.begin(), mangled_path.end(), '/', '&'); // character '/' is not supported in datasets names + + // Pending buffered writes have to reach the file before anything is + // unlinked: HDF5DataSetHandler::close() frees its write buffers without + // flushing them, so closing first would silently drop writes made earlier + // in the same WRITE_OP session to datasets this delete does not touch. + if (!opened_data_sets.empty()) + write_buffers(); + close_datasets(); //also clears the existing_data_sets H5Lexists cache + + std::vector < std::string > &members = groupMembers(gid); + std::vector < std::string > doomed; + std::vector < std::string > kept; + for (const std::string & name : members) { + if (is_in_subtree(name, mangled_path)) + doomed.push_back(name); + else + kept.push_back(name); + } + + for (const std::string & name : doomed) { + // H5Ldelete only unlinks: the freed space stays in the file until it is + // repacked, so a delete never shrinks .h5. + if (H5Ldelete(gid, name.c_str(), H5P_DEFAULT) < 0) { + invalidateGroupMembers(); + char error_message[300]; + snprintf(error_message, sizeof(error_message), "Unable to delete HDF5 dataset: %s\n", name.c_str()); + throw ALBackendException(error_message, LOG); + } + } + members = std::move(kept); +} + +void HDF5Writer::deleteOccurrence(OperationContext * ctx, hid_t file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, std::string & files_directory, std::string & relative_file_path) +{ close_datasets(); - close_group(ctx); + close_group(ctx); //also invalidates the cached group listing std::string IDS_link_name = ctx->getDataobjectName(); std::replace(IDS_link_name.begin(), IDS_link_name.end(), '/', '_'); HDF5Utils hdf5_utils; - //Deleting IDS link from master file if (H5Lexists(file_id, IDS_link_name.c_str(), H5P_DEFAULT) > 0) { //the IDS is referenced in the master file - auto got = opened_IDS_files.find(IDS_link_name); - hid_t IDS_file_id = -1; std::string IDSpulseFile = hdf5_utils.getIDSPulseFilePath(files_directory, relative_file_path, IDS_link_name); + auto got = opened_IDS_files.find(IDS_link_name); if (got != opened_IDS_files.end()) { - IDS_file_id = got->second; - if (IDS_file_id < 0) { - if (exists(IDSpulseFile.c_str())) { - hdf5_utils.openIDSFile(ctx, IDSpulseFile, &IDS_file_id, false); - } - } - else { - hdf5_utils.closeIDSFile(IDS_file_id, IDS_link_name); - hdf5_utils.deleteIDSFile(IDSpulseFile); - } + if (got->second >= 0) + hdf5_utils.closeIDSFile(got->second, IDS_link_name); opened_IDS_files[IDS_link_name] = -1; } - else { - if (exists(IDSpulseFile.c_str())) - hdf5_utils.deleteIDSFile(IDSpulseFile); - } + if (exists(IDSpulseFile.c_str())) + hdf5_utils.deleteIDSFile(IDSpulseFile); + //Deleting IDS link from master file: the link has to go with the file it + //points at, or the master keeps advertising an occurrence + //(al_get_occurrences enumerates these links) whose backing file is gone. + hdf5_utils.removeLinkFromMasterPulseFile(file_id, IDS_link_name); } } @@ -171,7 +260,7 @@ void HDF5Writer::create_IDS_group(OperationContext * ctx, hid_t file_id, std::un throw ALBackendException(error_message, LOG); } } - close_group(ctx); + close_group(ctx); //also invalidates the cached group listing hid_t loc_id = hdf5_utils.createOrOpenHDF5Group(ctx->getDataobjectName().c_str(), IDS_file_id); if (! (loc_id >= 0)) throw ALBackendException("HDF5Backend: unexpected value for loc_id in HDF5Writer::create_IDS_group()", LOG); @@ -194,6 +283,7 @@ void HDF5Writer::close_datasets() void HDF5Writer::close_group(OperationContext *ctx) { + invalidateGroupMembers(); hid_t gid = -1; auto got = IDS_group_id.find(ctx); if (got != IDS_group_id.end()) { @@ -279,6 +369,7 @@ int HDF5Writer::readTimedAOSShape(hid_t loc_id, std::string &tensorized_path, co void HDF5Writer::beginWriteArraystructAction(ArraystructContext * ctx, int *size) { + invalidateGroupMembers(); //may add the AOS_SHAPE dataset to the group HDF5Utils hdf5_utils; OperationContext *opctx = ctx->getOperationContext(); hid_t gid = -1; @@ -342,6 +433,7 @@ ArraystructContext* HDF5Writer::getDynamicAOS(Context * ctx) { void HDF5Writer::write_ND_Data(Context * ctx, std::string & att_name, std::string & timebasename, int datatype, int dim, int *size, void *data) { + invalidateGroupMembers(); //may add the value and _SHAPE datasets to the group std::string & dataset_name = att_name; std::replace(dataset_name.begin(), dataset_name.end(), '/', '&'); // character '/' is not supported in datasets names std::replace(timebasename.begin(), timebasename.end(), '/', '&'); diff --git a/src/hdf5/hdf5_writer.h b/src/hdf5/hdf5_writer.h index 70caf417..71014d35 100644 --- a/src/hdf5/hdf5_writer.h +++ b/src/hdf5/hdf5_writer.h @@ -21,6 +21,15 @@ class HDF5Writer { int homogeneous_time; std::unordered_map < OperationContext *, hid_t> IDS_group_id; + + // Link names of one IDS group, cached for the duration of a run of + // deleteData() calls. Reset by invalidateGroupMembers() on anything that + // can add a dataset to a group or swap the group itself, so an over-eager + // reset is always the safe direction. Without it a whole-IDS delete (the + // HLI issues one al_delete_data per DD leaf) would re-list the group once + // per leaf. + hid_t group_members_gid; + std::vector < std::string > group_members; int slice_mode; @@ -30,6 +39,10 @@ class HDF5Writer { int readTimedAOSShape(Context * ctx, hid_t loc_id, const std::vector < int > ¤t_arrctx_indices); int readTimedAOSShape(hid_t loc_id, std::string &tensorized_path, const std::vector < int > ¤t_arrctx_indices, uri::Uri uri); int getDynamic_AOS_slices_extension(Context *ctx); + void deleteSubtree(hid_t gid, const std::string & path); + void deleteOccurrence(OperationContext * ctx, hid_t file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, std::string & files_directory, std::string & relative_file_path); + std::vector < std::string > &groupMembers(hid_t gid); + void invalidateGroupMembers(); int getDynamic_slices_extension(Context *ctx, int timed_AOS_index, int time_vector_length); ArraystructContext* getDynamicAOS(Context * ctx); @@ -44,7 +57,7 @@ class HDF5Writer { static size_t write_chunk_cache_size; virtual void closePulse(DataEntryContext * ctx, int mode, hid_t *file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, int files_path_strategy, std::string & files_directory, std::string & relative_file_path); - virtual void deleteData(OperationContext * ctx, hid_t file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, std::string & files_directory, std::string & relative_file_path); + virtual void deleteData(OperationContext * ctx, const std::string & path, hid_t file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, std::string & files_directory, std::string & relative_file_path); virtual void write_ND_Data(Context * ctx, std::string & att_name, std::string & timebasename, int datatype, int dim, int *size, void *data); virtual void beginWriteArraystructAction(ArraystructContext * ctx, int *size); diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 5193999e..2efa10cf 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -57,10 +57,11 @@ carry a `/` (or `/__r`) suffix per instance. The suite covers the **data-path, introspection, structured-data surface, plugin-management surface, and Plugin-author audience** end to end and pins -the defects those issues surfaced (twenty xfails, each with a paired +the defects those issues surfaced (nineteen xfails, each with a paired current-behavior tripwire — issue #8 resolved every open ownership/coverage -question by test but found no new genuine defects, so the xfail count is -unchanged). Issue #8 (`6-ownership-sweep.md`) closed every remaining row: +question by test but found no new genuine defects, so it left the xfail count +unchanged; issue #63 retired one by fixing it, HDF5's occurrence-wide +`al_delete_data` widening). Issue #8 (`6-ownership-sweep.md`) closed every remaining row: - **Ownership**: `al_context_info`, `al_get_occurrences`'s `*occurrences_list`, and `al_list_filled_paths`'s list+strings were already @@ -127,8 +128,8 @@ genuine defect (expected-fail). | ↳ CHAR > 2-D refused (documented "not implemented") on HDF5·ASCII | :244-263 / D4 | — | ✓ | `…/{HDF5,ASCII}_CHAR_r{3..7}` (paired-negative) | covered | | ↳ CHAR scalar (dim 0) must round-trip — **HDF5 crashes** | :244-263 | — | ✓ | `RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips` + tripwire `RoundTripKnownDefectsDeath.Hdf5CharScalarCurrentlyCrashes` | **xfail** | | ↳ numeric at MAXDIM (rank 7) must round-trip — **ASCII corrupts (DOUBLE/COMPLEX) / aborts (INTEGER)** | :244-263 | — | ✓ | `RoundTripKnownDefects.DISABLED_Ascii{Integer,Double,Complex}MaxdimRoundTrips` + tripwires `RoundTripKnownDefectsDeath.AsciiIntegerMaxdimCurrentlyAborts`, `RoundTripKnownDefects.Ascii{Double,Complex}MaxdimCurrentlyCorrupts` | **xfail** | -| `al_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` + `DeleteKnownDefects.DISABLED_Hdf5LeafDeleteKeepsOccurrenceFileAndLink` + tripwire `DeleteKnownDefects.Hdf5LeafDeleteCurrentlyWipesWholeOccurrence`, which pins all three observable consequences (sibling gone, `.h5` deleted, master file left holding a dangling external link — issue #63); matrix cells skip via `Backends/DeleteMatrix.{Leaf,Structure}*/HDF5` | **xfail** | +| `al_delete_data` at signal / structure / DATAOBJECT-root granularity | :265-272 | ✓ (Memory) | ✓ (HDF5) | Support is still uneven per backend (see xfail rows below), but HDF5 now implements all three: `Backends/DeleteMatrix.{LeafDeleteRemovesJustTheLeaf,StructureDeleteRemovesWholeSubtree}/{HDF5,Memory}`, `…/RootDeleteRemovesWholeOccurrence/HDF5`. HDF5 used to ignore `path` and widen every call to the whole occurrence — fixed in issue #63, whose HDF5-layout-specific consequences (which files and master-file links survive which granularity, and that a same-prefix sibling is not collateral) are pinned separately in `Hdf5Delete.*`, since they are invisible to this backend-agnostic matrix. The decided meaning of `path` and the per-backend matrix are recorded in `docs/adr/0001-al-delete-data-path-semantics.md`. MDSplus: not joined to this fixture — terminal gap, see Part 4 (issue #18). UDA: remote delete is cleanly refused (the reference plugin has no `deleteData` handler); remote write over the same path falsely reports success instead of the same refusal — xfail, see Part 5 | covered | +| ↳ HDF5 honors `path`: a non-empty path deletes that node and its subtree only, an empty path the whole DATAOBJECT (issue #63 — it previously widened every call to the occurrence and left `master.h5` holding a dangling external link) | :265-272; src/hdf5/hdf5_writer.cpp `deleteData` / `deleteSubtree` / `deleteOccurrence` | — | ✓ | `Hdf5Delete.{LeafDeleteKeepsOccurrenceFileAndMasterLink,RootDeleteRemovesOccurrenceFileAndMasterLink,SamePrefixSiblingsSurviveASubtreeDelete,DeletingAnAbsentPathChangesNothing,ManySuccessiveLeafDeletesAllTakeEffect}` — the last one covers the HLI's actual usage (imas-python `delete_children()` issues one delete per DD leaf), which the old code silently no-op'd after the first call closed the IDS group | covered | | ↳ Memory has no code path for DATAOBJECT-root delete — **a root-granularity delete is silently a no-op** | :265-272; src/memory_backend.cpp deleteData (no root/occurrence case) | ✓ | — | `DeleteKnownDefects.DISABLED_MemoryRootDeleteClearsWholeIds` + tripwire `DeleteKnownDefects.MemoryRootDeleteCurrentlyDoesNothing`; matrix cell skips via `Backends/DeleteMatrix.RootDeleteRemovesWholeOccurrence/Memory` | **xfail** | | ↳ ASCII's `deleteData` is a literal no-op (empty body) at every granularity; Flexbuffers' is too but is not independently ABI-observable (it already refuses every in-session read regardless of delete, so a read-based oracle can't distinguish "delete did nothing" from "this backend never round-trips reads") | :265-272; src/ascii_backend.cpp:648-652, src/flexbuffers_backend.cpp:387-389 | ✓ | ✓ | One tripwire covers every granularity on ASCII, since an empty function body has no path-dependent branch to differ: `DeleteKnownDefects.DISABLED_AsciiDeleteRemovesLeaf` + tripwire `DeleteKnownDefects.AsciiDeleteIsCurrentlyANoOp`; matrix cells skip via `Backends/DeleteMatrix.*/ASCII` and `…/Flexbuffers` | **xfail** | | `al_iterate_over_arraystruct` advances the AOS cursor | :274-282 | ✓ (Memory) | ✓ (HDF5) | Exercised as an asserted dependency of every AOS write/read in `Backends/AosMatrix.*` (top-level and nested, multi-step) | covered (indirect) | @@ -225,7 +226,7 @@ and points at the same test. | **B** `openPulse` / `closePulse` | :542-552 | ✓ (Memory) | ✓ (HDF5, ASCII) | via `al_begin_dataentry_action` / `al_close_pulse` in all suites | covered (indirect) | | **C** `beginAction` / `endAction` | :558-574 | ✓ (Memory) | ✓ (HDF5, ASCII) | via `al_begin_global_action` / `al_end_action` | covered (indirect) | | **C** `writeData` / `readData` (0=not-found vs 1=success inner convention) | :576-589 | ✓ (Memory) | ✓ (HDF5, ASCII) | via `al_write_data` / `al_read_data` — `RoundTripMatrix` (+ the CHAR-scalar / ASCII-maxdim xfails above). MDSplus: see Part 4's real-DD-path breadth matrix (issue #15) instead of `RoundTripMatrix`. UDA: same — see Part 5's real-DD-path breadth matrix instead of `RoundTripMatrix` | covered | -| **C** `deleteData` | :591-599 | ✓ (Memory) | ✓ (HDF5) | via `al_delete_data` — see Cluster 2 row above and its 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** `deleteData` | :591-599 | ✓ (Memory) | ✓ (HDF5) | via `al_delete_data` — see Cluster 2 row above and its two remaining xfail rows (Memory root, ASCII/Flexbuffers no-op); HDF5 is covered at all three granularities since issue #63. MDSplus: terminal gap — see Part 4 (issue #18). UDA: remote delete cleanly refused, remote write falsely reports success — xfail, see Part 5 | covered (indirect) | | **C** `beginArraystructAction` | :601-606 | ✓ (Memory) | ✓ (HDF5) | via `al_begin_arraystruct_action` — see Cluster 2 row above. MDSplus: joined (divergence) plus its own unique struct-aware slice/segment-write surface — see Part 4. UDA: traversal mechanism covered, a dynamic leaf nested inside a struct_array returns the wrong value — xfail, see Part 5 | covered (indirect) | | **D** `get_occurrences` | :612-616 | — | ✓ | via `al_get_occurrences` — `Occurrences.*`. MDSplus: see Part 4. UDA: see Part 5 | covered (indirect) | | **D** `list_filled_paths` (per-backend: HDF5 real, others throw) | :618-640 | ✓ (Memory) | ✓ (HDF5) | via `al_list_filled_paths` — `CapabilityMatrix.ListFilledPathsPositiveOrRefused` pins both the HDF5 impl and the unconditional throw on Memory·ASCII·Flexbuffers. MDSplus: throws unconditionally too, same as Memory·ASCII·Flexbuffers — see Part 4. UDA: real over remote mode, restricted to `backend=hdf5` — see Part 5 | covered | @@ -275,8 +276,6 @@ whoever fixed it to enable the paired `DISABLED_` correct-contract test: | `ERASE_PULSE` == `CLOSE_PULSE` on Memory | `…DISABLED_MemoryEraseMakesPulseUnopenable` | `ErasePulseKnownDefects.MemoryEraseCurrentlyLeavesPulseOpenable` | | `al_get_backendID` accepts a non-pulse context via unchecked `static_cast` | `GetBackendIdKnownDefects.DISABLED_WrongContextTypeReturnsError` | `GetBackendIdKnownDefects.WrongContextTypeCurrentlySucceedsViaUnsafeCast` | | ASCII AOS read always reports size 0 | `AosKnownDefects.DISABLED_AsciiAosReadReportsWrittenSize` | `AosKnownDefects.AsciiAosReadCurrentlyReportsZero` | -| HDF5 leaf/structure delete wipes the whole occurrence | `DeleteKnownDefects.DISABLED_Hdf5LeafDeleteLeavesSiblingIntact` | `DeleteKnownDefects.Hdf5LeafDeleteCurrentlyWipesWholeOccurrence` | -| HDF5 leaf delete deletes `.h5` and leaves a dangling master-file link (issue #63) | `DeleteKnownDefects.DISABLED_Hdf5LeafDeleteKeepsOccurrenceFileAndLink` | `DeleteKnownDefects.Hdf5LeafDeleteCurrentlyWipesWholeOccurrence` | | Memory root delete is a no-op | `DeleteKnownDefects.DISABLED_MemoryRootDeleteClearsWholeIds` | `DeleteKnownDefects.MemoryRootDeleteCurrentlyDoesNothing` | | ASCII delete is a no-op at every granularity | `DeleteKnownDefects.DISABLED_AsciiDeleteRemovesLeaf` | `DeleteKnownDefects.AsciiDeleteIsCurrentlyANoOp` | diff --git a/tests/contract/test_structured_data.cpp b/tests/contract/test_structured_data.cpp index b2e2d63a..4cbd2bab 100644 --- a/tests/contract/test_structured_data.cpp +++ b/tests/contract/test_structured_data.cpp @@ -430,14 +430,13 @@ INSTANTIATE_TEST_SUITE_P( // =========================================================================== // The three granularities land on wildly different, non-overlapping support // across the always-on tier (verified by reading each backend's deleteData): -// - HDF5: ignores `path` entirely — every call deletes the whole occurrence. -// So *root* is the only granularity that matches the documented contract; -// leaf/structure are genuine defects (a "leaf" delete takes everything -// else with it). One tripwire (leaf) covers both defective granularities: -// `HDF5Writer::deleteData` (src/hdf5/hdf5_writer.cpp) takes no path -// parameter at all, so there is no leaf-vs-structure branch whose -// behavior could differ — a structure-shaped call hits the exact same -// unconditional occurrence wipe. +// - HDF5: all three work. `path` used to be dropped on the floor, so every +// call widened to the whole occurrence (issue #63); it now selects the +// matching datasets out of the IDS group's flat namespace, and only an +// empty path removes the occurrence. The HDF5-specific consequences — +// which files and master-file links survive which granularity, and that a +// same-prefix sibling is not collateral — are pinned in Hdf5Delete.* below, +// since they are not observable through this backend-agnostic matrix. // - Memory: the only backend that honors `path` — leaf and structure (an // AOS field) both work for real; there is no code path that special-cases // "delete the whole IDS", so root is the defect here. @@ -462,8 +461,8 @@ struct DeleteBackendCase { inline void PrintTo(const DeleteBackendCase& b, std::ostream* os) { *os << b.name; } const DeleteBackendCase kDeleteBackends[] = { - {HDF5_BACKEND, "HDF5", /*on_disk=*/true, /*leaf_ok=*/false, - /*structure_ok=*/false, /*root_ok=*/true}, + {HDF5_BACKEND, "HDF5", /*on_disk=*/true, /*leaf_ok=*/true, + /*structure_ok=*/true, /*root_ok=*/true}, {MEMORY_BACKEND, "Memory", /*on_disk=*/false, /*leaf_ok=*/true, /*structure_ok=*/true, /*root_ok=*/false}, {ASCII_BACKEND, "ASCII", /*on_disk=*/true, /*leaf_ok=*/false, @@ -608,16 +607,106 @@ class DeleteKnownDefects : public ::testing::Test { } }; -const BackendCase kHdf5{HDF5_BACKEND, "HDF5", /*on_disk=*/true}; const BackendCase kAscii{ASCII_BACKEND, "ASCII", /*on_disk=*/true}; -// --- HDF5: leaf delete ignores `path`, wipes the whole occurrence ---------- +// --- Memory: no code path implements DATAOBJECT-root delete ---------------- +bool memory_root_delete_clears_everything(al_contract::TempBase& base, + const PulseId& pulse) { + const std::string uri = al_contract::build_uri(MEMORY_BACKEND, base.str(), pulse); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); + EXPECT_EQ(al_contract::write_data(op, "leaf_a", {}, {11.0}).code, 0); + EXPECT_EQ(al_contract::write_data(op, "leaf_b", {}, {22.0}).code, 0); + EXPECT_EQ(al_delete_data(op, "").code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + int rop = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", READ_OP, &rop).code, 0); + std::vector shape; + std::vector a, bb; + EXPECT_EQ(al_contract::read_data(rop, "leaf_a", 0, &shape, &a).code, 0); + EXPECT_EQ(al_contract::read_data(rop, "leaf_b", 0, &shape, &bb).code, 0); + EXPECT_EQ(al_end_action(rop).code, 0); + al_close_pulse(pctx, CLOSE_PULSE); + return !a.empty() && !bb.empty() && a[0] == al_contract::kEmptyDouble && + bb[0] == al_contract::kEmptyDouble; +} + +TEST_F(DeleteKnownDefects, DISABLED_MemoryRootDeleteClearsWholeIds) { + EXPECT_TRUE(memory_root_delete_clears_everything(base_, pulse_)) + << "a DATAOBJECT-root delete must clear every field of the IDS"; +} + +TEST_F(DeleteKnownDefects, MemoryRootDeleteCurrentlyDoesNothing) { + EXPECT_FALSE(memory_root_delete_clears_everything(base_, pulse_)) + << "MemoryBackend::deleteData now supports root granularity — enable " + "DeleteKnownDefects.DISABLED_MemoryRootDeleteClearsWholeIds " + "(memory_backend.cpp deleteData has no root/occurrence case)"; +} + +// --- ASCII: deleteData is a literal no-op ---------------------------------- +// Flexbuffers is deliberately NOT covered by this pair: it refuses every +// in-session read regardless of delete (RoundTripMatrix classifies the whole +// backend as a must-refuse column — "write accepted, read refused, every +// datatype x shape"), so a read-based oracle can't distinguish "delete did +// nothing" from "this backend never round-trips reads at all." Its +// deleteData is confirmed a no-op by reading the source directly +// (flexbuffers_backend.cpp: empty NOOP body) — noted in the file-header +// comment above, not re-asserted here since it isn't ABI-observable. +bool delete_is_a_noop(const BackendCase& b, al_contract::TempBase& base, + const PulseId& pulse) { + const std::string uri = al_contract::build_uri(b.id, base.str(), pulse); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + int op = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); + EXPECT_EQ(al_contract::write_data(op, "leaf_a", {}, {11.0}).code, 0); + EXPECT_EQ(al_delete_data(op, "leaf_a").code, 0); + EXPECT_EQ(al_end_action(op).code, 0); + int rop = -1; + EXPECT_EQ(al_begin_global_action(pctx, kIds, "", READ_OP, &rop).code, 0); + std::vector shape; + std::vector a; + EXPECT_EQ(al_contract::read_data(rop, "leaf_a", 0, &shape, &a).code, 0); + EXPECT_EQ(al_end_action(rop).code, 0); + al_close_pulse(pctx, CLOSE_PULSE); + return !a.empty() && a[0] == 11.0; // still there => delete did nothing +} + +TEST_F(DeleteKnownDefects, DISABLED_AsciiDeleteRemovesLeaf) { + EXPECT_FALSE(delete_is_a_noop(kAscii, base_, pulse_)) + << "al_delete_data must actually remove data in the ASCII backend"; +} +TEST_F(DeleteKnownDefects, AsciiDeleteIsCurrentlyANoOp) { + EXPECT_TRUE(delete_is_a_noop(kAscii, base_, pulse_)) + << "AsciiBackend::deleteData now has an effect — enable " + "DeleteKnownDefects.DISABLED_AsciiDeleteRemovesLeaf " + "(ascii_backend.cpp deleteData has an empty body)"; +} + + +// =========================================================================== +// HDF5-specific al_delete_data consequences (issue #63). +// =========================================================================== +// DeleteMatrix above asks the backend-agnostic question ("is the named node +// gone, is its sibling still there"). Three things it cannot see matter on +// HDF5, because they are properties of its two-level file layout rather than +// of the C ABI: +// +// 1. Which *files* survive. A non-empty path must leave .h5 alone; only +// an empty path (the whole DATAOBJECT) removes it. Before issue #63 every +// delete removed it. +// 2. Whether master.h5 still links the occurrence. The link has to follow the +// file: leaving it behind advertises an occurrence — al_get_occurrences +// enumerates exactly these links — whose backing file is gone. +// 3. Same-prefix siblings. The IDS group is a flat namespace of mangled path +// names ('/' -> '&', AOS nodes suffixed "[]"), so "time" and +// "time_slice[]&x" are neighbours in one namespace and a prefix match +// alone would take the second with the first. // One non-empty-path delete of a single leaf, observed on three axes: the // sibling leaf, the occurrence's own pulse file, and the external link the -// master file holds for that occurrence. The last two are what issue #63 -// measured, and they are why the widening is not merely untidy — the call -// reports success while removing the whole occurrence, and leaves master.h5 -// pointing at a file that no longer exists. +// master file holds for that occurrence. struct Hdf5LeafDeleteObservation { double sibling_value; // leaf_b read back after al_delete_data("leaf_a") bool ids_file_exists; // .h5 still on disk @@ -681,111 +770,158 @@ Hdf5LeafDeleteObservation hdf5_observe_leaf_delete(al_contract::TempBase& base, return obs; } -TEST_F(DeleteKnownDefects, DISABLED_Hdf5LeafDeleteLeavesSiblingIntact) { - EXPECT_EQ(hdf5_observe_leaf_delete(base_, pulse_).sibling_value, 22.0) - << "a leaf-granularity delete must not remove sibling fields"; -} +class Hdf5Delete : public ::testing::Test { +protected: + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/12, /*run=*/0}; + + int open() { + base_.make_legacy_tree(pulse_); + const std::string uri = al_contract::build_uri(HDF5_BACKEND, base_.str(), pulse_); + int pctx = -1; + EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + return pctx; + } + + bool ids_file_exists() const { + return file_exists_under(base_.str(), std::string(kIds) + ".h5"); + } + + // True iff master.h5 still holds an external link for the occurrence. + bool occurrence_listed(int pctx) const { + int* occ = nullptr; + int n = -1; + const bool ok = al_get_occurrences(pctx, kIds, &occ, &n).code == 0 && n > 0; + free(occ); + return ok; + } +}; -TEST_F(DeleteKnownDefects, DISABLED_Hdf5LeafDeleteKeepsOccurrenceFileAndLink) { +TEST_F(Hdf5Delete, LeafDeleteKeepsOccurrenceFileAndMasterLink) { const Hdf5LeafDeleteObservation obs = hdf5_observe_leaf_delete(base_, pulse_); + EXPECT_EQ(obs.sibling_value, 22.0) + << "a leaf-granularity delete must not remove sibling fields"; EXPECT_TRUE(obs.ids_file_exists) << "a leaf-granularity delete must not remove the occurrence's pulse file"; EXPECT_TRUE(obs.occurrence_listed) - << "a leaf-granularity delete must leave the occurrence, and so the " - "master file's link to it, in place"; + << "the occurrence, and so the master file's link to it, must survive a " + "leaf-granularity delete"; } -TEST_F(DeleteKnownDefects, Hdf5LeafDeleteCurrentlyWipesWholeOccurrence) { - const Hdf5LeafDeleteObservation obs = hdf5_observe_leaf_delete(base_, pulse_); - EXPECT_EQ(obs.sibling_value, al_contract::kEmptyDouble) - << "HDF5 deleteData now honors `path` for leaf granularity — enable " - "DeleteKnownDefects.DISABLED_Hdf5LeafDeleteLeavesSiblingIntact " - "(src/hdf5/hdf5_writer.cpp deleteData ignores its caller's field)"; - EXPECT_FALSE(obs.ids_file_exists) - << "HDF5 deleteData no longer deletes the whole pulse file for a " - "non-empty path — enable " - "DeleteKnownDefects.DISABLED_Hdf5LeafDeleteKeepsOccurrenceFileAndLink"; - EXPECT_TRUE(obs.occurrence_listed) - << "the master file no longer holds a dangling link after a leaf " - "delete — enable " - "DeleteKnownDefects.DISABLED_Hdf5LeafDeleteKeepsOccurrenceFileAndLink"; +TEST_F(Hdf5Delete, RootDeleteRemovesOccurrenceFileAndMasterLink) { + const int pctx = open(); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_a", {}, {11.0})); + AL_EXPECT_OK(al_delete_data(op, "")); + AL_ASSERT_OK(al_end_action(op)); + + EXPECT_FALSE(ids_file_exists()) + << "an empty path addresses the whole DATAOBJECT: .h5 must go"; + EXPECT_FALSE(occurrence_listed(pctx)) + << "the master file must not keep an external link to a pulse file that " + "was just deleted (issue #63)"; + + al_close_pulse(pctx, CLOSE_PULSE); } -// --- Memory: no code path implements DATAOBJECT-root delete ---------------- -bool memory_root_delete_clears_everything(al_contract::TempBase& base, - const PulseId& pulse) { - const std::string uri = al_contract::build_uri(MEMORY_BACKEND, base.str(), pulse); - int pctx = -1; - EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); + +// The flat-namespace hazard, in both continuation forms: "time" vs the AOS +// "time_slice[]&…" ("[]"), and "code" vs the leaf "code_name" (neither '&' nor +// "[]", so not a child at all). +TEST_F(Hdf5Delete, SamePrefixSiblingsSurviveASubtreeDelete) { + const int pctx = open(); int op = -1; - EXPECT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); - EXPECT_EQ(al_contract::write_data(op, "leaf_a", {}, {11.0}).code, 0); - EXPECT_EQ(al_contract::write_data(op, "leaf_b", {}, {22.0}).code, 0); - EXPECT_EQ(al_delete_data(op, "").code, 0); - EXPECT_EQ(al_end_action(op).code, 0); + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "time", {}, {7.0})); + AL_EXPECT_OK(al_contract::write_data(op, "code_name", {}, {8.0})); + AL_EXPECT_OK(al_contract::write_data(op, "code/version", {}, {9.0})); + int size = 1; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, "time_slice", "", &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, "x", {}, {5.0})); + AL_ASSERT_OK(al_end_action(aos)); + AL_EXPECT_OK(al_delete_data(op, "time")); + AL_EXPECT_OK(al_delete_data(op, "code")); + AL_ASSERT_OK(al_end_action(op)); + int rop = -1; - EXPECT_EQ(al_begin_global_action(pctx, kIds, "", READ_OP, &rop).code, 0); + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &rop)); std::vector shape; - std::vector a, bb; - EXPECT_EQ(al_contract::read_data(rop, "leaf_a", 0, &shape, &a).code, 0); - EXPECT_EQ(al_contract::read_data(rop, "leaf_b", 0, &shape, &bb).code, 0); - EXPECT_EQ(al_end_action(rop).code, 0); - al_close_pulse(pctx, CLOSE_PULSE); - return !a.empty() && !bb.empty() && a[0] == al_contract::kEmptyDouble && - bb[0] == al_contract::kEmptyDouble; -} + std::vector t, code_name, code_version, x; + AL_EXPECT_OK(al_contract::read_data(rop, "time", 0, &shape, &t)); + AL_EXPECT_OK(al_contract::read_data(rop, "code_name", 0, &shape, &code_name)); + AL_EXPECT_OK(al_contract::read_data(rop, "code/version", 0, &shape, &code_version)); + int rsize = -1; + int raos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(rop, "time_slice", "", &rsize, &raos)); + ASSERT_EQ(rsize, 1) << "the time_slice AOS must survive deleting the leaf \"time\""; + AL_EXPECT_OK(al_contract::read_data(raos, "x", 0, &shape, &x)); + AL_ASSERT_OK(al_end_action(raos)); + AL_ASSERT_OK(al_end_action(rop)); -TEST_F(DeleteKnownDefects, DISABLED_MemoryRootDeleteClearsWholeIds) { - EXPECT_TRUE(memory_root_delete_clears_everything(base_, pulse_)) - << "a DATAOBJECT-root delete must clear every field of the IDS"; -} + EXPECT_EQ(t.at(0), al_contract::kEmptyDouble) << "the named leaf must be gone"; + EXPECT_EQ(code_version.at(0), al_contract::kEmptyDouble) + << "a structure delete must take its children"; + EXPECT_EQ(x.at(0), 5.0) << "\"time_slice[]&x\" is not part of the \"time\" subtree"; + EXPECT_EQ(code_name.at(0), 8.0) + << "\"code_name\" is not part of the \"code\" subtree"; -TEST_F(DeleteKnownDefects, MemoryRootDeleteCurrentlyDoesNothing) { - EXPECT_FALSE(memory_root_delete_clears_everything(base_, pulse_)) - << "MemoryBackend::deleteData now supports root granularity — enable " - "DeleteKnownDefects.DISABLED_MemoryRootDeleteClearsWholeIds " - "(memory_backend.cpp deleteData has no root/occurrence case)"; + al_close_pulse(pctx, CLOSE_PULSE); } -// --- ASCII: deleteData is a literal no-op ---------------------------------- -// Flexbuffers is deliberately NOT covered by this pair: it refuses every -// in-session read regardless of delete (RoundTripMatrix classifies the whole -// backend as a must-refuse column — "write accepted, read refused, every -// datatype x shape"), so a read-based oracle can't distinguish "delete did -// nothing" from "this backend never round-trips reads at all." Its -// deleteData is confirmed a no-op by reading the source directly -// (flexbuffers_backend.cpp: empty NOOP body) — noted in the file-header -// comment above, not re-asserted here since it isn't ABI-observable. -bool delete_is_a_noop(const BackendCase& b, al_contract::TempBase& base, - const PulseId& pulse) { - const std::string uri = al_contract::build_uri(b.id, base.str(), pulse); - int pctx = -1; - EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); +TEST_F(Hdf5Delete, DeletingAnAbsentPathChangesNothing) { + const int pctx = open(); int op = -1; - EXPECT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); - EXPECT_EQ(al_contract::write_data(op, "leaf_a", {}, {11.0}).code, 0); - EXPECT_EQ(al_delete_data(op, "leaf_a").code, 0); - EXPECT_EQ(al_end_action(op).code, 0); + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_a", {}, {11.0})); + AL_EXPECT_OK(al_delete_data(op, "never_written")) + << "deleting what is not there is not an error"; + AL_ASSERT_OK(al_end_action(op)); + int rop = -1; - EXPECT_EQ(al_begin_global_action(pctx, kIds, "", READ_OP, &rop).code, 0); + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &rop)); std::vector shape; std::vector a; - EXPECT_EQ(al_contract::read_data(rop, "leaf_a", 0, &shape, &a).code, 0); - EXPECT_EQ(al_end_action(rop).code, 0); + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_a", 0, &shape, &a)); + AL_ASSERT_OK(al_end_action(rop)); + EXPECT_EQ(a.at(0), 11.0) << "an absent-path delete must not touch anything else"; + al_close_pulse(pctx, CLOSE_PULSE); - return !a.empty() && a[0] == 11.0; // still there => delete did nothing } -TEST_F(DeleteKnownDefects, DISABLED_AsciiDeleteRemovesLeaf) { - EXPECT_FALSE(delete_is_a_noop(kAscii, base_, pulse_)) - << "al_delete_data must actually remove data in the ASCII backend"; -} -TEST_F(DeleteKnownDefects, AsciiDeleteIsCurrentlyANoOp) { - EXPECT_TRUE(delete_is_a_noop(kAscii, base_, pulse_)) - << "AsciiBackend::deleteData now has an effect — enable " - "DeleteKnownDefects.DISABLED_AsciiDeleteRemovesLeaf " - "(ascii_backend.cpp deleteData has an empty body)"; -} +// The whole-IDS delete the HLI actually issues: imas-python's delete_children() +// walks the DD and sends one al_delete_data per leaf, so the *second* call has +// to still find a live group. It did not before issue #63: the first call +// closed the group, and every later one returned early having done nothing. +TEST_F(Hdf5Delete, ManySuccessiveLeafDeletesAllTakeEffect) { + const int pctx = open(); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + for (const char* leaf : {"leaf_a", "leaf_b", "leaf_c"}) + AL_EXPECT_OK(al_contract::write_data(op, leaf, {}, {1.0})); + AL_ASSERT_OK(al_end_action(op)); + + int dop = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &dop)); + for (const char* leaf : {"leaf_a", "leaf_b"}) + AL_EXPECT_OK(al_delete_data(dop, leaf)); + AL_ASSERT_OK(al_end_action(dop)); + + int rop = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &rop)); + std::vector shape; + std::vector a, b, c; + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_a", 0, &shape, &a)); + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_b", 0, &shape, &b)); + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_c", 0, &shape, &c)); + AL_ASSERT_OK(al_end_action(rop)); + EXPECT_EQ(a.at(0), al_contract::kEmptyDouble); + EXPECT_EQ(b.at(0), al_contract::kEmptyDouble) + << "the second delete of a session must take effect too"; + EXPECT_EQ(c.at(0), 1.0) << "a leaf nobody deleted must survive"; + al_close_pulse(pctx, CLOSE_PULSE); +} // =========================================================================== // al_get_occurrences. From 1c00c855bad55045c32ed22ae7e2610a1bf24be1 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:24:42 +0200 Subject: [PATCH 89/91] Match delete paths per segment, so a path may cross an AOS (issue #63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut matched dataset names by string prefix plus a continuation character. That is right for every path the HLI issues, but it matches nothing at all for a path that traverses an array of structures: `outer/inner` mangles to `outer&inner` while the dataset is named `outer[]&inner&leaf`. The call then deleted nothing and reported success — in miniature, the same "argument accepted, ignored, success reported" state this issue exists to remove. Confirmed by test before changing the rule. Match per segment instead: each segment of the requested path matches a dataset-name segment with or without its `[]` AOS suffix, and the name's remaining segments are its subtree. Segment-wise still tells a real child from a same-prefix stranger (`time` vs `time_slice[]&x`, `code` vs `code_name`), and it gives an AOS-crossing path the one meaning the ABI leaves available: `deleteData` receives an OperationContext, never an arraystruct one, so no element can be named and the path can only mean "that subtree in every element" — exactly what the tensorized layout holds. --- .../adr/0001-al-delete-data-path-semantics.md | 18 ++++-- src/hdf5/hdf5_writer.cpp | 63 ++++++++++++++----- tests/contract/test_structured_data.cpp | 44 +++++++++++++ 3 files changed, 104 insertions(+), 21 deletions(-) diff --git a/docs/adr/0001-al-delete-data-path-semantics.md b/docs/adr/0001-al-delete-data-path-semantics.md index 49de0621..e31ef783 100644 --- a/docs/adr/0001-al-delete-data-path-semantics.md +++ b/docs/adr/0001-al-delete-data-path-semantics.md @@ -20,10 +20,20 @@ an absent path is a successful no-op. `HDF5Writer::deleteSubtree`. An IDS group is a flat namespace of "tensorized" dataset names — the DD path with `/` → `&`, array-of-structures nodes suffixed `[]`, one `_SHAPE` companion per non-scalar leaf, one `…[]&AOS_SHAPE` per -dynamic AOS — so the subtree rooted at a DD path is exactly the set of names -that are the mangled path, its `_SHAPE` companion, or that continue with `&` (a -structure child) or `[]` (an AOS element index). The continuation character is -what keeps `time` from taking `time_slice[]&x` with it. +dynamic AOS — so `time_slice/global_quantities/ip` is stored as +`time_slice[]&global_quantities&ip`, flattened over the AOS index. Matching is +therefore **per segment**, not by string prefix: each segment of the requested +path matches a name segment with or without the `[]` suffix, and the name's +remaining segments are its subtree. Segment-wise is what tells a real child +from a same-prefix stranger (deleting `time` must not take `time_slice[]&x`, +deleting `code` must not take `code_name`) while still letting a path address a +node *inside* an AOS. That last case has only one available meaning: the ABI +hands `deleteData` an `OperationContext`, never an arraystruct one, so there is +no way to name an element and `time_slice/global_quantities` can only mean "that +subtree in every element" — which is exactly what the tensorized layout stores. +A raw-prefix rule matched no dataset at all for such a path and returned +success, i.e. it reproduced in miniature the very failure mode this ADR +rejects. **2. Occurrence-wide delete is intended: reject a non-empty `path`.** Rejected. It is the cheaper change, but it is not what the ABI says and not what the other diff --git a/src/hdf5/hdf5_writer.cpp b/src/hdf5/hdf5_writer.cpp index 3782aafc..ba2e9a4c 100644 --- a/src/hdf5/hdf5_writer.cpp +++ b/src/hdf5/hdf5_writer.cpp @@ -61,27 +61,56 @@ void HDF5Writer::close_file_handler(std::string external_link_name, std::unorder // An IDS group is a flat namespace of "tensorized" dataset names: the DD path // with '/' replaced by '&', every array-of-structures node suffixed "[]", one // "_SHAPE" companion per non-scalar leaf and one "…[]&AOS_SHAPE" per dynamic -// AOS. So the subtree rooted at a DD path is exactly the set of dataset names -// that are the mangled path itself, its "_SHAPE" companion, or that continue -// with '&' (a structure child) or "[]" (an AOS element index). +// AOS. So "time_slice/global_quantities/ip" is stored as +// "time_slice[]&global_quantities&ip", flattened over the AOS index. // -// The continuation character is what separates a subtree from a same-prefix -// sibling: deleting "time" must not touch "time_slice[]&…". +// Matching therefore has to be per segment, not by raw string prefix: each +// segment of the requested path matches a dataset-name segment with or without +// the "[]" AOS suffix, and the dataset's remaining segments are its subtree. +// Segment-wise is what tells a real child from a same-prefix stranger — +// deleting "time" must not touch "time_slice[]&x", and deleting "code" must not +// touch "code_name" — while still letting a path address a node *inside* an +// AOS, which the C ABI gives no way to do per element: al_delete_data takes an +// OperationContext, so "time_slice/global_quantities" can only mean "that +// subtree in every element", exactly as the tensorized layout stores it. +static std::vector < std::string > split_on_ampersand(const std::string & path) +{ + std::vector < std::string > segments; + std::string::size_type start = 0; + while (true) { + const std::string::size_type sep = path.find('&', start); + if (sep == std::string::npos) { + segments.push_back(path.substr(start)); + return segments; + } + segments.push_back(path.substr(start, sep - start)); + start = sep + 1; + } +} + static bool is_in_subtree(const std::string & dataset_name, const std::string & mangled_path) { - if (dataset_name.size() < mangled_path.size() || - dataset_name.compare(0, mangled_path.size(), mangled_path) != 0) + const std::vector < std::string > name_segments = split_on_ampersand(dataset_name); + const std::vector < std::string > wanted_segments = split_on_ampersand(mangled_path); + if (name_segments.size() < wanted_segments.size()) + return false; + + for (size_t i = 0; i < wanted_segments.size(); i++) { + std::string segment = name_segments[i]; + if (segment.size() >= 2 && segment.compare(segment.size() - 2, 2, "[]") == 0) + segment.erase(segment.size() - 2); //an AOS node: "time_slice[]" -> "time_slice" + if (segment == wanted_segments[i]) + continue; + //The addressed node's own "_SHAPE" companion is the one case where the + //last segment may carry a suffix. A deeper AOS's "AOS_SHAPE" and a + //child's "…_SHAPE" are already covered: they are extra segments, or + //segments past the requested path. + if (i + 1 == wanted_segments.size() && name_segments.size() == wanted_segments.size() && + segment == wanted_segments[i] + "_SHAPE") + continue; return false; - const std::string continuation = dataset_name.substr(mangled_path.size()); - if (continuation.empty()) // the leaf's own dataset - return true; - if (continuation == "_SHAPE") // its shape companion - return true; - if (continuation[0] == '&') // a child of a structure - return true; - if (continuation.compare(0, 2, "[]") == 0) // "[]&…", "[]&AOS_SHAPE" - return true; - return false; + } + return true; } void HDF5Writer::invalidateGroupMembers() diff --git a/tests/contract/test_structured_data.cpp b/tests/contract/test_structured_data.cpp index 4cbd2bab..3683b3db 100644 --- a/tests/contract/test_structured_data.cpp +++ b/tests/contract/test_structured_data.cpp @@ -704,6 +704,9 @@ TEST_F(DeleteKnownDefects, AsciiDeleteIsCurrentlyANoOp) { // names ('/' -> '&', AOS nodes suffixed "[]"), so "time" and // "time_slice[]&x" are neighbours in one namespace and a prefix match // alone would take the second with the first. +// 4. Paths that traverse an AOS. The same flattening means "outer/inner" is +// stored as "outer[]&inner&…", so matching has to be per segment or such +// a path silently matches nothing. // One non-empty-path delete of a single leaf, observed on three axes: the // sibling leaf, the occurrence's own pulse file, and the external link the // master file holds for that occurrence. @@ -869,6 +872,47 @@ TEST_F(Hdf5Delete, SamePrefixSiblingsSurviveASubtreeDelete) { al_close_pulse(pctx, CLOSE_PULSE); } +// A path may address a node *inside* an AOS. The C ABI gives no way to say +// which element — al_delete_data takes an OperationContext, not an +// arraystruct one — so "outer/inner" can only mean "that subtree in every +// element", which is exactly how the tensorized layout stores it +// ("outer[]&inner&leaf", flattened over the AOS index). Raw prefix matching +// missed these entirely and reported success, so the call was a silent no-op. +TEST_F(Hdf5Delete, PathThroughAnAosDeletesTheSubtreeInEveryElement) { + const int pctx = open(); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + int size = 2; + int aos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(op, "outer", "", &size, &aos)); + AL_EXPECT_OK(al_contract::write_data(aos, "inner/leaf", {}, {1.0})); + AL_EXPECT_OK(al_contract::write_data(aos, "keep", {}, {3.0})); + AL_EXPECT_OK(al_iterate_over_arraystruct(aos, 1)); + AL_EXPECT_OK(al_contract::write_data(aos, "inner/leaf", {}, {2.0})); + AL_EXPECT_OK(al_contract::write_data(aos, "keep", {}, {4.0})); + AL_ASSERT_OK(al_end_action(aos)); + AL_EXPECT_OK(al_delete_data(op, "outer/inner")); + AL_ASSERT_OK(al_end_action(op)); + + int rop = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &rop)); + int rsize = -1; + int raos = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(rop, "outer", "", &rsize, &raos)); + ASSERT_EQ(rsize, 2); + std::vector shape; + std::vector leaf, keep; + AL_EXPECT_OK(al_contract::read_data(raos, "inner/leaf", 0, &shape, &leaf)); + AL_EXPECT_OK(al_contract::read_data(raos, "keep", 0, &shape, &keep)); + AL_ASSERT_OK(al_end_action(raos)); + AL_ASSERT_OK(al_end_action(rop)); + EXPECT_EQ(leaf.at(0), al_contract::kEmptyDouble) + << "a path through an AOS must delete its subtree in every element"; + EXPECT_EQ(keep.at(0), 3.0) << "a sibling inside the same AOS element must survive"; + + al_close_pulse(pctx, CLOSE_PULSE); +} + TEST_F(Hdf5Delete, DeletingAnAbsentPathChangesNothing) { const int pctx = open(); int op = -1; From 0250431484a3f1c71fd1a58f6e6807f5b130a6c3 Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:28:04 +0200 Subject: [PATCH 90/91] Address the code review on the al_delete_data change (issue #63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standards axis: - The group listing now validates itself against the group's live link count instead of relying on four hand-placed invalidateGroupMembers() calls on the write paths. Only a write can add a link, and a write can only add, so an unchanged count means an unchanged set of names — which removes the latent coupling where any future dataset-creating path had to remember to invalidate. - unlinkFromGroup() owns both the H5Ldelete loop and keeping the cache in step, so deleteSubtree no longer reassigns the writer's own vector through a returned reference. - The delete error builds its message as a std::string rather than through a fixed-size buffer: a dataset name is a caller-supplied DD path, which the src/hdf5 `char error_message[200]` idiom cannot bound. - Both H5 failures in the listing name their own function. - Hdf5Delete's tests all go through the fixture now; the free observation helper that duplicated three of its members (and skipped make_legacy_tree, unlike every other on-disk path in the file) is gone. Spec axis: - al_lowlevel.h's per-backend summary said memory does "node and subtree" where only the AOS case is characterized, and omitted UDA entirely. - The ADR records the two behaviour changes the issue did not itself measure — master-link removal on an empty-path delete, and deleting an IDS file that is closed but still on disk — rather than leaving them silent. The one substantive fidelity finding, that a path below an AOS silently matched nothing, was already fixed in the preceding commit; the review confirms it independently. --- .../adr/0001-al-delete-data-path-semantics.md | 12 ++- include/al_lowlevel.h | 14 ++-- src/hdf5/hdf5_writer.cpp | 58 +++++++------ src/hdf5/hdf5_writer.h | 16 ++-- tests/contract/test_structured_data.cpp | 83 ++++++------------- 5 files changed, 89 insertions(+), 94 deletions(-) diff --git a/docs/adr/0001-al-delete-data-path-semantics.md b/docs/adr/0001-al-delete-data-path-semantics.md index e31ef783..97ad6f83 100644 --- a/docs/adr/0001-al-delete-data-path-semantics.md +++ b/docs/adr/0001-al-delete-data-path-semantics.md @@ -66,7 +66,7 @@ Checked by reading each `deleteData` and pinned in `tests/contract/` | Backend | node | subtree | whole DATAOBJECT (`path == ""`) | |---|---|---|---| | HDF5 | ✅ | ✅ | ✅ — deletes `.h5` *and* the master-file link | -| Memory | ✅ | ✅ (AOS node) | ❌ no code path for it — xfail | +| Memory | ✅ | ✅ (AOS node; a plain non-AOS structure is uncharacterized) | ❌ no code path for it — xfail | | MDSplus | ✅ | ❌ `%TREE-W-NNF` — xfail | ❌ `%TREE-W-NNF` — xfail | | ASCII | ❌ empty body | ❌ | ❌ | | Flexbuffers | ❌ empty body | ❌ | ❌ | @@ -95,7 +95,15 @@ reach it. A plain (non-AOS) structure path on Memory is not characterized. - **The master-file link now follows the file.** A whole-DATAOBJECT delete removes the external link as well, so the master file no longer advertises an occurrence whose backing file is gone — the second half of what issue #63 - measured. + measured. Issue #63 raises the dangling link under option 2 only, but it is + listed there as part of what was measured on today's code, so it is fixed + either way. +- **A whole-DATAOBJECT delete now also removes an IDS file that was closed but + still on disk.** The old code reopened such a file and then marked it closed + without deleting it — leaking the handle and leaving the data in place. The + issue never measured this (through a `WRITE_OP` the file is always open, so + the branch was unreachable in the reproduction), but the empty-path contract + cannot hold with it in place. - A whole-IDS delete lists the IDS group's link names once per delete rather than once per leaf (`HDF5Writer::groupMembers`, invalidated by anything that can add a dataset). Without that cache the HLI's leaf-by-leaf traversal would diff --git a/include/al_lowlevel.h b/include/al_lowlevel.h index f5b11adc..e063d783 100644 --- a/include/al_lowlevel.h +++ b/include/al_lowlevel.h @@ -426,11 +426,15 @@ extern "C" Backend support for the three granularities is uneven, and a backend that cannot honour `path` does not report that it could not: ASCII and - flexbuffers ignore the call entirely, memory implements node and subtree - deletes but not the DATAOBJECT one, MDSplus implements node deletes only. - HDF5 implements all three. The per-backend matrix, and why the argument is - specified this way rather than as an occurrence-wide delete, are recorded - in docs/adr/0001-al-delete-data-path-semantics.md. + flexbuffers ignore the call entirely, memory implements node and + array-of-structures deletes but not the DATAOBJECT one, MDSplus implements + node deletes only. HDF5 implements all three. UDA forwards `path` to + whichever backend it is fronting: in fetch mode to the local one, and in + remote mode to a server plugin that need not implement delete at all (the + reference plugin does not, and refuses cleanly). The per-backend matrix, + and why the argument is specified this way rather than as an + occurrence-wide delete, are recorded in + docs/adr/0001-al-delete-data-path-semantics.md. @param[in] ctx operation context id (from al_plugin_begin_global_action() or al_plugin_begin_slice_action()) @param[in] path path of the data structure element to delete (suppress the whole subtree); empty means the whole DATAOBJECT diff --git a/src/hdf5/hdf5_writer.cpp b/src/hdf5/hdf5_writer.cpp index ba2e9a4c..e4dd33e5 100644 --- a/src/hdf5/hdf5_writer.cpp +++ b/src/hdf5/hdf5_writer.cpp @@ -119,23 +119,50 @@ void HDF5Writer::invalidateGroupMembers() group_members.clear(); } -std::vector < std::string > &HDF5Writer::groupMembers(hid_t gid) +// The cached listing is trusted only while it still accounts for every link in +// the group. Nothing but a write can add one, and a write can only add, so an +// unchanged link count means an unchanged set of names — which spares every +// write path from having to remember to invalidate this. +const std::vector < std::string > &HDF5Writer::groupMembers(hid_t gid) { - if (group_members_gid != gid) { + H5G_info_t group_info; + if (H5Gget_info(gid, &group_info) < 0) + throw ALBackendException("HDF5Backend: H5Gget_info has failed in HDF5Writer::groupMembers()", LOG); + + if (group_members_gid != gid || group_members.size() != (size_t) group_info.nlinks) { group_members.clear(); H5L_iterate_t collect = [](hid_t, const char *name, const H5L_info_t *, void *op_data) -> herr_t { static_cast < std::vector < std::string > *>(op_data)->push_back(name); return 0; }; if (H5Literate(gid, H5_INDEX_NAME, H5_ITER_NATIVE, NULL, collect, &group_members) < 0) { - group_members.clear(); - throw ALBackendException("HDF5Backend: H5Literate has failed in HDF5Writer::deleteData()", LOG); + invalidateGroupMembers(); + throw ALBackendException("HDF5Backend: H5Literate has failed in HDF5Writer::groupMembers()", LOG); } group_members_gid = gid; } return group_members; } +void HDF5Writer::unlinkFromGroup(hid_t gid, const std::vector < std::string > &names) +{ + for (const std::string & name : names) { + // H5Ldelete only unlinks: the freed space stays in the file until it is + // repacked, so a delete never shrinks .h5. + if (H5Ldelete(gid, name.c_str(), H5P_DEFAULT) < 0) { + invalidateGroupMembers(); + //A dataset name is a caller-supplied DD path, so it is built into + //the message as a string rather than through a fixed-size buffer. + throw ALBackendException("HDF5Backend: unable to delete HDF5 dataset: " + name, LOG); + } + } + //Keep the cached listing in step, so a run of deletes still lists the group + //only once. This is the only code that removes a link from the group. + for (const std::string & name : names) + group_members.erase(std::remove(group_members.begin(), group_members.end(), name), + group_members.end()); +} + void HDF5Writer::deleteData(OperationContext * ctx, const std::string & path, hid_t file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, std::string & files_directory, std::string & relative_file_path) { if (file_id == -1) @@ -170,27 +197,12 @@ void HDF5Writer::deleteSubtree(hid_t gid, const std::string & path) write_buffers(); close_datasets(); //also clears the existing_data_sets H5Lexists cache - std::vector < std::string > &members = groupMembers(gid); std::vector < std::string > doomed; - std::vector < std::string > kept; - for (const std::string & name : members) { + for (const std::string & name : groupMembers(gid)) { if (is_in_subtree(name, mangled_path)) doomed.push_back(name); - else - kept.push_back(name); - } - - for (const std::string & name : doomed) { - // H5Ldelete only unlinks: the freed space stays in the file until it is - // repacked, so a delete never shrinks .h5. - if (H5Ldelete(gid, name.c_str(), H5P_DEFAULT) < 0) { - invalidateGroupMembers(); - char error_message[300]; - snprintf(error_message, sizeof(error_message), "Unable to delete HDF5 dataset: %s\n", name.c_str()); - throw ALBackendException(error_message, LOG); - } } - members = std::move(kept); + unlinkFromGroup(gid, doomed); } void HDF5Writer::deleteOccurrence(OperationContext * ctx, hid_t file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, std::string & files_directory, std::string & relative_file_path) @@ -289,7 +301,7 @@ void HDF5Writer::create_IDS_group(OperationContext * ctx, hid_t file_id, std::un throw ALBackendException(error_message, LOG); } } - close_group(ctx); //also invalidates the cached group listing + close_group(ctx); hid_t loc_id = hdf5_utils.createOrOpenHDF5Group(ctx->getDataobjectName().c_str(), IDS_file_id); if (! (loc_id >= 0)) throw ALBackendException("HDF5Backend: unexpected value for loc_id in HDF5Writer::create_IDS_group()", LOG); @@ -398,7 +410,6 @@ int HDF5Writer::readTimedAOSShape(hid_t loc_id, std::string &tensorized_path, co void HDF5Writer::beginWriteArraystructAction(ArraystructContext * ctx, int *size) { - invalidateGroupMembers(); //may add the AOS_SHAPE dataset to the group HDF5Utils hdf5_utils; OperationContext *opctx = ctx->getOperationContext(); hid_t gid = -1; @@ -462,7 +473,6 @@ ArraystructContext* HDF5Writer::getDynamicAOS(Context * ctx) { void HDF5Writer::write_ND_Data(Context * ctx, std::string & att_name, std::string & timebasename, int datatype, int dim, int *size, void *data) { - invalidateGroupMembers(); //may add the value and _SHAPE datasets to the group std::string & dataset_name = att_name; std::replace(dataset_name.begin(), dataset_name.end(), '/', '&'); // character '/' is not supported in datasets names std::replace(timebasename.begin(), timebasename.end(), '/', '&'); diff --git a/src/hdf5/hdf5_writer.h b/src/hdf5/hdf5_writer.h index 71014d35..7acb17eb 100644 --- a/src/hdf5/hdf5_writer.h +++ b/src/hdf5/hdf5_writer.h @@ -22,12 +22,13 @@ class HDF5Writer { int homogeneous_time; std::unordered_map < OperationContext *, hid_t> IDS_group_id; - // Link names of one IDS group, cached for the duration of a run of - // deleteData() calls. Reset by invalidateGroupMembers() on anything that - // can add a dataset to a group or swap the group itself, so an over-eager - // reset is always the safe direction. Without it a whole-IDS delete (the - // HLI issues one al_delete_data per DD leaf) would re-list the group once - // per leaf. + // Link names of one IDS group, cached so that a run of deleteData() calls + // lists the group once instead of once per call — the HLI issues one + // al_delete_data per DD leaf, so re-listing per call would be quadratic in + // the size of the IDS. The cache validates itself against the group's live + // link count rather than relying on write paths to invalidate it: only a + // write can add a link, and the only code that removes one + // (unlinkFromGroup) keeps the cache in step itself. hid_t group_members_gid; std::vector < std::string > group_members; @@ -41,7 +42,8 @@ class HDF5Writer { int getDynamic_AOS_slices_extension(Context *ctx); void deleteSubtree(hid_t gid, const std::string & path); void deleteOccurrence(OperationContext * ctx, hid_t file_id, std::unordered_map < std::string, hid_t > &opened_IDS_files, std::string & files_directory, std::string & relative_file_path); - std::vector < std::string > &groupMembers(hid_t gid); + const std::vector < std::string > &groupMembers(hid_t gid); + void unlinkFromGroup(hid_t gid, const std::vector < std::string > &names); void invalidateGroupMembers(); int getDynamic_slices_extension(Context *ctx, int timed_AOS_index, int time_vector_length); ArraystructContext* getDynamicAOS(Context * ctx); diff --git a/tests/contract/test_structured_data.cpp b/tests/contract/test_structured_data.cpp index 3683b3db..585ac5fe 100644 --- a/tests/contract/test_structured_data.cpp +++ b/tests/contract/test_structured_data.cpp @@ -707,15 +707,6 @@ TEST_F(DeleteKnownDefects, AsciiDeleteIsCurrentlyANoOp) { // 4. Paths that traverse an AOS. The same flattening means "outer/inner" is // stored as "outer[]&inner&…", so matching has to be per segment or such // a path silently matches nothing. -// One non-empty-path delete of a single leaf, observed on three axes: the -// sibling leaf, the occurrence's own pulse file, and the external link the -// master file holds for that occurrence. -struct Hdf5LeafDeleteObservation { - double sibling_value; // leaf_b read back after al_delete_data("leaf_a") - bool ids_file_exists; // .h5 still on disk - bool occurrence_listed; // master file still links the occurrence -}; - // Located by name rather than by rebuilding the backend's own on-disk path // strategy (hdf5_utils.cpp pulseFilePathFactory), which the test has no // business restating. @@ -730,49 +721,6 @@ inline bool file_exists_under(const std::string& root, const std::string& name) return false; } -Hdf5LeafDeleteObservation hdf5_observe_leaf_delete(al_contract::TempBase& base, - const PulseId& pulse) { - const std::string uri = al_contract::build_uri(HDF5_BACKEND, base.str(), pulse); - int pctx = -1; - EXPECT_EQ(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, &pctx).code, 0); - int op = -1; - EXPECT_EQ(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op).code, 0); - EXPECT_EQ(al_contract::write_data(op, "leaf_a", {}, {11.0}).code, 0); - EXPECT_EQ(al_contract::write_data(op, "leaf_b", {}, {22.0}).code, 0); - EXPECT_EQ(al_delete_data(op, "leaf_a").code, 0); - EXPECT_EQ(al_end_action(op).code, 0); - - // Reopening a READ on the occurrence may itself fail — if the delete - // removed the whole occurrence's group/link/file there is nothing left to - // open. That failure is just as much proof the sibling is gone as reading - // back the sentinel would be. - int rop = -1; - al_status_t open_read = al_begin_global_action(pctx, kIds, "", READ_OP, &rop); - Hdf5LeafDeleteObservation obs{al_contract::kEmptyDouble, false, false}; - if (open_read.code == 0) { - std::vector shape; - std::vector bb; - EXPECT_EQ(al_contract::read_data(rop, "leaf_b", 0, &shape, &bb).code, 0); - EXPECT_EQ(al_end_action(rop).code, 0); - if (!bb.empty()) obs.sibling_value = bb[0]; - } - - // al_get_occurrences enumerates the master file's links live - // (hdf5_reader.cpp H5Literate over master_file_id), so it reports the link - // whether or not the file it points at still exists — exactly the dangling - // state issue #63 measured. - int* occ = nullptr; - int n = -1; - if (al_get_occurrences(pctx, kIds, &occ, &n).code == 0 && n > 0) - obs.occurrence_listed = true; - free(occ); - - obs.ids_file_exists = file_exists_under(base.str(), std::string(kIds) + ".h5"); - - al_close_pulse(pctx, CLOSE_PULSE); - return obs; -} - class Hdf5Delete : public ::testing::Test { protected: al_contract::TempBase base_; @@ -791,6 +739,11 @@ class Hdf5Delete : public ::testing::Test { } // True iff master.h5 still holds an external link for the occurrence. + // al_get_occurrences enumerates those links live (hdf5_reader.cpp + // H5Literate over master_file_id) and never stats what they point at, so + // it reports a link whose file is gone — which is what makes it the oracle + // for the dangling state issue #63 measured, independently of + // ids_file_exists(). bool occurrence_listed(int pctx) const { int* occ = nullptr; int n = -1; @@ -801,14 +754,32 @@ class Hdf5Delete : public ::testing::Test { }; TEST_F(Hdf5Delete, LeafDeleteKeepsOccurrenceFileAndMasterLink) { - const Hdf5LeafDeleteObservation obs = hdf5_observe_leaf_delete(base_, pulse_); - EXPECT_EQ(obs.sibling_value, 22.0) + const int pctx = open(); + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", WRITE_OP, &op)); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_a", {}, {11.0})); + AL_EXPECT_OK(al_contract::write_data(op, "leaf_b", {}, {22.0})); + AL_EXPECT_OK(al_delete_data(op, "leaf_a")); + AL_ASSERT_OK(al_end_action(op)); + + int rop = -1; + AL_ASSERT_OK(al_begin_global_action(pctx, kIds, "", READ_OP, &rop)); + std::vector shape; + std::vector a, b; + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_a", 0, &shape, &a)); + AL_EXPECT_OK(al_contract::read_data(rop, "leaf_b", 0, &shape, &b)); + AL_ASSERT_OK(al_end_action(rop)); + + EXPECT_EQ(a.at(0), al_contract::kEmptyDouble) << "the named leaf must be gone"; + EXPECT_EQ(b.at(0), 22.0) << "a leaf-granularity delete must not remove sibling fields"; - EXPECT_TRUE(obs.ids_file_exists) + EXPECT_TRUE(ids_file_exists()) << "a leaf-granularity delete must not remove the occurrence's pulse file"; - EXPECT_TRUE(obs.occurrence_listed) + EXPECT_TRUE(occurrence_listed(pctx)) << "the occurrence, and so the master file's link to it, must survive a " "leaf-granularity delete"; + + al_close_pulse(pctx, CLOSE_PULSE); } TEST_F(Hdf5Delete, RootDeleteRemovesOccurrenceFileAndMasterLink) { From 858a7a2457d55f0105de90e0ad34043be596514d Mon Sep 17 00:00:00 2001 From: YohannMrg <98689112+yohannmarguier@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:02:51 +0200 Subject: [PATCH 91/91] Make HDF5 reads honor absolute field paths (issue #65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `al_read_data` has always documented that a fieldpath may address a field from the dataobject root with a prepended `/`, but `HDF5Reader::read_ND_Data` honored only the relative half: it replaced `/` with `&` and then unconditionally prepended the current AOS context's tensorized path, so an already-rooted name was appended to the context it was meant to replace (`time_slice[]&constraints&j_phi[]&&time_slice&constraints&j_phi&…`). `H5Lexists` found nothing, the reader returned 0, and `al_plugin_read_data` filled the caller's buffer with EMPTY_* while reporting code 0 — stored data reported as absent, a silent wrong answer rather than an error. Decision: an absolute path resolves **by path segment** against the AOS chain the caller has open, cutting at the last matching AOS boundary. That answers the two coupled questions together — which dataset name to look up (the tensorized `[]` markers of the retained levels are kept) and which indices still apply (only the retained levels', so a child's cursor is never applied to an ancestor's shallower dataset, and the timed-AOS designation is dropped with it). Recorded in docs/adr/0002-absolute-field-path-resolution.md with the four rejected alternatives — chiefly plain de-slashing, which fixes only root fields, and keeping the child's whole cursor, which returns a wrong number instead of none. Segment-wise matching is the same rule ADR 0001 chose for `al_delete_data`, for the same reason: it tells a real descendant from a same-prefix stranger. The resolution lives in `HDF5Utils::resolveAbsoluteFieldPath`, which reports how many AOS levels the target still sits under; `read_ND_Data` truncates the current indices to it. No DD-version knowledge, no schema lookup, no ABI change. A target beyond an AOS the caller never opened has no cursor and no schema to infer one from, so it resolves without that `[]`, is not found, and travels the ordinary missing-data path — decided, and pinned by a test. Scope is HDF5 reads, as issue #65 establishes: the suite writes through relative spellings only, so it presumes nothing about writes, delete or arraystruct opening. Tests: Hdf5AbsoluteFieldPaths.* — both spellings of one address compared at nonzero indices on both levels of a nested AOS (time_slice → constraints/j_phi), for a scalar leaf, a CHAR array leaf, ancestor targets, root targets and the operation context itself; the plugin twin (al_plugin_read_data) asserted separately; cache/iteration stability, an absent-data control, and the unopened-AOS limit. Ten of the thirteen fail on the parent commit. Verified against the full suite, the Debug leg, ASan/UBSan (no new findings) and the Python suite. --- .../0002-absolute-field-path-resolution.md | 130 +++++ src/hdf5/hdf5_reader.cpp | 32 +- src/hdf5/hdf5_utils.cpp | 114 ++++ src/hdf5/hdf5_utils.h | 12 + tests/contract/CMakeLists.txt | 1 + tests/contract/TRACEABILITY.md | 1 + tests/contract/test_hdf5_absolute_paths.cpp | 544 ++++++++++++++++++ 7 files changed, 833 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0002-absolute-field-path-resolution.md create mode 100644 tests/contract/test_hdf5_absolute_paths.cpp diff --git a/docs/adr/0002-absolute-field-path-resolution.md b/docs/adr/0002-absolute-field-path-resolution.md new file mode 100644 index 00000000..3333f7f8 --- /dev/null +++ b/docs/adr/0002-absolute-field-path-resolution.md @@ -0,0 +1,130 @@ +--- +status: accepted +--- + +# An absolute field path resolves by segment match against the open context chain + +`al_read_data(ctx, fieldpath, …)` documents `fieldpath` as "always relative to +current Context, dataobject absolute path can be specified with a prepended +'/'" (`include/al_lowlevel.h`, read-data block). An absolute field path is not +a filesystem path and does not carry the IDS name: +`/time_slice/constraints/j_phi/reconstructed` names the same stored datum as +`reconstructed` read from the corresponding array-of-structures context. + +The HDF5 read path honoured only the relative half. `HDF5Reader::read_ND_Data` +replaced every `/` with `&` (the tensorized separator) and then +*unconditionally* prepended the current AOS context's tensorized path, so an +already-rooted name was appended to the context it was meant to replace: + +```text +requested /time_slice/constraints/j_phi/reconstructed +looked up time_slice[]&constraints&j_phi[]&&time_slice&constraints&j_phi&reconstructed +stored as time_slice[]&constraints&j_phi[]&reconstructed +``` + +`H5Lexists` found nothing, `read_ND_Data` returned 0, and `al_plugin_read_data` +filled the caller's buffer with `EMPTY_DOUBLE` while reporting `code == 0` +(`Lowlevel::setDefaultValue`). Stored data was therefore indistinguishable from +genuinely absent data — a silent wrong answer, not an error (issue #65). The +same defect made a root field unreadable from the operation context itself, +where the leading `&` was the only thing left to strip. + +We keep the documented meaning and implement it: an absolute field path is +resolved **by path segment** against the AOS chain the caller currently has +open, retaining the tensorized markers and the indices of every level the +target still sits under, and only those. + +## Considered options + +**1. Segment-match against the open context chain (chosen).** Implemented in +`HDF5Utils::resolveAbsoluteFieldPath`, called from `read_ND_Data`. An IDS group +is a flat namespace of tensorized dataset names — the DD path with `/` → `&`, +each array-of-structures segment suffixed `[]`, the AOS flattened into a +leading index dimension — so resolving an absolute path means answering two +questions at once, which is why one function answers both: + +- **which name to look up.** The stored name keeps the `[]` of every AOS the + field sits under, so the tensorized prefix of the matched levels is retained + and only the remaining segments are appended. +- **which indices still apply.** Each retained `[]` level keeps its current + cursor; levels the target does not sit under are dropped, along with the + timed-AOS designation if the timed level is among them (the dataset then + carries no time dimension to select in). + +Both fall out of one walk: compare the absolute path's segments against the +open chain's segments and cut at the last AOS boundary that still matches. +Matching is per segment, never by string prefix — `time_slice_extra/…` shares a +textual prefix with `time_slice[]` but no path prefix at all. This is the same +rule ADR 0001 adopted for `al_delete_data`, for the same reason: segment-wise +is what tells a real descendant from a same-prefix stranger. + +Worked examples, with `time_slice[]` → `constraints/j_phi[]` open at +`(slice=1, constraint=2)`: + +| Requested | Resolved name | Indices applied | +|---|---|---| +| `/time_slice/constraints/j_phi/reconstructed` | `time_slice[]&constraints&j_phi[]&reconstructed` | `(1, 2)` | +| `/time_slice/global_quantities/ip` | `time_slice[]&global_quantities&ip` | `(1)` | +| `/vacuum_toroidal_field/r0` | `vacuum_toroidal_field&r0` | none | +| `/time_slice/constraints/j_phi/reconstructed`, from the `time_slice` context | `time_slice[]&constraints&j_phi&reconstructed` | `(1)` → not found, absent | + +**2. Strip the leading `/` and use the rest verbatim.** Rejected. It resolves a +root field correctly and nothing else: the result carries no `[]` for any AOS +above the target, so every path through an array of structures — the case the +issue is actually about — still misses. It also silently duplicates the path +when the target is under the open context. + +**3. Skip the context prefix by string length.** Rejected. Cheaper than a +segment walk and wrong twice over: it drops the `[]` markers along with the +prefix, and a purely textual prefix matches same-prefix strangers. + +**4. Keep the child's whole cursor regardless of where the target sits.** +Rejected. An ancestor-level dataset carries fewer tensorized dimensions than +the child context has indices, so applying all of them either selects out of +extent or reads the wrong element. This is the mirror image of option 2's +failure and would be the more dangerous one, since it returns a number. + +**5. Leave the core alone; have the caller rewrite absolute to relative.** +Rejected. The contract being broken is the core's own, stated in its public +header, so every caller would have to carry the same workaround. It is also not +generally possible: an absolute path may address an *ancestor* or a *root* +field, which no relative spelling from the current context can name, and a +blanket rewrite of the caller's argument would corrupt exactly those cases. + +## Scope + +The reproduction, diagnosis and fix are HDF5 **reads**. Writes, +`al_delete_data`, `al_begin_arraystruct_action` and the other backends were not +established as affected by this reproduction and are deliberately not changed +here; a concrete finding about any of them belongs in its own issue. The +regression suite writes through relative spellings only, so it never presumes +an answer for the write side. + +The change is confined to path/selection computation: no DD-version knowledge, +no schema lookup, no C ABI change. The version knowledge constraint in +`NORTH_STAR.md` is the reason a "just look up whether that segment is an AOS" +resolution is not on the table at all. + +## Consequences + +- **A target beyond an AOS the caller never opened is reported absent.** There + is no cursor for that level, and the core holds no schema that would even + tell it the segment is an array of structures. The resolved name carries no + `[]` for it, finds nothing, and travels the ordinary missing-data path. That + is the honest answer; inventing an index is how a wrong value gets returned + in place of no value. Pinned by + `Hdf5AbsoluteFieldPaths.TargetBeyondAnUnopenedAosIsReportedAbsent`. +- **Ordinary missing-data semantics are unchanged.** An absolute path that + resolves but names nothing stored still returns `code == 0` with the type's + empty sentinel, exactly as the relative spelling does. The fix makes stored + data findable; it does not make absent data look present. +- **Existence and dataset caches are keyed on the resolved name** + (`existing_data_sets`, `opened_data_sets`), so the two spellings of one + address share one cache entry and cannot disagree across repeated reads. +- **A root timebase is now classified correctly.** `/time` from the operation + context previously resolved to `&time`, which matched the "time dataset + inside a dynamic AOS" suffix test rather than the homogeneous-timebase one. + It now resolves to `time` and is classified as the homogeneous timebase. +- **Absolute and relative reads may still differ in *cost*.** Nothing here + changes the per-node `Backend` contract: an absolute read is resolved and + looked up one field at a time, like every other read. diff --git a/src/hdf5/hdf5_reader.cpp b/src/hdf5/hdf5_reader.cpp index 7c8e9230..e60d9c6d 100644 --- a/src/hdf5/hdf5_reader.cpp +++ b/src/hdf5/hdf5_reader.cpp @@ -478,14 +478,32 @@ int HDF5Reader::read_ND_Data(Context *ctx, std::string &att_name, std::string &t if (gid == -1) // IDS does not exist in the file return 0; + // Whether the field is addressed from the dataobject root must be decided + // before the separators are normalized, while the leading '/' marker is + // still distinguishable (issue #65). + const bool absolute_field = HDF5Utils::isAbsoluteFieldPath(att_name); + std::string &dataset_name = att_name; std::replace(dataset_name.begin(), dataset_name.end(), '/', '&'); // character '/' is not supported in datasets names std::replace(timebasename.begin(), timebasename.end(), '/', '&'); hid_t dataset_id = -1; std::string tensorized_path = dataset_name; + // How many of the open AOS chain's levels the field lives under. Only an + // absolute path can sit above the current context and so retain fewer than + // all of them; a relative one is always read at the full current cursor. + size_t applicable_aos_levels = std::numeric_limits::max(); - if (ctx->getType() == CTX_ARRAYSTRUCT_TYPE) + if (absolute_field) + { + const std::vector no_open_aos; // an operation context opens none + const std::vector &tensorized_paths = + (ctx->getType() == CTX_ARRAYSTRUCT_TYPE) + ? tensorized_paths_per_context[static_cast(ctx)] + : no_open_aos; + tensorized_path = HDF5Utils::resolveAbsoluteFieldPath(dataset_name, tensorized_paths, &applicable_aos_levels); + } + else if (ctx->getType() == CTX_ARRAYSTRUCT_TYPE) { auto &tensorized_paths = tensorized_paths_per_context[static_cast(ctx)]; tensorized_path = tensorized_paths.back() + "&" + dataset_name; @@ -540,6 +558,18 @@ int HDF5Reader::read_ND_Data(Context *ctx, std::string &att_name, std::string &t std::vector current_arrctx_indices; hdf5_utils.getAOSIndices(ctx, current_arrctx_indices, &timed_AOS_index); // getting current AOS indices + if (applicable_aos_levels < current_arrctx_indices.size()) + { + // An absolute field path that stops short of the innermost open AOS + // keeps only the indices of the levels it still passes through -- the + // child's cursor must not be applied to an ancestor's dataset, which + // carries fewer tensorized dimensions (issue #65). The indices run + // outermost first, so the retained ones are the leading entries. + current_arrctx_indices.resize(applicable_aos_levels); + if (timed_AOS_index >= static_cast(applicable_aos_levels)) + timed_AOS_index = -1; // the timed AOS is not among them: the field is not tensorized on time + } + bool is_dynamic = timebasename.compare("") != 0 ? true : false; bool isTimed = (timed_AOS_index != -1); int slice_index = -1; diff --git a/src/hdf5/hdf5_utils.cpp b/src/hdf5/hdf5_utils.cpp index c51c227b..9b277cd1 100644 --- a/src/hdf5/hdf5_utils.cpp +++ b/src/hdf5/hdf5_utils.cpp @@ -587,6 +587,120 @@ void HDF5Utils::setTensorizedPaths(ArraystructContext * ctx, std::vector < std:: std::replace(tensorized_paths.back().begin(), tensorized_paths.back().end(), '/', '&'); } +// --- absolute field paths (issue #65) -------------------------------------- +// +// al_read_data's contract (al_lowlevel.h) lets a caller address a field from +// the dataobject root by prepending '/': it is not a filesystem path and does +// not carry the IDS name, so "/time_slice/constraints/j_phi/reconstructed" +// names the same datum as "reconstructed" read from the corresponding AOS +// context. +// +// Resolving one answers two questions at once, which is why they are answered +// here together rather than by trimming the string at the call site: +// +// * which dataset name to look up. The stored name keeps the tensorized +// "[]" marker of every AOS the field sits under, so simply dropping the +// '/' ("time_slice&constraints&j_phi&reconstructed") never matches, and +// simply skipping the context prefix duplicates nothing but loses those +// markers. +// * which AOS indices still apply. A field that sits above the current +// context must not be read at the child's cursor. +// +// Both follow from one walk: compare the absolute path's segments against the +// open context chain's segments and cut at the last AOS boundary that still +// matches. Segments, never string prefixes -- "time_slice_extra/..." shares a +// textual prefix with "time_slice[]" but no path prefix at all. +// +// Whatever follows the cut is appended verbatim. A path that leaves the open +// chain -- a sibling branch, or one crossing an AOS the caller never opened -- +// therefore resolves to a name carrying no "[]" for that branch: it is looked +// up, not found, and reported by the ordinary missing-data path. That is the +// honest answer rather than a guess: the core holds no schema, so it cannot +// know an unopened segment is an AOS, and would have no cursor for it if it +// did. + +// Tensorized dataset names join their segments with '&' ('/' is not a legal +// HDF5 link-name character), and mark each array of structures with a +// trailing "[]" -- see setTensorizedPaths() above. +static const char TENSORIZED_SEPARATOR = '&'; +static const char *AOS_MARKER = "[]"; + +static bool isAOSSegment(const std::string & segment) +{ + const size_t marker_length = strlen(AOS_MARKER); + return segment.size() >= marker_length && + segment.compare(segment.size() - marker_length, marker_length, AOS_MARKER) == 0; +} + +static std::string stripAOSMarker(const std::string & segment) +{ + return isAOSSegment(segment) ? segment.substr(0, segment.size() - strlen(AOS_MARKER)) : segment; +} + +static std::vector < std::string > splitTensorizedSegments(const std::string & name) +{ + std::vector < std::string > segments; + size_t start = 0; + while (true) { + const size_t end = name.find(TENSORIZED_SEPARATOR, start); + if (end == std::string::npos) { + segments.push_back(name.substr(start)); + return segments; + } + segments.push_back(name.substr(start, end - start)); + start = end + 1; + } +} + +bool HDF5Utils::isAbsoluteFieldPath(const std::string & field) +{ + return !field.empty() && field[0] == '/'; +} + +std::string HDF5Utils::resolveAbsoluteFieldPath(const std::string & absolute_dataset_name, + const std::vector < std::string > &tensorized_paths, + size_t * applicable_aos_levels) +{ + *applicable_aos_levels = 0; + + // The '/' separators (including the leading absolute marker) have already + // been normalized to '&' by the caller, so the path and the context chain + // are compared in one spelling. + std::string rooted_name = absolute_dataset_name; + if (!rooted_name.empty() && rooted_name[0] == TENSORIZED_SEPARATOR) + rooted_name.erase(0, 1); + + if (tensorized_paths.empty()) // no AOS is open: the marker was all there was to strip + return rooted_name; + + const std::vector < std::string > context_segments = splitTensorizedSegments(tensorized_paths.back()); + const std::vector < std::string > target_segments = splitTensorizedSegments(rooted_name); + + size_t matched_segments = 0; // target segments consumed at the last matching AOS boundary + size_t levels = 0; // AOS levels retained at that boundary + size_t levels_walked = 0; + for (size_t i = 0; i < context_segments.size() && i < target_segments.size(); i++) { + if (stripAOSMarker(context_segments[i]) != target_segments[i]) + break; + if (isAOSSegment(context_segments[i])) { + levels_walked++; + matched_segments = i + 1; + levels = levels_walked; + } + } + *applicable_aos_levels = levels; + + // tensorized_paths[levels - 1] is that boundary's own tensorized path: + // every segment up to and including its "[]" marker. + std::string resolved_name = (levels == 0) ? std::string() : tensorized_paths[levels - 1]; + for (size_t i = matched_segments; i < target_segments.size(); i++) { + if (!resolved_name.empty()) + resolved_name += TENSORIZED_SEPARATOR; + resolved_name += target_segments[i]; + } + return resolved_name; +} + hid_t HDF5Utils::createOrOpenHDF5Group(const std::string & path, const hid_t & parent_loc_id) { diff --git a/src/hdf5/hdf5_utils.h b/src/hdf5/hdf5_utils.h index a7d621a5..520bc221 100644 --- a/src/hdf5/hdf5_utils.h +++ b/src/hdf5/hdf5_utils.h @@ -79,6 +79,18 @@ class HDF5Utils { void getAOSIndices(Context * ctx, std::vector < int >&indices, int *timedAOS_index); int getAOSIndicesSize(Context * ctx); void setTensorizedPaths(ArraystructContext * ctx, std::vector < std::string > &tensorized_paths); + + // Absolute field paths (issue #65). isAbsoluteFieldPath() must be asked + // before '/' separators are normalized to '&'; resolveAbsoluteFieldPath() + // takes the already-normalized name, the tensorized paths of the open AOS + // chain (innermost last, as setTensorizedPaths builds them) and reports, + // through applicable_aos_levels, how many of those levels the resolved + // dataset still lives under -- i.e. how many of the caller's current AOS + // indices apply to it. See the block comment in hdf5_utils.cpp. + static bool isAbsoluteFieldPath(const std::string & field); + static std::string resolveAbsoluteFieldPath(const std::string & absolute_dataset_name, + const std::vector < std::string > &tensorized_paths, + size_t * applicable_aos_levels); void showStatus(hid_t file_id); enum Files_paths_strategies { FULL_MDSPLUS_STRATEGY = 1, MODIFIED_MDSPLUS_STRATEGY = 2, FREE_PATH_STRATEGY = 3}; void setDefaultOptions(size_t *read_cache, size_t *write_cache, bool *readBuffering, bool *writeBuffering); diff --git a/tests/contract/CMakeLists.txt b/tests/contract/CMakeLists.txt index b22a72bf..fd5f9f55 100644 --- a/tests/contract/CMakeLists.txt +++ b/tests/contract/CMakeLists.txt @@ -70,6 +70,7 @@ add_executable(contract_tests test_plugins.cpp test_backend_factory.cpp test_hdf5_version_drift.cpp + test_hdf5_absolute_paths.cpp real_dd_path_catalog.cpp test_real_dd_path_catalog.cpp test_structured_data.cpp diff --git a/tests/contract/TRACEABILITY.md b/tests/contract/TRACEABILITY.md index 2efa10cf..d596aca2 100644 --- a/tests/contract/TRACEABILITY.md +++ b/tests/contract/TRACEABILITY.md @@ -128,6 +128,7 @@ genuine defect (expected-fail). | ↳ CHAR > 2-D refused (documented "not implemented") on HDF5·ASCII | :244-263 / D4 | — | ✓ | `…/{HDF5,ASCII}_CHAR_r{3..7}` (paired-negative) | covered | | ↳ CHAR scalar (dim 0) must round-trip — **HDF5 crashes** | :244-263 | — | ✓ | `RoundTripKnownDefects.DISABLED_Hdf5CharScalarRoundTrips` + tripwire `RoundTripKnownDefectsDeath.Hdf5CharScalarCurrentlyCrashes` | **xfail** | | ↳ numeric at MAXDIM (rank 7) must round-trip — **ASCII corrupts (DOUBLE/COMPLEX) / aborts (INTEGER)** | :244-263 | — | ✓ | `RoundTripKnownDefects.DISABLED_Ascii{Integer,Double,Complex}MaxdimRoundTrips` + tripwires `RoundTripKnownDefectsDeath.AsciiIntegerMaxdimCurrentlyAborts`, `RoundTripKnownDefects.Ascii{Double,Complex}MaxdimCurrentlyCorrupts` | **xfail** | +| `al_read_data` addresses a field relative to the passed context, or from the dataobject root with a prepended `/` | :244-263; al_lowlevel.h:390-413 | — | ✓ (HDF5) | `Hdf5AbsoluteFieldPaths.*` — both spellings of one address are read back and compared, at nonzero indices on both levels of a nested AOS (`time_slice` → `constraints/j_phi`), for a scalar leaf, an array leaf, ancestor targets (only the ancestor's cursor retained), root targets (no index applied) and the operation context itself; plus the plugin twin (`…AgreesThroughPluginEntryPoint`), cache/iteration stability and an absent-data control. HDF5 used to prepend the current AOS context's tensorized path to an already-rooted name, so every absolute read returned `EMPTY_*` with `code == 0` — stored data reported as absent (issue #65); the resolution rule is recorded in `docs/adr/0002-absolute-field-path-resolution.md`. Memory·ASCII·Flexbuffers·MDSplus·UDA: not joined to this fixture — terminal gap, the diagnosis and fix are HDF5-only (issue #65 scope) and each remaining backend resolves field paths in its own layout | covered | | `al_delete_data` at signal / structure / DATAOBJECT-root granularity | :265-272 | ✓ (Memory) | ✓ (HDF5) | Support is still uneven per backend (see xfail rows below), but HDF5 now implements all three: `Backends/DeleteMatrix.{LeafDeleteRemovesJustTheLeaf,StructureDeleteRemovesWholeSubtree}/{HDF5,Memory}`, `…/RootDeleteRemovesWholeOccurrence/HDF5`. HDF5 used to ignore `path` and widen every call to the whole occurrence — fixed in issue #63, whose HDF5-layout-specific consequences (which files and master-file links survive which granularity, and that a same-prefix sibling is not collateral) are pinned separately in `Hdf5Delete.*`, since they are invisible to this backend-agnostic matrix. The decided meaning of `path` and the per-backend matrix are recorded in `docs/adr/0001-al-delete-data-path-semantics.md`. MDSplus: not joined to this fixture — terminal gap, see Part 4 (issue #18). UDA: remote delete is cleanly refused (the reference plugin has no `deleteData` handler); remote write over the same path falsely reports success instead of the same refusal — xfail, see Part 5 | covered | | ↳ HDF5 honors `path`: a non-empty path deletes that node and its subtree only, an empty path the whole DATAOBJECT (issue #63 — it previously widened every call to the occurrence and left `master.h5` holding a dangling external link) | :265-272; src/hdf5/hdf5_writer.cpp `deleteData` / `deleteSubtree` / `deleteOccurrence` | — | ✓ | `Hdf5Delete.{LeafDeleteKeepsOccurrenceFileAndMasterLink,RootDeleteRemovesOccurrenceFileAndMasterLink,SamePrefixSiblingsSurviveASubtreeDelete,DeletingAnAbsentPathChangesNothing,ManySuccessiveLeafDeletesAllTakeEffect}` — the last one covers the HLI's actual usage (imas-python `delete_children()` issues one delete per DD leaf), which the old code silently no-op'd after the first call closed the IDS group | covered | | ↳ Memory has no code path for DATAOBJECT-root delete — **a root-granularity delete is silently a no-op** | :265-272; src/memory_backend.cpp deleteData (no root/occurrence case) | ✓ | — | `DeleteKnownDefects.DISABLED_MemoryRootDeleteClearsWholeIds` + tripwire `DeleteKnownDefects.MemoryRootDeleteCurrentlyDoesNothing`; matrix cell skips via `Backends/DeleteMatrix.RootDeleteRemovesWholeOccurrence/Memory` | **xfail** | diff --git a/tests/contract/test_hdf5_absolute_paths.cpp b/tests/contract/test_hdf5_absolute_paths.cpp new file mode 100644 index 00000000..ae9809b1 --- /dev/null +++ b/tests/contract/test_hdf5_absolute_paths.cpp @@ -0,0 +1,544 @@ +// HDF5 absolute field-path reads (issue #65). +// +// al_read_data's documented contract (include/al_lowlevel.h, read-data block) +// is that a fieldpath is relative to the passed context, "dataobject absolute +// path can be specified with a prepended '/'". An absolute fieldpath is NOT a +// filesystem path and does NOT carry the IDS name: "/time_slice/.../field" +// addresses the field from the dataobject root. +// +// HDF5Reader::read_ND_Data used to honor only the relative half: it replaced +// every '/' with '&' (the tensorized dataset-name separator) and then +// unconditionally prepended the current array-structure context's tensorized +// path, so an absolute spelling produced a malformed name -- +// time_slice[]&constraints&j_phi[]&&time_slice&constraints&j_phi&reconstructed +// for a dataset actually stored as +// time_slice[]&constraints&j_phi[]&reconstructed +// H5Lexists could not find it, the reader returned 0, and al_plugin_read_data +// filled the caller's buffer with EMPTY_DOUBLE while reporting code 0. Stored +// data was therefore indistinguishable from genuinely absent data -- a silent +// wrong answer, the failure mode this suite exists to catch. +// +// Every test below drives only the public C ABI (decision D1). The fix is a +// pure path/selection change in the HDF5 read path: no DD-version knowledge, +// no schema lookup, no ABI change -- so these tests read the same values back +// through two *spellings* of one address and assert they agree. +// +// Scope, per the issue: HDF5 *reads*. Writes, al_delete_data and +// al_begin_arraystruct_action are deliberately exercised only in their +// relative form here; whether they honor an absolute path is a separate +// question and must be filed as its own finding, not assumed by these tests. +// +// Fixture shape -- every path is a real equilibrium DD-4.1.1 path (the +// equilibrium_seed.h convention, issue #33), validated with the imas-dd tool +// suite; the core still treats them as opaque strings: +// +// vacuum_toroidal_field/r0 FLT_0D root scalar +// ids_properties/version_put/data_dictionary STR_0D root DD stamp +// time FLT_1D homogeneous timebase +// time_slice AOS timebasepath "time" +// time FLT_0D +// global_quantities/ip FLT_0D ancestor-level leaf +// profiles_1d/psi FLT_1D ancestor-level array +// constraints/j_phi AOS nested, multi-segment +// reconstructed FLT_0D the issue's own leaf +// measured FLT_0D +// source STR_0D array leaf (CHAR 1D) +// +// Both AOS levels hold more than one element and every value is distinct per +// (slice, constraint) pair, so a resolution bug that silently reads element +// zero -- the easy way to "fix" this wrongly -- cannot pass. + +#include "al_contract.h" + +#include +#include + +#include + +#include +#include + +using al_contract::PulseId; + +namespace { + +constexpr const char* kIds = "equilibrium"; + +// --- addresses, in both spellings ------------------------------------------- +constexpr const char* kR0 = "vacuum_toroidal_field/r0"; +constexpr const char* kR0Abs = "/vacuum_toroidal_field/r0"; +constexpr const char* kDdStamp = "ids_properties/version_put/data_dictionary"; +constexpr const char* kDdStampAbs = "/ids_properties/version_put/data_dictionary"; +constexpr const char* kTimebase = "time"; +constexpr const char* kTimeSlice = "time_slice"; +constexpr const char* kJPhi = "constraints/j_phi"; // multi-segment AOS +constexpr const char* kIp = "global_quantities/ip"; +constexpr const char* kIpAbs = "/time_slice/global_quantities/ip"; +constexpr const char* kPsi = "profiles_1d/psi"; +constexpr const char* kPsiAbs = "/time_slice/profiles_1d/psi"; +constexpr const char* kReconstructed = "reconstructed"; +constexpr const char* kReconstructedAbs = + "/time_slice/constraints/j_phi/reconstructed"; +constexpr const char* kMeasuredAbs = "/time_slice/constraints/j_phi/measured"; +constexpr const char* kSource = "source"; +constexpr const char* kSourceAbs = "/time_slice/constraints/j_phi/source"; +// Never written by the seed: the "genuinely absent" control. +constexpr const char* kWeightAbs = "/time_slice/constraints/j_phi/weight"; + +constexpr int kNSlices = 3; // > 1 so a nonzero outer cursor is reachable +constexpr int kNJPhi = 3; // > 1 so a nonzero inner cursor is reachable +constexpr int kRadialLen = 4; + +// --- deterministic content, distinct per (slice, constraint) ---------------- +double r0_value() { return 6.2; } +double slice_time(int i) { return 1.0 + 0.5 * i; } +double ip(int i) { return 100.0 + 7.0 * i; } +double reconstructed(int i, int j) { return 47.0 + 10.0 * i + j; } +double measured(int i, int j) { return 200.0 + 10.0 * i + j; } + +std::string source(int i, int j) { + return "src-" + std::to_string(i) + "-" + std::to_string(j); +} +std::string dd_stamp() { return "4.1.1"; } + +std::vector timebase() { + std::vector t; + for (int i = 0; i < kNSlices; ++i) t.push_back(slice_time(i)); + return t; +} +std::vector psi(int i) { + std::vector v; + for (int r = 0; r < kRadialLen; ++r) v.push_back(10.0 * i + r + 0.25); + return v; +} + +// --------------------------------------------------------------------------- +// Fixture: one freshly seeded on-disk HDF5 pulse per test. +// --------------------------------------------------------------------------- +class Hdf5AbsoluteFieldPaths : public ::testing::Test { +protected: + void SetUp() override { + base_.make_legacy_tree(pulse_); + const std::string uri = + al_contract::build_uri(HDF5_BACKEND, base_.str(), pulse_); + ASSERT_FALSE(uri.empty()); + AL_ASSERT_OK(al_begin_dataentry_action(uri.c_str(), FORCE_CREATE_PULSE, + &pctx_)); + ASSERT_NO_FATAL_FAILURE(write_seed()); + } + + void TearDown() override { + if (pctx_ != -1) al_close_pulse(pctx_, CLOSE_PULSE); + } + + // Writes the fixture shape above. Relative spellings only -- the write + // side's absolute handling is out of this issue's scope. + void write_seed() { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx_, kIds, "", WRITE_OP, &op)); + AL_ASSERT_OK(al_contract::write_data(op, kR0, {}, {r0_value()})); + AL_ASSERT_OK(al_contract::write_char_array(op, kDdStamp, dd_stamp())); + AL_ASSERT_OK( + al_contract::write_data(op, kTimebase, {kNSlices}, timebase())); + + int slices = kNSlices; + int ts = -1; + AL_ASSERT_OK( + al_begin_arraystruct_action(op, kTimeSlice, kTimebase, &slices, &ts)); + for (int i = 0; i < kNSlices; ++i) { + AL_ASSERT_OK( + al_contract::write_data(ts, "time", {}, {slice_time(i)})); + AL_ASSERT_OK(al_contract::write_data(ts, kIp, {}, {ip(i)})); + AL_ASSERT_OK( + al_contract::write_data(ts, kPsi, {kRadialLen}, psi(i))); + + int constraints = kNJPhi; + int jp = -1; + AL_ASSERT_OK( + al_begin_arraystruct_action(ts, kJPhi, "", &constraints, &jp)); + for (int j = 0; j < kNJPhi; ++j) { + AL_ASSERT_OK(al_contract::write_data( + jp, kReconstructed, {}, {reconstructed(i, j)})); + AL_ASSERT_OK(al_contract::write_data(jp, "measured", {}, + {measured(i, j)})); + AL_ASSERT_OK(al_contract::write_char_array(jp, kSource, + source(i, j))); + if (j + 1 < kNJPhi) + AL_ASSERT_OK(al_iterate_over_arraystruct(jp, 1)); + } + AL_ASSERT_OK(al_end_action(jp)); + if (i + 1 < kNSlices) AL_ASSERT_OK(al_iterate_over_arraystruct(ts, 1)); + } + AL_ASSERT_OK(al_end_action(ts)); + AL_ASSERT_OK(al_end_action(op)); + } + + // Opens a GLOBAL read, walks the outer AOS to element `slice` and the + // nested AOS to element `constraint`, then hands the body the three live + // contexts (operation, time_slice, constraints/j_phi). + // + // The cursor is advanced element by element, exactly as an HLI drives it; + // that is what makes "the current cursor" a real, nonzero position rather + // than a freshly opened context that happens to sit at zero. + template + void at(int slice, int constraint, Body body) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx_, kIds, "", READ_OP, &op)); + + int slices = 0; + int ts = -1; + AL_ASSERT_OK( + al_begin_arraystruct_action(op, kTimeSlice, kTimebase, &slices, &ts)); + ASSERT_EQ(slices, kNSlices); + for (int i = 0; i < slice; ++i) + AL_ASSERT_OK(al_iterate_over_arraystruct(ts, 1)); + + int constraints = 0; + int jp = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(ts, kJPhi, "", &constraints, &jp)); + ASSERT_EQ(constraints, kNJPhi); + for (int j = 0; j < constraint; ++j) + AL_ASSERT_OK(al_iterate_over_arraystruct(jp, 1)); + + body(op, ts, jp); + + al_end_action(jp); + al_end_action(ts); + al_end_action(op); + } + + // Reads one DOUBLE scalar; returns the status and the value the core left + // in the caller's buffer (EMPTY_DOUBLE when the field is reported absent). + static al_status_t read_scalar(int ctx, const char* field, double* out) { + *out = al_contract::kEmptyDouble; + int size[MAXDIM] = {0}; + void* buf = out; + return al_read_data(ctx, field, "", &buf, DOUBLE_DATA, 0, size); + } + + // The same read through the plugin entry point. al_read_data delegates to + // al_plugin_read_data whenever no plugin is bound to the field + // (al_lowlevel.cpp), so the two normally share this code path -- but the + // issue's independent reproducer reached the core through this symbol + // directly, and a caller that interposes the plugin layer sees only this + // one. Asserting both keeps the pair from silently diverging. + static al_status_t plugin_read_scalar(int ctx, const char* field, double* out) { + *out = al_contract::kEmptyDouble; + int size[MAXDIM] = {0}; + void* buf = out; + return al_plugin_read_data(ctx, field, "", &buf, DOUBLE_DATA, 0, size); + } + + al_contract::TempBase base_; + PulseId pulse_{/*database=*/"test", /*version=*/"3", /*pulse=*/65, /*run=*/0}; + int pctx_ = -1; +}; + +// =========================================================================== +// The issue's headline case: a scalar leaf beneath a nested AOS, addressed +// absolutely from inside that same AOS. +// =========================================================================== +TEST_F(Hdf5AbsoluteFieldPaths, ScalarUnderNestedAosAgreesWithRelative) { + // Nonzero on both AOS levels: an implementation that ignores the cursor + // and reads element (0,0) reads 47.0 and would pass at (0,0) alone. + constexpr int kSlice = 1, kConstraint = 2; + at(kSlice, kConstraint, [&](int, int, int jp) { + double relative = 0.0, absolute = 0.0; + AL_EXPECT_OK(read_scalar(jp, kReconstructed, &relative)); + AL_EXPECT_OK(read_scalar(jp, kReconstructedAbs, &absolute)); + + EXPECT_DOUBLE_EQ(relative, reconstructed(kSlice, kConstraint)) + << "the relative spelling is the control -- if this fails the " + "fixture, not the absolute-path resolution, is wrong"; + EXPECT_DOUBLE_EQ(absolute, reconstructed(kSlice, kConstraint)) + << "an absolute fieldpath must address the same stored datum as " + "the relative one (al_lowlevel.h read-data contract); " + "EMPTY_DOUBLE here is the issue-#65 silent-absence bug"; + }); +} + +// The same assertion through al_plugin_read_data, the entry point the issue's +// independent baseline resolved with dlsym. +TEST_F(Hdf5AbsoluteFieldPaths, ScalarUnderNestedAosAgreesThroughPluginEntryPoint) { + constexpr int kSlice = 1, kConstraint = 2; + at(kSlice, kConstraint, [&](int, int, int jp) { + double relative = 0.0, absolute = 0.0; + AL_EXPECT_OK(plugin_read_scalar(jp, kReconstructed, &relative)); + AL_EXPECT_OK(plugin_read_scalar(jp, kReconstructedAbs, &absolute)); + EXPECT_DOUBLE_EQ(relative, reconstructed(kSlice, kConstraint)); + EXPECT_DOUBLE_EQ(absolute, reconstructed(kSlice, kConstraint)) + << "al_plugin_read_data must resolve an absolute fieldpath exactly " + "as al_read_data does"; + }); +} + +// Every (slice, constraint) pair, so the resolved AOS selection is shown to +// track the live cursor on both levels rather than matching at one position. +TEST_F(Hdf5AbsoluteFieldPaths, AbsoluteReadTracksBothCursors) { + for (int i = 0; i < kNSlices; ++i) { + for (int j = 0; j < kNJPhi; ++j) { + at(i, j, [&](int, int, int jp) { + double absolute = 0.0, absolute_measured = 0.0; + AL_EXPECT_OK(read_scalar(jp, kReconstructedAbs, &absolute)); + AL_EXPECT_OK(read_scalar(jp, kMeasuredAbs, &absolute_measured)); + EXPECT_DOUBLE_EQ(absolute, reconstructed(i, j)) + << "slice=" << i << " constraint=" << j; + EXPECT_DOUBLE_EQ(absolute_measured, measured(i, j)) + << "slice=" << i << " constraint=" << j; + }); + } + } +} + +// =========================================================================== +// Array leaf beneath the nested AOS: rank and extent must survive resolution, +// not just the first element. +// =========================================================================== +TEST_F(Hdf5AbsoluteFieldPaths, ArrayLeafUnderNestedAosAgreesWithRelative) { + constexpr int kSlice = 2, kConstraint = 1; + at(kSlice, kConstraint, [&](int, int, int jp) { + std::string relative, absolute; + AL_EXPECT_OK(al_contract::read_char_array(jp, kSource, &relative)); + AL_EXPECT_OK(al_contract::read_char_array(jp, kSourceAbs, &absolute)); + + EXPECT_EQ(relative, source(kSlice, kConstraint)); + EXPECT_EQ(absolute, source(kSlice, kConstraint)) + << "a CHAR array leaf must resolve identically through both " + "spellings, extent included"; + }); +} + +// =========================================================================== +// Ancestor targets: the retained selection must be the ancestor's, not the +// child's. Applying the full child cursor to an ancestor-level dataset is the +// other way to get this wrong. +// =========================================================================== +TEST_F(Hdf5AbsoluteFieldPaths, AncestorScalarKeepsOnlyTheAncestorCursor) { + constexpr int kSlice = 2, kConstraint = 1; + at(kSlice, kConstraint, [&](int, int ts, int jp) { + double from_parent = 0.0, from_child = 0.0; + // Same datum, reached two ways: relatively from the time_slice + // context, and absolutely from one level deeper. + AL_EXPECT_OK(read_scalar(ts, kIp, &from_parent)); + AL_EXPECT_OK(read_scalar(jp, kIpAbs, &from_child)); + + EXPECT_DOUBLE_EQ(from_parent, ip(kSlice)); + EXPECT_DOUBLE_EQ(from_child, ip(kSlice)) + << "an ancestor-level field addressed from a child context must " + "keep the time_slice index and drop the constraints/j_phi one"; + }); +} + +TEST_F(Hdf5AbsoluteFieldPaths, AncestorArrayKeepsOnlyTheAncestorCursor) { + constexpr int kSlice = 1, kConstraint = 2; + at(kSlice, kConstraint, [&](int, int ts, int jp) { + std::vector parent_shape, child_shape; + std::vector parent_data, child_data; + AL_EXPECT_OK( + al_contract::read_data(ts, kPsi, 1, &parent_shape, &parent_data)); + AL_EXPECT_OK(al_contract::read_data(jp, kPsiAbs, 1, &child_shape, + &child_data)); + + ASSERT_EQ(parent_shape, std::vector{kRadialLen}); + EXPECT_EQ(parent_data, psi(kSlice)); + EXPECT_EQ(child_shape, std::vector{kRadialLen}) + << "the absolute read must report the ancestor array's real extent"; + EXPECT_EQ(child_data, psi(kSlice)); + }); +} + +// =========================================================================== +// Root targets: no AOS index applies at all. Reached from the deepest context +// (both cursors live) and from the plain operation context. +// =========================================================================== +TEST_F(Hdf5AbsoluteFieldPaths, RootScalarReadableFromNestedContext) { + constexpr int kSlice = 2, kConstraint = 2; + at(kSlice, kConstraint, [&](int op, int ts, int jp) { + double from_op = 0.0, from_slice = 0.0, from_constraint = 0.0; + AL_EXPECT_OK(read_scalar(op, kR0, &from_op)); + AL_EXPECT_OK(read_scalar(ts, kR0Abs, &from_slice)); + AL_EXPECT_OK(read_scalar(jp, kR0Abs, &from_constraint)); + + EXPECT_DOUBLE_EQ(from_op, r0_value()); + EXPECT_DOUBLE_EQ(from_slice, r0_value()) + << "a root field must stay readable from inside an AOS"; + EXPECT_DOUBLE_EQ(from_constraint, r0_value()) + << "... and from inside a nested AOS, with neither index applied"; + }); +} + +// The DD stamp is an ordinary root string field (the core attaches no meaning +// to it) -- and it is exactly what a version-agnostic reader asks for first, +// from whatever context it happens to hold. +TEST_F(Hdf5AbsoluteFieldPaths, RootDdStampReadableFromNestedContext) { + at(1, 1, [&](int op, int, int jp) { + std::string from_op, from_constraint; + AL_EXPECT_OK(al_contract::read_char_array(op, kDdStamp, &from_op)); + AL_EXPECT_OK(al_contract::read_char_array(jp, kDdStampAbs, &from_constraint)); + EXPECT_EQ(from_op, dd_stamp()); + EXPECT_EQ(from_constraint, dd_stamp()); + }); +} + +// An absolute spelling used from the operation context itself: no AOS is open, +// so the leading '/' is the only thing to strip. This is the same defect with +// an empty context chain, and it must not regress the relative form. +TEST_F(Hdf5AbsoluteFieldPaths, AbsoluteAndRelativeAgreeAtRootContext) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx_, kIds, "", READ_OP, &op)); + + double relative = 0.0, absolute = 0.0; + AL_EXPECT_OK(read_scalar(op, kR0, &relative)); + AL_EXPECT_OK(read_scalar(op, kR0Abs, &absolute)); + EXPECT_DOUBLE_EQ(relative, r0_value()); + EXPECT_DOUBLE_EQ(absolute, r0_value()) + << "at the operation context an absolute fieldpath differs from the " + "relative one only by its leading '/'"; + + std::vector shape; + std::vector data; + AL_EXPECT_OK( + al_contract::read_data(op, "/time", 1, &shape, &data)); + EXPECT_EQ(shape, std::vector{kNSlices}); + EXPECT_EQ(data, timebase()) << "the root timebase array too"; + + al_end_action(op); +} + +// =========================================================================== +// Ordinary missing-data semantics must be untouched: the fix makes stored data +// findable, it must not make absent data look present. +// =========================================================================== +TEST_F(Hdf5AbsoluteFieldPaths, GenuinelyAbsentFieldsStayAbsent) { + constexpr int kSlice = 1, kConstraint = 1; + at(kSlice, kConstraint, [&](int op, int, int jp) { + // Never written, addressed absolutely... + double absent_abs = 0.0; + AL_EXPECT_OK(read_scalar(jp, kWeightAbs, &absent_abs)); + EXPECT_DOUBLE_EQ(absent_abs, al_contract::kEmptyDouble) + << "an unwritten field must still report the absent sentinel"; + + // ... and relatively, the control. + double absent_rel = 0.0; + AL_EXPECT_OK(read_scalar(jp, "weight", &absent_rel)); + EXPECT_DOUBLE_EQ(absent_rel, al_contract::kEmptyDouble); + + // A root-level path that does not exist at all. + double absent_root = 0.0; + AL_EXPECT_OK(read_scalar(jp, "/no_such_structure/no_such_field", + &absent_root)); + EXPECT_DOUBLE_EQ(absent_root, al_contract::kEmptyDouble); + + // An absolute path whose leading segment matches the open AOS but + // whose remainder does not exist: this must resolve and come back + // absent, not accidentally alias a sibling that does exist. + double absent_sibling = 0.0; + AL_EXPECT_OK(read_scalar(jp, "/time_slice/global_quantities/no_such_leaf", + &absent_sibling)); + EXPECT_DOUBLE_EQ(absent_sibling, al_contract::kEmptyDouble); + + double still_there = 0.0; + AL_EXPECT_OK(read_scalar(op, kR0, &still_there)); + EXPECT_DOUBLE_EQ(still_there, r0_value()) + << "the absent lookups must not poison neighbouring reads"; + }); +} + +// =========================================================================== +// The decided limit of the rule, pinned so it reads as a decision rather than +// an accident (docs/adr/0002-absolute-field-path-resolution.md). +// =========================================================================== +// An absolute path is resolved against the AOS chain the caller has actually +// opened. A target that crosses an AOS the caller never entered has no cursor +// to be read at, and the core holds no schema that would even tell it that +// segment IS an array of structures -- so the name resolves without that +// level's "[]", finds nothing, and is reported absent. The alternative would be +// to invent an index, which is how a wrong number gets returned instead of no +// number at all. +TEST_F(Hdf5AbsoluteFieldPaths, TargetBeyondAnUnopenedAosIsReportedAbsent) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx_, kIds, "", READ_OP, &op)); + int slices = 0, ts = -1; + AL_ASSERT_OK( + al_begin_arraystruct_action(op, kTimeSlice, kTimebase, &slices, &ts)); + ASSERT_EQ(slices, kNSlices); + + // constraints/j_phi is never opened here, so this names no single datum. + double beyond = 0.0; + AL_EXPECT_OK(read_scalar(ts, kReconstructedAbs, &beyond)); + EXPECT_DOUBLE_EQ(beyond, al_contract::kEmptyDouble) + << "with no cursor for constraints/j_phi the read must report absent, " + "not silently pick an element"; + + // The ancestor level that *is* open still resolves, at its own cursor. + double ancestor = 0.0; + AL_EXPECT_OK(read_scalar(ts, kIpAbs, &ancestor)); + EXPECT_DOUBLE_EQ(ancestor, ip(0)); + + al_end_action(ts); + al_end_action(op); +} + +// =========================================================================== +// Dataset-name caching: the reader memoizes both "this dataset exists" and the +// open dataset handle under the resolved name. Repeating the reads in mixed +// order, and re-reading after the cursor moves, proves the cache is keyed on +// the *resolved* name and not on the spelling the caller used. +// =========================================================================== +TEST_F(Hdf5AbsoluteFieldPaths, RepeatedMixedSpellingsStayConsistent) { + constexpr int kSlice = 2, kConstraint = 0; + at(kSlice, kConstraint, [&](int, int, int jp) { + const double expected = reconstructed(kSlice, kConstraint); + for (int repeat = 0; repeat < 3; ++repeat) { + double absolute = 0.0, relative = 0.0; + AL_EXPECT_OK(read_scalar(jp, kReconstructedAbs, &absolute)); + AL_EXPECT_OK(read_scalar(jp, kReconstructed, &relative)); + EXPECT_DOUBLE_EQ(absolute, expected) << "repeat " << repeat; + EXPECT_DOUBLE_EQ(relative, expected) << "repeat " << repeat; + } + + // An absent absolute lookup is cached too; it must not turn a later + // present one into a miss. + double absent = 0.0; + AL_EXPECT_OK(read_scalar(jp, kWeightAbs, &absent)); + EXPECT_DOUBLE_EQ(absent, al_contract::kEmptyDouble); + + double after = 0.0; + AL_EXPECT_OK(read_scalar(jp, kReconstructedAbs, &after)); + EXPECT_DOUBLE_EQ(after, expected); + }); +} + +// Within one open AOS context, advancing the cursor must move what an absolute +// path resolves to -- the cached dataset handle is shared across iterations, +// so this is where a name/selection mix-up would surface. +TEST_F(Hdf5AbsoluteFieldPaths, AbsoluteReadFollowsIterationWithinOneContext) { + int op = -1; + AL_ASSERT_OK(al_begin_global_action(pctx_, kIds, "", READ_OP, &op)); + int slices = 0, ts = -1; + AL_ASSERT_OK( + al_begin_arraystruct_action(op, kTimeSlice, kTimebase, &slices, &ts)); + ASSERT_EQ(slices, kNSlices); + + for (int i = 0; i < kNSlices; ++i) { + int constraints = 0, jp = -1; + AL_ASSERT_OK(al_begin_arraystruct_action(ts, kJPhi, "", &constraints, &jp)); + ASSERT_EQ(constraints, kNJPhi); + for (int j = 0; j < kNJPhi; ++j) { + double absolute = 0.0, ancestor = 0.0; + AL_EXPECT_OK(read_scalar(jp, kReconstructedAbs, &absolute)); + AL_EXPECT_OK(read_scalar(jp, kIpAbs, &ancestor)); + EXPECT_DOUBLE_EQ(absolute, reconstructed(i, j)) + << "slice=" << i << " constraint=" << j; + EXPECT_DOUBLE_EQ(ancestor, ip(i)) + << "slice=" << i << " constraint=" << j; + if (j + 1 < kNJPhi) AL_EXPECT_OK(al_iterate_over_arraystruct(jp, 1)); + } + al_end_action(jp); + if (i + 1 < kNSlices) AL_EXPECT_OK(al_iterate_over_arraystruct(ts, 1)); + } + al_end_action(ts); + al_end_action(op); +} + +} // namespace