Skip to content

BMI Formulation Serialization and Deserialization implemented via a bmi::protocol - #957

Open
hellkite500 wants to merge 24 commits into
NOAA-OWP:masterfrom
hellkite500:feat/bmi-serialization-protocol
Open

BMI Formulation Serialization and Deserialization implemented via a bmi::protocol#957
hellkite500 wants to merge 24 commits into
NOAA-OWP:masterfrom
hellkite500:feat/bmi-serialization-protocol

Conversation

@hellkite500

Copy link
Copy Markdown
Contributor

BMI State Serialization and Deserialization Protocol.
This is an opt-in convention that lets a BMI model participate in ngen's state checkpoint/restore workflow by exposing four reserved BMI variables (ngen::serialization_create, _free, _size, _state). The engine side is a pair of sibling protocols

  • NgenSerializationProtocol (save)
  • NgenDeserializationProtocol (restore)

registered under NgenBmiProtocols on every BMI formulation. Records share a single length-prefixed archive file keyed by a new engine-level compound_id() per formulation, so a multi-feature, multi-submodule simulation can checkpoint into one file and restore any entity by id. Scale work, MPI-agnostic path templating, and full cross-language test-model support (C / C++ / Fortran / Python) are included.

See doc/BMI_SERIALIZATION_PROTOCOL.md for the model-author-facing spec and test/utils/bmi/ for end-to-end coverage.

Additions

  • SerializationRecord on-disk format (length-prefixed boost archives)
    with truncation tolerance and a MAX_RECORD_ARCHIVE_BYTES sanity cap
  • NgenSerializationProtocol (save) and NgenDeserializationProtocol
    (restore), with metadata-only check_support, is_fatal-driven
    escalation at initialize(), and the usual frequency gating
  • CheckpointIndex — process-global per-path lazy-built index turning
    restore from O(features × records) into O(records + features); exposed
    clear_checkpoint_indexes() for memory-bounded callers
  • compound_id() accessor on Bmi_Formulation with multi-submodule
    3-part key injection (<feature>.<n>:<submodule-mtn>:<multi-mtn>)
  • realization::config::GlobalConfigKey + apply_config
    realization-level inheritable config blocks with per-formulation override
  • utilities::resolve_path_tokens + {{rank}}/{{pid}}/{{host}}/{{date}}
    template support so per-rank/host/date paths stay an engine concern
    (the protocol itself is MPI-agnostic)
  • Bmi_Py_Adapter uint8/int8/byte dispatch branch for opaque-byte
    marshaling across the Python language boundary
  • BMI serialization protocol support in all four test models
    (test_bmi_c, test_bmi_cpp, test_bmi_fortran, test_bmi_py — pickle-based)
  • Engine hooks: Formulation::checkpoint_state() (per-step, called from
    Layer.hpp) and restore call at the end of
    Bmi_Module_Formulation::inner_create_formulation (one-shot)
  • Formulation_Manager::read() releases the CheckpointIndex cache once all
    formulations have been constructed — restores are init-phase-only, so holding
    the index longer is dead weight for memory-bounded runs
  • doc/BMI_SERIALIZATION_PROTOCOL.md spec — reserved variables, unit
    conventions, wire format, model-developer guide, engine integration,
    scaling considerations, MPI guidance

Removals

  • None

Changes

  • NgenBmiProtocols gains Protocol::SERIALIZATION and
    Protocol::DESERIALIZATION enum entries + dispatch wiring
  • Formulation_Manager parses a realization-level serialization block,
    resolves path tokens, auto-derives restore.id_subset from the
    hydrofabric, and injects the block into both construction paths
  • Bmi_Multi_Formulation::init_nested_module peeks the submodule's
    model_type_name from its params and injects the 3-part compound id
    via set_compound_id() before create_formulation() runs
  • Layer::update_models calls checkpoint_state() alongside the
    existing check_mass_balance()
  • Bmi_Py_Adapter::{GetValue,SetValue} dispatch gains a uint8_t
    branch; existing numeric paths unchanged

Testing

  1. cd cmake_build && make test_bmi_protocols && ./test/test_bmi_protocols
    53 tests (record format + both protocols)
  2. make test_bmi_c && ./test/test_bmi_c57 tests
  3. make test_bmi_cpp && ./test/test_bmi_cpp45 tests
  4. make test_bmi_fortran && ./test/test_bmi_fortran93 tests
  5. make test_path_tokens && ./test/test_path_tokens9 tests
  6. With NGEN_WITH_PYTHON=ON:
    make test_bmi_python && PYTHONPATH=<site-packages> ./test/test_bmi_python
    54 tests (includes Python byte-path + save/restore round-trip)
  7. make ngen to confirm downstream linker integrity

All green on macOS/arm64 with gcc-14 / clang.

Notes

  • Format version stamped as v0.1 in file headers. Two independent
    version fields (BOOST_CLASS_VERSION for boost wire format,
    SerializationRecord::CURRENT_VERSION for application schema) support
    forward evolution without one forcing the other.

  • The Meyers-singleton-style CheckpointIndex cache holds memory until
    clear_checkpoint_indexes() is called or the process exits.
    Intentional: init-time restores should share the index across all
    formulations. Memory-bounded callers should invoke the clear hook
    once initialization-phase restores complete.

  • This PR ended up a little larger than initially anticipated. Each commit in the PR is
    logically contained and ordered in a meaningful fashion. I recommend reviewing by commit
    if the overall change set seems a bit daunting, it should help the review process a fair bit!

