Repository navigation
append_step: record a timestep without rewriting the document - #18
Conversation
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>
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>
|
Noted on top of this (commit 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 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: Not done here: it is a format version, it changes both ends of the reader and writer, and |
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>
Groundwork for proteus#86, which keeps the
.ymfcurrent 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. Optionalfsync.read_ymffolds appended records back into their collections, so both shapes read identically: one document (whatwrite_ymfproduces, 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 aRuntimeWarning. 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)
---, a bare---, and during the very first step before any marker exists) — each loses only the step in flight;simplefilter("error");10.0than0.0) while the file grows past 10× that, so the write does not grow with what the archive holds.🤖 Generated with Claude Code