Skip to content

feat: cross-language symmetry — deferred Wave B (frame walker, sync remote, typed enums) - #167

Open
tlmquintino wants to merge 9 commits into
feat/symmetry-wave-afrom
feat/symmetry-wave-b-deferred
Open

feat: cross-language symmetry — deferred Wave B (frame walker, sync remote, typed enums)#167
tlmquintino wants to merge 9 commits into
feat/symmetry-wave-afrom
feat/symmetry-wave-b-deferred

Conversation

@tlmquintino

@tlmquintino tlmquintino commented Jul 28, 2026

Copy link
Copy Markdown
Member

Implements the deferred Wave-B plan (plans/INTERFACE_SYMMETRY.md §9). Stacked on #164 — GitHub will retarget this to main when that merges. Built strictly TDD: every capability's test was written and observed failing before its implementation.

A · Frame walker + typed message header — all six bindings

Structural introspection of one message: walk its frames and read its envelope without decoding payloads or CBOR.

  • Rust core (new)frames(msg) -> FrameIter (lazy) yielding FrameInfo { frame_type, version, flags, offset, length, payload } + has_hash(); message_header(msg) -> MessageHeader (version, total_length, eight typed predicates).
  • Ctgm_frame_type, TgmFrame/TgmMessageHeader, tgm_frame_iter_create/_next/_free, tgm_frame_has_hash, tgm_message_header.
  • C++enum class frame_type, frame, lazy frame_range (range-for via a C++17 sentinel), frames(), read_message_header().
  • Pythonframes() / message_header(), Frame, FrameIter, MessageHeader.
  • TypeScriptframes(), messageHeader() (new wasm frame_walk module).
  • Fortrantensogram_frames(), tensogram_frame_iterator, tensogram_frame, tensogram_message_header_read().

Contract: only the type 1–3/5–9 frames are walked (4 is reserved; the preamble/postamble are not frames — use the header). offset is message-relative (1-based in Fortran, 0-based elsewhere); payload is frame content (16-byte header and 20 B/12 B type-specific footer stripped). C/C++ borrow the caller's buffer; Python/TS/Fortran copy. A false/end from the C iterator is disambiguated via tgm_last_error (clean end vs malformed).

B · Synchronous remote access — C, C++, Fortran

Previously only the async C API could open a remote .tgm. Now tgm_is_remote_url + tgm_file_open_remote(source, keys[], values[], n, opts, out) returns a plain sync file handle, so the existing file API works unchanged on S3/HTTP sources; mirrored as C++ file::open_remote and Fortran tensogram_file_open_remote.

Opt-in remote Cargo feature — deliberately NOT in the default published C-API tarball (same model as async); the header declares the symbols either way and a feature-off build returns TGM_ERROR_REMOTE naming --features=remote. C++ exposes -DTENSOGRAM_REMOTE=ON (default OFF). Both C++ and Fortran verified the key/value marshalling against a live file:// backend with the feature on.

C · Typed enums + full encode options