Todos

Checklist

  • PR has an informative and human-readable title
  • Changes are limited to a single goal (no scope creep)
  • Code can be automatically merged (no conflicts)
  • Code follows project standards (link if applicable)
  • Passes all existing automated tests
  • Any change in functionality is tested
  • New functions are documented (with a description, list of inputs, and expected output)
  • Placeholder code is flagged / future todos are captured in comments
  • Project documentation has been updated (including the "Unreleased" section of the CHANGELOG)
  • Reviewers requested with the Reviewers tool ➡️

Target Environment support

  • Linux
  • MacOS

Comment thread include/utilities/bmi/serialization_record.hpp Outdated
Comment thread include/utilities/bmi/serialization_record.hpp Outdated
Comment thread include/utilities/bmi/serialization_record.hpp Outdated
Comment thread test/utils/bmi/serialization_record_Test.cpp Outdated
Comment thread doc/BMI_SERIALIZATION_PROTOCOL.md Outdated
(restore) classes registered under `NgenBmiProtocols`.

Conformance is optional. Models that do not implement the protocol run
unchanged; the engine silently skips checkpointing for those modules.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Protocol-wise, sure, conformance is optional.

The engine will need to contemplate what it means to be asked to checkpoint/save-state when it's running with a model that can't. That's a modeling problem.

Maybe the latter phrase's sentiment just doesn't belong in this document at all.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is relevant to note the protocol and the engine are opt in. If the functionality is required by a particular application, e.g. operational forecasting, it is up to the deployment of that application to ensure conformance and test the modules. There are clear indicators in the runtime to point to modules which aren't capable of that use.

I don't think it is worth preventing the testing and development of modules via the model engine that aren't capable of this specific functionality. I would be open to a strict mode that could be set which complains much more loudly and prevents runs/configurations that aren't fully conformant

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree on configurability of the engine to require or ignore protocol conformance.

That's not the point, though. Documentation of the protocol shouldn't speak to the pragmatics of a particular implementation - viz., whether ngen will or won't require conformance, and under what circumstances

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The applicability and audience of this file are somewhat muddled. It initially feels like it's addressing itself to model developers, and how they can expect ngen to interact with them relative to this protocol.

But, then it includes details of how ngen is going to store the data a model provides, which is not salient to the protocol, and how ngen's configuration will control its use of the protocol.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I conflated these two viewpoints into one document. It definitely makes sense to pull them apart and target documentation to two distinct audiences.

@PhilMiller

Copy link
Copy Markdown
Contributor

I'm only part way through looking over this PR. Broadly, I like the construction, and appreciate the thoroughness of presentation (Thanks, Claude, I guess?). That said, I see a need for a stronger separation of concerns among at least

  • interaction between ngen and BMI models to retrieve and restore a state
  • configuration of ngen regarding when, where, and how states are saved and/or restored
  • the mechanisms within ngen providing the configured where and how of state saving/restoring (e.g. the indexable, appendable storage format posited here)

Comment thread include/core/Layer.hpp Outdated
// Check mass balance if able
r_c->check_mass_balance(output_time_index, simulation_time.get_total_output_times(), current_timestamp);
// Checkpoint state via the serialization protocol (no-op when unconfigured)
r_c->checkpoint_state(output_time_index, simulation_time.get_total_output_times(), current_timestamp);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This insertion starts to illustrate a design idea I want to push for in ngen, that's applicable to a lot of simulation engines. Basically, rather than hard-coding these various periodic meta-operations 'about' the simulation, there should be a timer wheel that gets cranked each time step, and dispatches whatever abstract operations are scheduled to occur on that step. Right now, as seen here, we're going to get an explosion of code enumerating every possible thing we might be configured to do on any step.

Comment thread src/utilities/bmi/serialization.cpp Outdated
Comment on lines +133 to +143
// Frequency gate — matches NgenMassBalance.
bool checkpoint_step = false;
if (frequency > 0) {
checkpoint_step = (ctx.current_time_step % frequency) == 0;
}
else if (ctx.current_time_step == ctx.total_steps) {
checkpoint_step = true;
}
if (!checkpoint_step) {
return {};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is exactly the kind of thing I was talking about in terms of separation of concerns - the protocol should run when it's called to run(), not be responsible for deciding "nah, I'd rather not".

Comment thread doc/BMI_SERIALIZATION_PROTOCOL.md Outdated
Comment on lines +723 to +726
| `{{rank}}` | MPI rank (0 for non-MPI builds or uninitialized MPI)|
| `{{pid}}` | POSIX process id |
| `{{host}}` | First label of the host name (max 63 chars) |
| `{{date}}` | Local date at startup, `YYYYMMDD` |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like a really weird selection of variables to be able to template over. I can see PID, host, and date being useful for intra-unit-test purposes, but not much else.

MPI rank is more reasonable, if one is going to embrace a file-per-rank design. However, my experience says that saved states should ideally be independent of rank/count. Baking in that dependence would seem to be a mis-step.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear on "date", I think it would be quite sensible to have tokens representing dates significant to the substance of the simulation - e.g. the starting epoch

I can kinda see the execution date being workable in an operational forecasting environment, but it would require a bunch of scaffolding that a date descriptive of the simulation itself would not, in that the simulation epoch is already going to be available in whatever script set up and launched the job.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was an attempt to keep the protocol agonistic of the runtime but still allow for some level of flexibility. The other templates seemed like plausible differentiators that may be useful and are straightforward enough to implement/support. I'm definitely open to cutting this down and adding new ones as needed in the future, though.

@hellkite500
hellkite500 force-pushed the feat/bmi-serialization-protocol branch 2 times, most recently from f91fb6b to 0106cfb Compare May 20, 2026 20:27
hellkite500 added a commit to hellkite500/cfe that referenced this pull request May 20, 2026
…/restore

Add support for the four reserved ngen::serialization_* BMI variables
that allow the ngen engine to checkpoint and restore model state via
an opaque byte buffer (see NOAA-OWP/ngen#957).

Protocol implementation:
- SetValue(create): snapshots state into a versioned byte buffer
- GetValue(size): reports buffer size
- GetValue(state): copies captured bytes to caller
- SetValue(state): restores model state from bytes with validation
- SetValue(free): releases internal buffer

The buffer layout (version 1) packs soil_storage_m, discrete theta,
gw_storage_m, nash_subsurface_storage, and giuh_queue as raw doubles
for bit-exact round-trip fidelity. Deserialization validates layout
version, DSBM flags, and GIUH ordinate count against the running
model config.

Shared protocol definitions and helpers added to ngen_utilities.h
following the existing mass balance protocol pattern.

Tests:
- test_serialization_metadata: verifies check_support() probe
- test_serialization_round_trip: full save/restore/compare cycle

Assisted by Claude Opus 4.6 (1M context)
@hellkite500
hellkite500 force-pushed the feat/bmi-serialization-protocol branch from 0106cfb to de3a93d Compare May 20, 2026 22:22
@hellkite500
hellkite500 force-pushed the feat/bmi-serialization-protocol branch from fb5bf64 to 979d40b Compare June 6, 2026 05:13
@hellkite500
hellkite500 force-pushed the feat/bmi-serialization-protocol branch from 88b813f to 1021dd8 Compare June 16, 2026 21:32
Comment on lines +36 to +41
3. Append-only multi-writer safety — because each record is a self-
contained boost archive, and the length prefix lets a reader locate
the next record without reconstructing archive state, multiple writers
can append independently without file-level coordination. Writers
must still serialize their writes relative to each other — see
NgenSerializationProtocol for the per-instance mutex policy.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might end up being misleading to callers, if anyone actually reads these comments looking for this sort of insight. Appends work fine between processes on a single system, but I honestly have no idea whether they work at all correctly on Lustre or other distributed filesystems.

It might be best to just back off the claims.

Comment on lines +123 to +127
* since epoch) get a one-to-one mapping; callers with formatted strings
* that don't parse cleanly get 0, and the record is still written —
* restore-by-timestamp just won't work against those records unless
* the caller matches on the same "0" sentinel. This is a deliberate
* policy: a parse failure is not fatal to save.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My gut sense is that this is not the right policy to adopt. If we're trying to save data that won't be usefully readable once it's written, because we don't know its timestamp, we should throw, not return a junk value that's otherwise part of the output range.

@hellkite500
hellkite500 force-pushed the feat/bmi-serialization-protocol branch from 1021dd8 to 0987d39 Compare June 18, 2026 16:45
strcmp(name, NGEN_SERIALIZATION_FREE) == 0) {
*size = 0;
return BMI_FAILURE;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may actually be the wrong approach, vs reporting a size congruent with the item's type, since that's how some of the adapters match up concrete language-level types against the strings reported through BMI.

The actual implementation in the test model really only needs to support the way we're going to use it, but it may also be taken as an example for other BMI models, so I'd prefer to treat it more expansively.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same applies to nbytes, too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assumption and design here is to treat this as a trigger, which is by design typeless, and contains no semantic size or number of bytes, but instead communicates a semantic action the module should take. the variable NGEN_SERIALIZATION_FREE doesn't actually exist. When I was working through this, the question of "What does it mean to call get_value on this kind trigger made it pretty clear that exposing it as an actual variable was just overhead and noise.

The only real semantic value is "did serialization free succeed when passed to set value" which is communicated by the "success or failure" of the set value call.

@robertbartel

Copy link
Copy Markdown
Contributor

I'm a bit late to this party, so if I missed a similar discussion on this somewhere, feel free to ignore me ...

The assumption and design here is to treat this as a trigger ...

I think I see the reasons for wanting to just treat the advertised protocol "variables" as triggers for Set_Value calls, rather than actual saved module state, but when I read this and some of ‎doc/BMI_SERIALIZATION_PROTOCOL.md‎ today, it didn't sit right.

This doesn't really comply with the BMI documentation. It's one thing to go beyond what BMI intends, but using SetValue(ngen::serialization_create, _) calls strictly as a stateless trigger simply doesn't do what the docs say Set_Value does. It also causes complex, time-specific, model-specific behavior to be run somewhere outside of Update (or Update_Until), which feels like it violates a de facto part of the design of BMI.

It may feel slightly clunkier, but I think it would still be better to have these as true state variable that are set, just as with other BMI variables. Then have the logic of Update read them, see when a flag to trigger something extra related to state serialization/deserialization is set, and have such things happen as optional parts of the main routine for advancing the model through time. That seems more true to the design of BMI (to me), even if slightly less efficient.

Though, to Phil's earlier point:

Documentation of the protocol shouldn't speak to the pragmatics of a particular implementation

Perhaps all this only really matters with respect to ngen's documentation, and perhaps we should be cautious about saying too much there about how model developers have to develop their models. Still, if it's being assumed on this end, we should be careful about any unintended consequences of that assumption.

Comment thread include/utilities/bmi/serialization.hpp Outdated
/** Save-side / restore-side shared sub-keys. */
constexpr const char* const SERIALIZATION_CHECK_KEY = "check";
constexpr const char* const SERIALIZATION_FATAL_KEY = "fatal";
constexpr const char* const SERIALIZATION_FREQUENCY_KEY = "frequency";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like that the protocol itself owns responsibility for frequency of its execution, but it needs to be configured somewhere. It should be fine for now.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NGWPC work takes a different path on configuration, though I don't know the details.

Comment thread include/utilities/bmi/serialization.hpp Outdated
Comment on lines +75 to +89
* All protocol instances pointed at the same `path` write into the same
* file; one entity (id) may have at most one record per time step but
* may have records at multiple steps.
*
* Thread safety — three regions to reason about
* ---------------------------------------------
* Each region is documented at its point of use inside the .cpp; this
* header lists them as a map so readers can find the relevant comment.
*
* (1) Per-instance I/O state (the persistent ofstream and the
* reusable payload buffer): guarded by `io_mutex_` because a
* future threaded driver may invoke `run()` for different ids
* concurrently against the same protocol instance. Without the
* mutex, two threads could interleave archive bytes on the
* stream and corrupt the file.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are concerns about the implementation using a particular file format and writer functionality. They really shouldn't be part of the protocol itself.

Maybe we can pull in the Saver abstraction from the NGWPC work and shift it over there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was refactored behind a higher level abstraction introduced in 1d056c9, and the switch of the protocol to use is in 22986fb (the HEAD of this branch)

Comment thread include/utilities/bmi/serialization.hpp Outdated
// that mutation safe to do across threads.
mutable std::mutex io_mutex_;
mutable std::unique_ptr<std::ofstream> out_;
mutable std::vector<char> payload_buffer_;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The payload_buffer_ member almost makes sense, but I think we can potentially do without it.

Instead of using GetValue("state", payload_buffer_), we could just call GetValuePtr("state"), and pass that along to whatever is doing the output, skipping the intermediate copy.

Comment thread src/utilities/bmi/serialization.cpp Outdated
Comment on lines +40 to +43
// buffer only grows; it is never shrunk, which is fine because each
// protocol instance is bound to one model whose state size is
// stationary across timesteps.
constexpr size_t INITIAL_PAYLOAD_BUFFER_BYTES = 4096;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this comment is an accurate description of the operation, then it's a huge problem. It's talking about maintaining an extra memory footprint equal to each model's state.

When I previously read other comments about the buffer, I understood it to be a single shared instance that would be the maximum of any of the individual models being saved. As long as we're only dealing in catchment column models, and not t-route, SCHISM, or some big gridded model, that's fine and desirable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a real concern that is better handled with the refactored abstraction and Writer mechanics later (the buffer could more easily be per writer and reused much more in alignment with the original intent.) I think its a pretty small change against this PR head to support that, I'll take a look.

Close out the BMI serialization protocol's cross-language support
story with a pickle-based reference implementation in the Python
test model and end-to-end framework tests. Three pieces:

1. Bmi_Py_Adapter byte-typed path. `get_analogous_cxx_type` now
   recognizes numpy `uint8` / `int8` / `byte` (itemsize 1) and
   routes them through a new `uint8_t` branch in GetValue /
   SetValue. The existing `copy_to_array<T>` and `set_value<T>`
   templates instantiate cleanly with `T = uint8_t`, so the adapter
   gains opaque-byte support without new pybind11 machinery.

2. Python test model. `bmi_model.py` gains the four reserved
   variables (uint8 STATE, int32 triggers + size) with matching unit
   strings and a pair of pickle-based `_create_serialization` /
   `_deserialize_state` helpers. Location is deliberately not
   advertised for the reserved names, matching the C/C++/Fortran
   reference models. `get_var_nbytes` gains a dynamic branch for
   the STATE variable: when the adapter asks how many bytes STATE
   currently occupies (including at restore time before any CREATE
   has fired), the model answers by pickling a snapshot of the
   current state and returning its length. Pickle output is
   size-stable for the fixed schema used here — numpy arrays of
   fixed dtype/shape + Python floats, which pickle to fixed-width
   BINFLOAT opcodes — so the live-state probe matches the saved
   record's length and the adapter wraps the incoming byte buffer
   at the right size. Docs updated: the Model Developer Guide
   explains this adapter-driven sizing expectation so authors of
   future Python (or similarly adapter-wrapped) BMI models have a
   template to follow.

3. Framework tests. A new `test/utils/bmi/py_serialization_Test.cpp`
   (wired into `test_bmi_python`) exercises three cases against the
   live adapter:
     * `check_support_passes` — conforming units probe.
     * `save_writes_record` — byte-exact comparison between the
       record on disk and a second manual CREATE/SIZE/STATE/FREE
       capture against the unchanged model, catching any
       adapter-layer byte corruption.
     * `save_restore_roundtrip` — explicit mutation of the
       serialized inputs (sentinel -777/-888) with a pre-restore
       divergence assertion, proving that the post-restore equality
       checks measure real work done by the restore rather than
       inputs that were never perturbed.

Assisted by Claude Opus 4.7 (1M context)
NgenMassBalance::run treated `ctx.current_time_step == ctx.total_steps`
as the end-of-run sentinel when frequency == -1. The Context contract
puts current_time_step in [0, total_steps - 1] (end-of-step counter),
so the comparison gated against an out-of-range value and the
negative-frequency branch was dead at every in-range counter value.

The original comparison was authored with begin-of-step semantics in
mind, where K == total_steps would name the boundary before the
would-be Nth step (which is never computed). Mixing the two
perspectives made the branch unreachable in practice.

Existing frequency_end tests passed against the buggy comparison
because they manually constructed contexts with current_time_step
== total_steps, matching the bug rather than the contract. They're
rewritten to drive in-range; frequency_end_single_step,
frequency_end_large_n, and frequency_negative_other_than_minus_one
cover the sentinel edges.

Assisted by Claude Opus 4.7 (1M context)
BMI_SERIALIZATION_PROTOCOL.md — protocol spec for model developers.
Reserved variables, unit conventions, and the model implementation
guide. Intro frames conformance as use-case dependent, with the
engine's behavior for non-conforming models documented in one place.

NGEN_SERIALIZATION.md — ngen-internal documentation. Wire format
(SerializationRecord, length-prefixed boost archives), save and
restore protocol configuration, identity convention (compound_id()),
engine integration points, the worked multi-formulation example,
realization-level (global) configuration including path templating,
and scaling considerations (CheckpointIndex, id_subset, thread and
process safety, when the protocol is not the right tool).
  src/utilities/bmi/wire_format.hpp — RecordPrefix struct +
      read/write functions for the v0.2 on-disk record header.
      Defines the format, its versioning, the timestamp encoding
      convention, the two's-complement assumption with its
      run-host canary tests, and the cross-host portability
      claims.

  src/utilities/bmi/byte_io.hpp — internal helper that fronts
      `boost::endian`'s per-type `store_little_uXX` /
      `load_little_uXX` primitives via using-declarations, adds
      the single-byte trivials boost doesn't supply, and wraps
      them in iostream-friendly stream adapters that
      `write_record_prefix` / `read_record_prefix` call.

Both are internal-only: under src/ (not include/), PRIVATE include
path on ngen_bmi_protocols, opted in by test_bmi_protocols only.

Will replace the boost::serialization-backed record layout in a
follow-up commit; landing the new infrastructure as pure addition
first keeps the format-introduction step independently reviewable
from the format-switch step.

Purely additive: no existing code path uses these helpers yet,
existing protocol tests are unchanged.

Assisted by Claude Opus 4.7 (1M context)
Replaces the boost::serialization-backed record layout
(length-prefixed archive body) with the fixed-prefix wire format
from the prior commit. write_record / read_next_record /
read_record_length / read_record_metadata now drive the
byte_io + wire_format primitives directly; BOOST_CLASS_VERSION
machinery and the boost archive includes are gone.

SerializationRecord mirrors the wire fields directly: id,
time_step, simulation_timestamp, checkpoint_epoch, payload. The
constructor's 5th parameter defaults to 0 so existing 4-arg test
fixtures keep working; the production write site stamps it with
std::time(nullptr) (a future Coordinator will provide a single
per-event value).

CheckpointIndex uses the new read_record_metadata fast walker
— prefix + id read, then seekg past the payload. Index-build cost
becomes O(prefix + id_size) per record instead of v0.1's
O(prefix + payload_size). Internal field names align with the
wire format (simulation_timestamp, target_simulation_timestamp).

MAX_RECORD_ARCHIVE_BYTES is renamed MAX_RECORD_PAYLOAD_BYTES (it
caps payload_length now, not an archive body).

The library no longer links Boost::serialization.

Error handling: the wire-format read/write helpers return
expected<wire_format::Status, std::string> (reads) and
expected<void, std::string> (writes). The three-state Status
distinguishes "record read" from "clean EOF / torn final
record"; the error arm is reserved for malformed records (bad
magic, unsupported wire_version, oversized payload). The
throw-on-corruption pattern from earlier drafts is gone —
serialization.cpp's write loop and deserialization.cpp's index
walk both check the expected<> arm directly. The library no
longer imposes exception semantics on consumers; the application
layer decides whether to escalate.

The wire-format-specific tests in SerializationRecordTest are
rewritten for the v0.2 byte layout and the expected<>-arm
contract.

Assisted by Claude Opus 4.7 (1M context)
New engine-agnostic library at include/utilities/serialization/
carrying the abstractions for record-based state checkpointing:

  - Record        — value type producers and backends interchange
  - RecordBackend — abstract storage interface with nested
                    Writer / Reader sub-handles, returned by
                    factories writer() / reader(). Scoped
                    with_writer() / with_reader() wrappers are
                    the recommended call pattern.
  - BackendError  — categorized error type (NotFound, Corrupted,
                    IOError) carried on every expected<> error arm.
  - IdPredicate   — application-supplied id-string predicate the
                    library invokes but never parses.
  - Durability    — caller-chosen contract for commit semantics.

Header-only INTERFACE target; ngen_bmi_protocols PUBLIC-links it
and re-exports Record as the legacy SerializationRecord typedef,
so existing BMI call sites are unchanged.

Initial release v0.1.0. The 0.X series carries no API-compatibility
guarantee — the abstract surface is in active design.

Design rationale and the rules every backend must satisfy are
documented in include/utilities/serialization/README.md.

Fallible methods on the abstract surface carry [[nodiscard]] so
silently dropping an error arm requires an explicit (void) cast.

The abstract surface is covered by tests in test_serialization
against five reusable mock backends (Noop, Tracking, Failing,
Snapshot, SharedState); existing BMI tests continue to pass
unchanged.

Assisted by Claude Opus 4.7 (1M context)
Route both serialization and deserialization through the
ngen::serialization::RecordBackend abstraction via a new
FileBackend implementation shared across protocol instances by
path (process-static weak_ptr registry). The N protocols on a
realization configured against the same checkpoint file share
one in-memory index, one write fd, and one mutex-serialized
write critical section — fixing the latent interleaving hazard
from N independent fds and amortizing the index walk to one per
path.

Two-layer scope filtering: realization-level construction scope
bounds the index; per-call exact_id(ctx.id) read scope narrows
each Reader to the one feature the engine is asking about.

Removes the v0.1 process-global CheckpointIndex cache and its
clear_checkpoint_indexes() eviction hook; weak_ptr lifecycle in
the registry handles eviction automatically.

Assisted by Claude Opus 4.7 (1M context)
Splits the checkpoint-record value type via a
`detail::RecordT<PayloadT>` template into:
  - `Record`     — owning,     payload = std::vector<char>
  - `RecordView` — non-owning, payload = boost::span<const char>

Writer API:
- `RecordBackend::Writer::write(const RecordView&)` replaces the
  prior owning `write(const Record&)` signature. The writer
  streams the caller-owned payload bytes to storage and does not
  retain the pointer after returning.
- `bmi::write_record` gains an overload taking `RecordView` as
  the canonical entry point; the owning `Record` overload is
  retained alongside it.
- `FileBackend::Writer` consumes the view directly on the POSIX
  path (zero copy); the non-POSIX fallback calls the new
  RecordView `write_record` overload.

Producer side (NgenSerializationProtocol::run):
- Drops the `mutable payload_buffer_` member and the move-in /
  move-out dance with `Record`. Each save allocates a
  function-scoped `vector<char>`, fills it via `GetValue`, and
  hands the writer a view over it.

Tests and mocks updated for the new write signature.

Single review-checkpoint commit; may be squashed back into the
earlier serialization commits on this branch once reviewed.
…mers adopt it

The original `parse_timestamp` guarded only against "std::stoll
consumed zero characters" — any input with at least one leading
digit (including ngen's "2025-12-19 14:30:00" format from
`Simulation_Time::get_timestamp`) parsed as the leading integer
(2025) and silently landed in the record's `simulation_timestamp`
field. The doc claim "non-numeric strings map to 0" did not match
the implementation.

Parser (`include/utilities/serialization/record.hpp`)
-----------------------------------------------------
Rewrite into a two-helper split with a uniform contract — all
three return `int64_t`, never throw, and resolve failures to a
documented out-of-band sentinel:

  * `parse_epoch_string` — whole-string signed integer interpreted
    as Unix epoch seconds. Tolerates surrounding whitespace;
    rejects any non-numeric trailing content (including the
    partial-numeric case that was the bug). Catches std::stoll
    exceptions internally.
  * `parse_formatted_time` — whole-string strptime match against
    `TIMESTAMP_STRPTIME_FORMAT` (`"%Y-%m-%d %T"`, matching ngen's
    Simulation_Time format). Tolerates trailing whitespace.
  * `parse_timestamp` — orchestrator. Tries the epoch parser
    first; cascades to the formatted parser on sentinel.

`UNPARSEABLE_TIMESTAMP_SENTINEL = INT64_MIN` is documented at the
type and pinned by tests. INT64_MIN interpreted as Unix epoch is
~9.2e18 seconds before 1970 — guaranteed unreachable as a real
timestamp, including paleo-hydrology simulations.

Producer adoption (`src/utilities/bmi/serialization.cpp`)
---------------------------------------------------------
On an unparseable `ctx.timestamp`, stamp the sentinel on the
record and emit a `PROTOCOL_WARNING` naming the offending input
and pointing to `parse_timestamp` for accepted forms. The save
itself succeeds — callers that only restore-by-step are
unaffected; restore-by-timestamp simply cannot match the
sentinel.

Consumer adoption (`src/utilities/bmi/deserialization.cpp`)
-----------------------------------------------------------
On an unparseable `restore.timestamp` config value, refuse to
enable restore — returns `PROTOCOL_ERROR` (when `fatal: true`)
or `PROTOCOL_WARNING` + `check = false` (when `fatal: false`),
rather than silently looking up the leading integer.

Tests
-----
New `test/utils/serialization/parse_timestamp_Test.cpp` (20
tests, 4 suites) pins each helper's success and rejection
contract, the sentinel's value and non-collision with any
parseable input, the cascade behavior, and the
`TIMESTAMP_STRPTIME_FORMAT` convention against the format
produced by ngen's `Simulation_Time::get_timestamp`.

New protocol-level tests cover the warning + sentinel behavior
end-to-end: `unparseable_timestamp_warns_and_uses_sentinel` on
the save side; `unparseable_timestamp_warns_when_non_fatal` and
`unparseable_timestamp_throws_when_fatal` on the restore side.

Existing tests across both serialization and deserialization
suites used `"t0"` / `"t1"` / `"t2"` as placeholder
`ctx.timestamp` strings; under the new contract each of those
saves emits a `PROTOCOL_WARNING` to stderr. Replaced 14 call
sites in `serialization_Test.cpp` and 1 in
`deserialization_Test.cpp` with parseable 1-hour-cadence epoch
strings (`"0"`, `"3600"`, `"7200"`, …) — same test semantics,
no warning noise. The stale comment in `check_writes_record`
claiming non-numeric inputs "land as 0" is updated to point at
the new dedicated warning-path test.

Single review-checkpoint commit; intended to be folded back into
the introducing commits — parser body + sentinel + helpers + test
file into the original SerializationRecord-on-disk-format commit;
producer/consumer adoption and test-suite noise cleanup into the
respective NgenSerializationProtocol / NgenDeserializationProtocol
introduction commits — once reviewed.
@hellkite500
hellkite500 force-pushed the feat/bmi-serialization-protocol branch from 34928ba to dc2173e Compare August 12, 2026 22:22
@hellkite500

hellkite500 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

The most recent force push layers on top of #1008 #1009 and #1011
It also squashed a fair number of comment/documentation cleanups in to respective commits. A couple additional new commits touch on some the remaining review comments, and the final commit added here provides a split read/write file path for the protocol supporting. A quick look at that split path feature and tests would be in order, but otherwise this should be at a solid point to rebase/merge after the PR's mentioned above are in place.

…ization_size in models

A 32 bit compatible path is proposed in this commit, allowing models to use
a narrower value for the serialized byte size up to INT32_MAX. Overflow becomes
a model concern in this case.

The outlier here is the Fortran ABI and the iso C interface, which *can*
support a 64 bit c int declaration on the model side, but requires marshalling
the size across the set_value_int function as an array of 4 byte ints, and using
`transfer` to correction assemble them.

Test coverage for each model expands to round trip into both a 32 bit and 64 bit
integer in the test models.
`run()` had accumulated three pieces of unreachable code that
predated the DESERIALIZATION case being added:

  - `expected<void, ProtocolError> result_or_err;` — a local
    that was never assigned or referenced;
  - `break;` after each `return` inside the switch arms — dead
    because the return exits the function before the break can
    fire;
  - `return {};` after the switch — dead because every arm
    (including `default:`) returns.

The `default:` case itself stays as defensive coverage for
enum-abuse-via-cast, with a comment explaining what triggers it.
No behavior change; just clearer control flow.
`FileBackend::create(path, ...)` is a path-keyed cache — whichever
protocol's `initialize()` fires first fixes the shared backend's
construction-time scope for every subsequent caller on that path.
Because save-side previously ran first with no scope, the shared
in-memory index defaulted to "all records in the file" and the
restore-side `id_scope` (from `serialization.restore.id_subset`)
was silently ignored at cache-fill time.

Reordering so deserialization initializes first lets its
`id_scope` size the shared index. Save-side writes append
independently of the index scope, so tightening it here does not
affect save behavior.

Correctness is unchanged either way — per-Reader `exact_id(ctx.id)`
scoping still filters results — but memory footprint of the
in-memory index now honors the restore-side scope as intended.
`save.path` and `restore.path` now override the top-level
`serialization.path` for their respective directions. The top-level
`path` remains a shared default when neither sub-block overrides it,
so existing configs keep working unchanged.

Because `FileBackend::create` is path-keyed, split paths resolve to
two independent backend instances — restore reads the source file
and save appends to the destination without either seeing the
other. Enables the write-forward pattern (warm-start from an old
checkpoint, land new records in a fresh file) that a single shared
path can't express.

Warn-and-disable behavior is unchanged: an enabled direction with
no resolved path (neither top-level nor sub-block) still emits a
PROTOCOL_WARNING and disables. The warning text now mentions both
possible sources so the operator can pick where to add the key.

Tests:
- save.path overrides top-level path (writes land in sub, not top)
- save.path only (no top-level) is sufficient to enable save
- restore.path overrides top-level path (reads sub, not top)
- restore.path only is sufficient to enable restore
- save + restore round-trip with distinct paths, verifying the
  restore file is not appended to and the save file is created

Docs: schema updated for both directions; new "Split save/restore
paths" subsection documents the resolution rule and the FileBackend
consequence.

Assisted by Claude Opus 4.7 (1M context)
@hellkite500
hellkite500 force-pushed the feat/bmi-serialization-protocol branch from 0966ff0 to a4558ec Compare August 12, 2026 23:05
@robertbartel

robertbartel commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

@hellkite500, something occurred to me as I was working on getting NOM ready for this. As I think more about it and write this up, I'm more confident we can safely defer working on it until later as a refinement, but I'll at least go ahead and mention it now in case (not as confident as I was earlier; see subsequent comment about NOM out-of-bounds array read).

I don’t see a way via the protocol to distinguish between what I'll call a hotstart restore (i.e., bootstrap a new simulation) versus a resume restore (i.e., resume an interrupted simulation after it failed or was stopped). The only way ngen sends info to a model is via the serialization payload, which doesn’t carry anything about the type of restore. I can imagine scenarios (and I think NOM might be a tangible example with some time-related counter variables) when a model might want to behave differently depending on which of those restore scenarios was happening, and I'm pretty sure (at least eventually) ngen will want to support the distinction.

@robertbartel

Copy link
Copy Markdown
Contributor

@hellkite500, to add weight to my previous comment, I am pretty sure Noah-OWP-Modular - after updates to support the protocol - would have some significant issues, unless it is also updated/hacked to ignore or leave out certain parts of the state (or we do more changes on the ngen side).

For one, it would cause ngen to throw an exception on the first time step when used for a hotstart, unless allow_model_exceed_end_time = true is explicitly set in the realization config. This we could work around, though.

More seriously, the time step value (itime) - which would be loaded from state - is used as the index value for an array - sim_datetimes. This array is created at initialization based on the date values of the new simulation. With itime restored to a previous run's time step value, this will lead to an out-of-bounds read.

