Skip to content

append_step: record a timestep without rewriting the document - #18

Merged
cekees merged 1 commit into
mainfrom
appendable-archive
Oct 23, 2026
Merged

cekees merged 1 commit into
mainfrom
appendable-archive

Conversation

@cekees

@cekees cekees commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Groundwork for proteus#86, which keeps the .ymf current during a run. ymf merges first — proteus CI installs ymf from @main.

The problem

A writer producing one timestep at a time had to rewrite the whole document to record it: O(steps) per step, O(steps²) over a run. Measured on a one-collection archive — 0.6 ms at 10 steps, 6.8 ms at 100, 35 ms at 500, so a 500-step run spends 8.6 s in it and a 5,000-step run would spend minutes.

That requirement is inherited from XML, and YAML does not have it. A stream of documents separated by --- is valid after any complete document.

The change

  • append_step(path, collection, step) — opens in append mode, writes one record, flushes. Optional fsync.
  • read_ymf folds appended records back into their collections, so both shapes read identically: one document (what write_ymf produces, and what a finished run leaves), or that document followed by one per step (what a run in progress, or a killed one, leaves).

A record names its collection rather than indexing the header's list, because collections are created lazily — a model that first archives at step 40 adds one then, and an index agreed at step 0 would be wrong by then.

The part that needed care

A truncated YAML document usually still parses. Cutting inside a mapping leaves a shorter mapping, so completeness cannot be judged by parsing — an early draft here read a half-written step back as a real one, silently.

So each record ends with ..., YAML's explicit document-end marker, written last. Anything after the final marker is a partial write, dropped with a RuntimeWarning. Damage earlier in the file is raised rather than truncated, because treating it as a killed writer would hide real corruption.

Tests (10 new; suite 471 passed, 6 skipped)

  • appending produces the same domain as writing the whole document;
  • a collection appearing after the header;
  • a kill at four cut points (mid-mapping, just after ---, a bare ---, and during the very first step before any marker exists) — each loses only the step in flight;
  • a complete archive raises no warning, under simplefilter("error");
  • corruption mid-file raises rather than silently truncating;
  • the cost itself, asserted on bytes written per step — deterministic where timing is not. The spread is bounded at ≤8 bytes (the time value's text is wider at 10.0 than 0.0) while the file grows past 10× that, so the write does not grow with what the archive holds.

🤖 Generated with Claude Code

A writer that produces one timestep at a time had to rewrite the whole
.ymf to record it. That is O(steps) per step and O(steps^2) over a run:
on a one-collection archive, writing is 0.6 ms at 10 steps, 6.8 ms at 100
and 35 ms at 500, so a 500-step run spends 8.6 s in it and a 5,000-step
run would spend minutes.

The requirement that a document be rewritten to stay well formed is
inherited from XML, and YAML does not have it. A stream of documents
separated by --- is valid after any complete document, so a step can be
appended and flushed. append_step does that; read_ymf folds the appended
records back into their collections, so the two shapes -- one document, or
that document plus one per step -- read identically.

A record names its collection rather than indexing the header's list,
because collections are created lazily: a model that first archives at
step 40 adds one then, and an index agreed at step 0 would be wrong by
then.

The part that needed care is detecting a killed writer. A truncated YAML
document usually still *parses* -- cutting inside a mapping leaves a
shorter mapping -- so completeness cannot be judged by parsing it. An
early draft here read a half-written step back as a real one. So each
record ends with "...", YAML's explicit document-end marker, written last;
anything after the final marker is a partial write and is dropped with a
warning. Damage earlier in the file is not treated as truncation, because
that would hide real corruption.

Ten tests, including: appending equals writing the whole document; a
collection appearing after the header; a kill at four different cut points
losing only the step in flight; corruption mid-file raising rather than
truncating; and the cost itself, asserted on bytes written per step, which
is deterministic where timing is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cekees referenced this pull request in cekees/proteus Oct 23, 2026
Review of the first version: rewriting the whole document to record a step
is O(steps) per step, so O(steps^2) over a run, and many proteus runs have
very large numbers of steps. That requirement is inherited from XML, where
a document has to be closed to be well formed. YAML has no such rule, so a
step can be appended and flushed.

Measured on a one-collection archive, per-step write and cumulative:

    steps     rewrite      append
       10      0.6 ms     0.10 ms
      100      6.8 ms     0.11 ms
      500     35.0 ms     0.09 ms
     5000   ~350   ms     0.11 ms

    5000 steps:  ~15 min (extrapolated)  vs  0.49 s

Flat per step, as it should be: the write no longer depends on what the
archive already holds.

This needs ymf's append_step and its stream-aware read_ymf (cekees/ymf#18),
which must merge first -- proteus CI installs ymf from @main. There the
document grows as a stream: a header, then one document per step naming its
collection. Naming rather than indexing matters because proteus creates
collections lazily, one per archived FE space, so a model that first
archives at step 40 adds one then.

close() still writes the canonical single document from the HDF5, so a run
that finishes leaves exactly what it left before; only an interrupted run
is in the live shape, and read_ymf takes both.

The cost accounting and the slow-write warning the first version added are
gone with the cost they measured.

Two tests pin the mechanism rather than the outcome: that the mid-run file
really is a stream, one record per step with each one completed, and that
close collapses it so nothing downstream needs to know the live shape
exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cekees
cekees merged commit f1868bb into main Oct 23, 2026
7 checks passed
@cekees
cekees deleted the appendable-archive branch October 23, 2026 03:23
@cekees

cekees commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Noted on top of this (commit docs/archive-format.md), because this PR entrenches the current nesting:

Time and space may be nested the wrong way round. The document has one temporal collection per function space. That is XDMF's shape — a Temporal GridCollection belongs to a grid type. The transpose, one time collection with the spaces inside each instant, is likely the better model.

The objection is operator splitting, and it does not hold: a fractional-step calculation has synchronization points where every space is known, and those are the logical archive points anyway; where it does not, there are rational ways to write every space at the finest-grained step. More to the point, the archiving step is a separate choice from the numerical step — writing every space at every step it was approximated at is usually not affordable — so the archive already samples at instants, which is exactly what the transposed shape describes.

Relevant to this PR specifically: append_step has to name a collection in every record only because collections appear lazily as models first archive. Under the transposed shape that problem does not exist — one record per instant, no names needed. So this PR's design is partly a workaround for the nesting, and would get simpler if the nesting were inverted.

Not done here: it is a format version, it changes both ends of the reader and writer, and ymf2xmf would transpose on the way out.

cekees referenced this pull request in cekees/proteus Oct 23, 2026
The import sat outside the guard, so a proteus newer than its ymf raised
out of the middle of a timestep instead of losing its live document --
the one failure mode the writer was built to absorb.

CI found it the honest way. Three legs of run 39894170430 resolved
ymf@main at 03:18, five minutes before cekees/ymf#18 merged at 03:23;
the macOS legs resolved it at 04:25 and passed. The provenance probe
this workflow prints made that a two-minute diagnosis rather than a
hunt, but the skew it exposed is real for anyone installing the two
repositories separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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