From 3de596025a42481020e9f6a46bdb25f178d20ae1 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] 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. (cherry picked from commit 858a7a2457d55f0105de90e0ad34043be596514d) --- .../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 7d01a395..890810ff 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`, `RoundTripKnownDefectsDeath.Ascii{Double,Complex}MaxdimCurrentlyCorrupts` (death-test children: corruption *or* crash keeps them green — the overrun's manifestation is machine-dependent) | **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