@peckhams

Copy link
Copy Markdown

Hi Guys, I just wanted to remind you that I implemented a model state serialization strategy a few years ago (for OWP via Lynker contract). I gave several presentations on it at conferences, and wrote a report or two, etc. It doesn't appear that you are making use of that prior work. I'm happy to send details and links, etc., if it is of interest. We serialized several models as test cases, including Topmodel, and models in Fortran and C. Several others worked on this with me.

@hellkite500

Copy link
Copy Markdown
Contributor Author

Hi Guys, I just wanted to remind you that I implemented a model state serialization strategy a few years ago (for OWP via Lynker contract). I gave several presentations on it at conferences, and wrote a report or two, etc. It doesn't appear that you are making use of that prior work. I'm happy to send details and links, etc., if it is of interest. We serialized several models as test cases, including Topmodel, and models in Fortran and C. Several others worked on this with me.

The fundamental difference in this work specifically is the ability of a calling engine ( e.g. ngen ) to collect and mange the serialized representation via BMI.

@peckhams

Copy link
Copy Markdown

In the work we did, BMI was extended with three new functions including "get_var_role()", and C code was written with the intent of it being called by the NextGen framework. Jessica and I modified topmodel and others with these new BMI functions in a branch, and then demonstrated the serialization by this BMI extension mechanism. In other work we tested 4 different approaches to do the same for Fortran. Just hoping this work was helpful to the current effort.

@christophertubbs

Copy link
Copy Markdown
Contributor

My concern over:

In the work we did, BMI was extended with three new functions including "get_var_role()", and C code was written with the intent of it being called by the NextGen framework. Jessica and I modified topmodel and others with these new BMI functions in a branch, and then demonstrated the serialization by this BMI extension mechanism. In other work we tested 4 different approaches to do the same for Fortran. Just hoping this work was helpful to the current effort.

Is the extension aspect. The official BMI is the surface that we have the ability to work with as a lowest common denominator. Have your extensions been evaluated, approved, and added to the official BMI spec? If so, you may have made my week. Unless we pivot as a product, if it's not in the official spec, it's essentially off limits.

@peckhams

Copy link
Copy Markdown

The extension we proposed is a pretty elegant approach to this and many related problems, like model calibration, but is not part of the core BMI spec. The BMI Council has been working on what it means to be a BMI extension, and how a framework can tell which extensions a model supports. The way model state variables (which go beyond input and output variables) and information about them are acquired for the approach you are using goes beyond what a BMI implementor is expected to provide. So someone (e.g. you guys) then needs to modify a model's BMI functions to make this work for variables other than input and output variables. So while technically conforming to the BMI version 2.0 specs, you cannot apply this to a user-contributed, BMI v2 compliant model without further modifying their BMI implementation to a significant degree. So this isn't an optimal approach either. With our extension approach, you wouldn't need to do this additional work.


/** Reserved BMI variable names driving the protocol's capture sequence. */
constexpr const char* const SERIALIZATION_CREATE_NAME = "ngen::serialization_create";
constexpr const char* const SERIALIZATION_FREE_NAME = "ngen::serialization_free";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The more I get my hands dirty with serialization code, the more squeamish about serialization_create and serialization_free BMI variables that semi-directly call internal logic within BMI objects I get. If the point of BMI is to just act as a data exchange and progression api, sneaking in a required RPC interface runs counter to that.

It's needed for the RAII approach, which is a relatively safe approach, but it may be forcing NGen to be responsible for things it shouldn't be. If BMI is supposed to be a pretty firm barrier between logic and language, the BMI implementation should probably be the one responsible for getting the BMI implementation initialized and ready to go (and destructed).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no way in the BMI 2.0 ABI to avoid this, which is why these are "triggers" by type in the protocol, a loose indication between caller and receiver that when get/set value see this variable, it has expected semantics (e.g. rpc like semantics vs typical read/write a state variable).

// The reserved-variable table is defined in serialization.hpp and
// shared with the restore protocol so the two agree on the exact
// set of variables a conforming model must expose.
for (const auto& ev : RESERVED_VARS) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be a bit too restrictive. Under this logic, if a BMI implementation doesn't expose a create and free function, they aren't valid for serialization, but create and free may not be required for a BMI implementation to be adequately serialized and deserialized. Size and state? Needed without a doubt. Free and create? Not so much, especially since the assumption they are available hints at abstraction leakage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are required if the protocol expects to always call these in a specific order and expect them to result in particular semantics.

@hellkite500

Copy link
Copy Markdown
Contributor Author

So while technically conforming to the BMI version 2.0 specs, you cannot apply this to a user-contributed, BMI v2 compliant model without further modifying their BMI implementation to a significant degree. So this isn't an optimal approach either. With our extension approach, you wouldn't need to do this additional work.

This is why protocol compliance/support is documented and expected in addition. Regardless of the technique applied, someone is going to have to modify, annotate, and manage the serialization and deserialization of the computational states the model requires. The ability to classify a named variable as a state variable requires the model developer to map that BMI string to the variable, one after the other. This is no different than mapping each required state variable to a buffer and advertising that as a complete (opaque) collection of state.

Also, one benefit of treating state as an opaque buffer though is that 4 BMI calls are needed (as this protocol exists today) to capture any number of state variables from the model, vs having to enumerate and iterate, one after the other, N variables and make 4 * N BMI function calls to get the same collection.

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.

7 participants