tgm_dtype (15), tgm_byte_order, tgm_aggregate_hash_policy, tgm_compression_backend (each enum's zero value is the library default, so a zero-initialised C struct is correct), tgm_object_dtype_enum / _byte_order_enum (string getters unchanged), and TgmEncodeOptions exposing the previously-unreachable aggregate_hash and compression_backend. Python gained compression_backend=; TS gained aggregateHash (and documents compressionBackend as a WASM no-op — pure-Rust codecs only).

Drift protection: exhaustive-match Rust tests (a new core variant breaks the build) plus header/Fortran mirror guards, wired into ctest.

Notable design calls made during implementation

  • C++ deliberately has no *_with_encode_options overloads — they'd be signature-identical to the existing entry points. Those now escalate to the new C functions when the new fields are set. encode_pre_encoded throws rather than emitting a message contradicting options the C ABI cannot honour there.
  • Fortran's iterator copies the message buffer: the C cursor borrows for its whole life, which no Fortran dummy argument can promise (a non-contiguous actual dies with its temporary). Sound by construction, and callers need no target attribute.
  • TS Frame.payload is copied into the JS heap — a view would dangle, since wasm-bindgen frees the argument allocation on return.

Finding surfaced by TDD (not fixed here)

The streaming encoder sets the PRECEDER_METADATA preamble flag unconditionally and advisorily (it cannot know at preamble-write time whether preceders follow) and may leave total_length at 0 — but plans/WIRE_FORMAT.md §3.1 states that flag definitively. Implementation and spec disagree. The implementation is deliberate and documented in code, so I documented the actual behaviour in the guide and left the wire spec untouched — it needs a spec-owner decision. Tests encode the true contract: buffered messages match exactly, streaming only guarantees frame present ⇒ flag set.

Docs

New docs/src/guide/frame-introspection.md (language-neutral contract + a snippet per language), sections added to the c/cpp/python/typescript/fortran API guides (the C-API guide calls out the opt-in remote caveat prominently), a Rust runnable example closing the last example gap, and a CHANGELOG [Unreleased] entry.

Verification (full matrix, all green)

Rust core 746 · workspace 69 suites, 0 failures · FFI 272 / 273 (remote) / 281 (all-features) · C++ ctest 336/336 (+59 with remote ON) · Fortran ctest 41/41, f2008 -Werror clean, valgrind clean · TS 519 vitest · wasm 193 · Python 867 passed / 52 skipped · cargo-c-header-check no drift (header byte-identical across feature sets) · clippy 0 warnings · rust-fmt clean · make docs-build clean.

Docs Preview
https://sites.ecmwf.int/docs/tensogram/pull-requests/PR-167

Deferred Wave B, component A0 — the reference implementation every binding
mirrors (plans/INTERFACE_SYMMETRY.md §9).

- frames(message) -> FrameIter: lazy, per-message walk of the type 1-9 frames.
  Each FrameInfo carries {frame_type, version, flags, offset, length} plus a
  borrowed content payload (frame header + type-specific footer stripped);
  offset is relative to the message start. has_hash() exposes the per-frame
  HASH_PRESENT bit.
- message_header(message) -> MessageHeader: the preamble as typed values
  (version, total_length) plus the eight structural predicates.
- The walk is bounded to the frame region so the postamble is never mistaken
  for a frame, including streaming messages whose total_length was never
  back-filled (detected via the trailing END_MAGIC).

TDD: tests written first (red), then implemented. 8 tests; core suite 746.
Deferred Wave B, component A1 — wrap the core frame walker in the C ABI:
- tgm_frame_type enum (1-9, 4 reserved) + cbindgen rename.
- TgmFrame POD {frame_type, version, flags, offset, length, payload, payload_len}
  and TgmMessageHeader POD (version, total_length + 8 typed booleans).
- Lazy iterator tgm_frame_iter_create/_next/_free (opaque handle borrowing the
  caller's message) + tgm_frame_has_hash + tgm_message_header.
- Lifetime contract documented on the handle, create, and TgmFrame: payload
  points INTO the caller's msg and outlives the iterator; msg must outlive it.
- tgm_frame_iter_next clears the sticky last-error on clean exhaustion so
  callers can distinguish 'end' from 'malformed' via tgm_last_error.
- New check_frame_type_enum.sh guard (C header vs core FrameType) wired into
  the Fortran ctest suite alongside the error/value-type guards.

TDD: 3 red->green cycles, tests written first. +18 tests (229 -> 247); clippy
clean; header regenerated with no cargo-c drift.
…(A4 + C)

Deferred Wave B, Python lane:
- frames(buf) -> FrameIter yielding Frame {frame_type (name), frame_type_code,
  version, flags, offset, length, payload, has_hash, is_data_object}.
- message_header(buf) -> MessageHeader (version, total_length, flags + the
  eight has_* predicates, keeping the core predicate names).
- compression_backend= ('auto'|'ffi'|'pure') on encode / append /
  StreamingEncoder / AsyncStreamingEncoder. Note: aggregate_hash= turned out to
  be already wired; it is now covered by tests and documented.

TDD: tests written first (81 failed red -> 94 green), plus a second red cycle
for FrameIter.__len__. +96 tests; suite 867 passed / 52 skipped; ruff clean.
Deferred Wave B, TypeScript/WASM lane:
- frames(buf) -> Frame[] and messageHeader(buf) -> MessageHeader via a new
  wasm frame_walk module; Frame payloads are copied into the JS heap (a view
  would dangle — wasm-bindgen frees the argument allocation on return).
- Returns an array, not a generator: the wasm call materialises the whole walk,
  so a malformed chain throws rather than silently yielding fewer frames.
- EncodeOptions/AppendOptions gain aggregateHash (forwarded) and
  compressionBackend (validated, documented as a WASM platform no-op —
  pure-Rust codecs only, mirroring the accepted [L] exception for threads).

TDD: tests written first (27 failed red -> green), plus a second red cycle for
append forwarding. +33 vitest (486 -> 519) and +10 wasm tests (183 -> 193).
Deferred Wave B, components B and C.

B — synchronous remote (new opt-in 'remote' feature; does NOT imply async):
- tgm_is_remote_url, TgmRemoteScanOptions {bidirectional},
  tgm_file_open_remote(source, keys[], values[], n, opts, out) returning a plain
  tgm_file_t so the existing sync file API works on remote sources.
- Argument validation runs BEFORE the feature check, so every build answers
  INVALID_ARG identically; feature-off stubs return TGM_ERROR_REMOTE with a
  message naming --features=remote. Symbols always link.

C — typed enums + encode options:
- tgm_dtype (15), tgm_byte_order, tgm_aggregate_hash_policy,
  tgm_compression_backend; each enum's zero value is the library default so a
  zero-initialised C struct behaves correctly.
- tgm_object_dtype_enum / tgm_object_byte_order_enum (string getters unchanged).
- TgmEncodeOptions POD (hash + aggregate_hash + compression_backend + mask
  fields) with *_with_encode_options entry points; TgmEncodeMaskOptions and all
  existing entry points untouched.
- Drift protection: exhaustive-match Rust tests (a new core variant breaks the
  build) plus check_dtype_enum.sh for the checked-in generated header.

Closed loop: aggregate_hash=BOTH verified via the new frame walker (both
HEADER_HASH and FOOTER_HASH frames present). TDD: 7 red->green cycles.
+34 tests (247 -> 281 all-features); clippy clean; header byte-identical across
default/remote/all-features; no cargo-c drift.
…2 + B + C)

Deferred Wave B, C++ lane — wrap the widened C ABI:
- A2: enum class frame_type, a value-type frame, and a lazy frame_range with an
  input iterator + C++17 sentinel end(); frames(msg,len) and
  read_message_header(). The range owns the cursor (RAII, movable without
  invalidating live iterators); payload borrows the caller's buffer and stays
  valid after the range dies (asserted by test). A false from the C iterator is
  disambiguated via tgm_last_error: clean end stops the loop, malformed throws
  framing_error.
- B: is_remote_url + file::open_remote(source, storage_options, opts) returning
  a plain file. Adds NUL-embedding validation C++ can actually get wrong (a
  truncated C string would silently open a different object). Wired
  TENSOGRAM_REMOTE into cpp/CMakeLists (default OFF); verified both states.
- C: enum class dtype/byte_order/aggregate_hash_policy/compression_backend,
  decoded_object::dtype_enum()/byte_order_enum() (throwing on OOB rather than
  returning the indistinguishable zero variant), and encode_options gaining
  aggregate_hash + codec_backend.

Deliberate deviation: no *_with_encode_options C++ overloads — they would be
signature-identical to the existing entry points. Those now escalate to the new
C functions when the new fields are set, matching the header's existing
three-tier ladder. encode_pre_encoded throws instead of emitting a message that
contradicts options the C ABI cannot honour there.

TDD: 5 red->green cycles (incl. one runtime red). +57 tests; cpp ctest 336/336,
and 59/59 of the new tests with -DTENSOGRAM_REMOTE=ON (real file:// round-trip).
…s (A3 + B + C)

Deferred Wave B, Fortran lane — bind the widened C ABI:
- A3: tensogram_frame (value type; fields and payload bytes copied out),
  tensogram_frame_iterator (%next/%free, non-copyable + final), and
  tensogram_message_header (version/total_length + the eight has_*), via
  tensogram_frames() and tensogram_message_header_read().
  The C cursor borrows its buffer for its whole life, which no Fortran dummy
  can promise (a non-contiguous actual dies with its temporary), so the
  iterator copies the buffer and lends the cursor that — sound by
  construction, and callers need no target attribute. %offset() is 1-based,
  matching tensogram_scan. End vs malformed is disambiguated through the
  optional err (OK vs FRAMING), with INVALID_ARG for a freed cursor.
- B: tensogram_is_remote_url and a generic tensogram_file_open_remote (with or
  without storage-option key/value arrays — 'keys without values' is a
  compile-time error), returning a plain tensogram_file.
- C: tensogram_object_dtype_enum/_byte_order_enum (string getters untouched)
  and *_with_options entry points exposing every TgmEncodeOptions knob as
  optional arguments; TGM_DTYPE_*/BYTE_ORDER_*/AGGREGATE_HASH_POLICY_*/
  COMPRESSION_BACKEND_*/FRAME_TYPE_* params.
- One parameterised check_enum_mirror.sh guard registered 5x replaces five
  near-identical scripts.

TDD: 5 red->green cycles plus mutation teeth-checks (break the source, watch the
right test fail, restore). +11 ctest entries (30 -> 41), 401 assertions;
valgrind clean; f2008 -Werror clean; test_remote re-run against a
--features remote C library gives 28/28 with a live file:// round-trip.
New docs/src/guide/frame-introspection.md (wired into SUMMARY.md beside the
metadata guide): the language-neutral contract — what counts as a frame (types
1-3 and 5-9; 4 is reserved), preamble/postamble are not frames, the one-message
rule with the scan-then-slice pattern, offsets/spans/payload boundaries (20 B vs
12 B footer), the per-binding borrow-vs-copy lifetime table, the
buffered-exact vs streaming-advisory header-flag rule, end-of-walk vs malformed
chain, and a snippet per language.

Per-language guides updated: c-api.md gains the opt-in "remote" feature (with
the not-in-the-published-tarball caveat), frame introspection, and the typed
enums / TgmEncodeOptions; cpp/python/typescript/fortran guides gain their
corresponding sections. CHANGELOG [Unreleased] records all three capabilities.

Notable corrections found while writing: streaming also emits a HeaderMetadata
frame, so has_header_index (not has_header_metadata) discriminates
random-access; Python frames() walks eagerly and materialises lazily; TS
aggregateHash/compressionBackend exist on encode/append only.
Closes the last example gap for the frame walker (C++/Python/TS/Fortran already
had one), per the symmetry Examples contract. Reads the envelope, walks the
frames of a buffered vs a streaming message, and verifies aggregate_hash
placement with the walker itself. Also demonstrates two documented nuances:
has_header_index (not has_header_metadata) discriminates random access, and a
streaming message reports preceder=true with no PrecederMetadata frame.
@tlmquintino
tlmquintino requested review from sametd and tmi as code owners July 28, 2026 22:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant