NGen
Loading...
Searching...
No Matches
wire_format.hpp
1/*
2Author: Nels Frazier
3Copyright (C) 2025 Lynker
4------------------------------------------------------------------------
5Licensed under the Apache License, Version 2.0 (the "License");
6you may not use this file except in compliance with the License.
7You may obtain a copy of the License at
8
9 http://www.apache.org/licenses/LICENSE-2.0
10
11Unless required by applicable law or agreed to in writing, software
12distributed under the License is distributed on an "AS IS" BASIS,
13WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14See the License for the specific language governing permissions and
15limitations under the License.
16------------------------------------------------------------------------
17INTERNAL — DO NOT INCLUDE FROM EXTERNAL CALLERS.
18
19This header is an implementation detail of the BMI serialization
20protocol library. It lives under `src/` (not `include/`) so that
21external consumers — engines, tools, downstream libraries linking
22against `NGen::bmi_protocols` — cannot reach into the wire format
23and write arbitrary bytes that bypass the record layer's correctness
24checks, version handling, and identity guarantees.
25
26The public API for producing or consuming records lives in
27`include/utilities/bmi/serialization_record.hpp`. The struct and
28helpers in this file are NOT part of the library's stable API.
29
30Visibility is enforced by include-path discipline: this header is
31only on the PRIVATE include path of the `ngen_bmi_protocols`
32library's own translation units and of the `test_bmi_protocols`
33test target. It is never exported via `target_include_directories(PUBLIC ...)`.
34
35------------------------------------------------------------------------
36Fixed-prefix wire format for serialization records.
37
38This header defines a compact, portable on-disk header that prefixes
39every serialization record. Each field is encoded one byte at a
40time in little-endian order via the helpers in `byte_io.hpp` — an
41implementation detail of this header that has no API contract of
42its own. Readers do not need to reach for byte_io.hpp; the wire
43format's behavior, claims, and version are all documented here.
44
45Why roll our own wire format
46----------------------------
47Alternatives that were considered and rejected — reasons kept
48here so a future maintainer doesn't retry them:
49
501. **`boost::serialization` archives.** boost's own docs describe
51 `binary_oarchive` as "a non-portable native binary archive":
52 it stamps host-native integer widths and endianness into the
53 archive and throws on cross-host reads. Boost's remedy is an
54 *example* file (`portable_binary_iarchive`) rather than a
55 supported class. The archive's byte-level spec is also spread
56 across boost's docs, the compiler, and any
57 `BOOST_CLASS_VERSION` macros in scope. And every read path
58 needs try/catch to distinguish EOF from corruption from
59 version mismatch — no natural way to make clean-EOF and
60 torn-record-EOF share control flow.
61
62 References:
63 https://www.boost.org/doc/libs/release/libs/serialization/doc/archives.html
64 https://www.boost.org/doc/libs/release/libs/serialization/example/portable_binary_iarchive.hpp
65 https://www.boost.org/doc/libs/latest/libs/serialization/doc/exceptions.html
66
672. **Protobuf / flatbuffers / similar libraries.** All add
68 external build/runtime dependencies for a payload we treat as
69 opaque bytes anyway. The prefix is 39 bytes of integers —
70 nothing benefits from a schema language.
71
723. **A packed struct + `reinterpret_cast` over the stream.** The
73 natural "shortcut" implementation. Compiler padding rules,
74 member-alignment requirements, and host endianness combine to
75 make this subtly wrong on at least some target combination.
76
77The chosen format writes each field byte-by-byte in little-endian
78via `byte_io.hpp`. On-disk bytes are identical on any host, the
79spec is the hex-editor-readable table below, and reads return
80`false` on a short read at any field boundary so torn-record and
81clean-EOF paths are the same code.
82
83On-disk layout for wire_version=1 (RecordPrefix::PREFIX_BYTES, little-endian, no padding):
84
85 offset size field type notes
86 -------- ---- --------------------- ------ --------------------------------
87 [0..4) 4 magic uint32 'NGSR' bytes-on-disk (N,G,S,R)
88 [4..5) 1 wire_version uint8 prefix-layout version
89 [5..13) 8 time_step int64 simulation step counter
90 [13..21) 8 simulation_timestamp int64 sim time, signed seconds since
91 the Unix epoch (1970-01-01T00:00:00 UTC)
92 [21..29) 8 checkpoint_epoch int64 wall-clock event time, signed
93 seconds since the Unix epoch
94 [29..31) 2 id_length uint16 bytes of id that follow
95 [31..39) 8 payload_length uint64 bytes of payload that follow
96
97Followed on the stream by:
98 [id_length bytes of raw id]
99 [payload_length bytes of opaque payload]
100
101Timestamp encoding convention
102-----------------------------
103Both `simulation_timestamp` and `checkpoint_epoch` are **signed
104seconds since the Unix epoch** (1970-01-01T00:00:00 UTC). This is
105mandated by `wire_version=1`. The int64 range covers ±292 billion
106years from 1970, we should be good for a while with this...
107
108A future `wire_version` may redefine the time encoding (e.g.
109milliseconds, a different reference epoch, or a non-second tick
110quantum), at which point readers branch on `wire_version` to
111select the parser — same mechanism that governs every other
112layout-or-semantics change.
113
114Models that genuinely need an encoding the wire format does not
115support — sub-second precision, a domain-specific reference
116calendar, non-Earth time — embed their own time representation
117inside their payload bytes. The wire format's timestamp fields
118are a coarse indexing aid for the protocol layer, not the
119canonical authority on the model's notion of simulation time.
120
121The two timestamp fields are not redundant
122------------------------------------------
123`simulation_timestamp` is the *simulated* time the record
124represents: the moment in the model's universe being captured.
125`checkpoint_epoch` is the *wall-clock* time the operator
126triggered the save event. The two values diverge in every
127nontrivial run:
128
129 * Reanalysis (modeling the past): simulation_timestamp ≪
130 checkpoint_epoch.
131 * Forecast (modeling the future): simulation_timestamp ≫
132 checkpoint_epoch.
133 * Restart re-run: same simulation_timestamp on the new record,
134 later checkpoint_epoch.
135
136Carrying both per-record makes records self-describing — any
137single record can answer both "what simulated moment does this
138represent?" and "when did the operator save this?" without
139needing to consult the surrounding filesystem layout (which not
140every backend preserves identically across copy, concatenation,
141or format migration).
142
143wire_version
144------------
145The prefix carries exactly one version field, `wire_version`,
146which covers the prefix-layout itself: if any field is added,
147removed, reordered, widened, or has its semantic meaning change
148(timestamp encoding, reference epoch, etc.), `wire_version`
149bumps and readers select the appropriate parser. The format
150carries no payload-schema version — payloads are opaque bytes,
151so the library can neither enforce nor detect a payload-schema
152mismatch. Model authors who want payload versioning embed their
153own header inside the payload bytes (see the "Recommended:
154defensive payload format" section of
155BMI_SERIALIZATION_PROTOCOL.md).
156
157Signed integer fields and the two's-complement assumption
158---------------------------------------------------------
159Three of the prefix's fields are signed int64 (`time_step`,
160`simulation_timestamp`, `checkpoint_epoch`). The on-disk bytes
161for a signed value are written as the bit pattern of the
162matching unsigned width, transferred via `std::memcpy`. This
163encoding assumes the host uses **two's-complement signed
164integer representation**.
165
166The assumption matters for *cross-host* readability between
167different signed representations — same-host round-trip works
168regardless of representation because memcpy preserves the bit
169pattern. C++20 mandates two's-complement, so this holds by
170language rule on any conforming toolchain. Two byte_io tests
171verify it end-to-end on the running host as a safeguard against
172nonconforming toolchains and possible ports to older standards:
173
174 ByteIO.i32_explicit_byte_layout_negative
175 ByteIO.i64_explicit_byte_layout_negative
176
177Truncation tolerance
178--------------------
179Any short read inside the prefix returns `false` from
180`read_record_prefix`. Short reads in the id or payload bytes
181that follow the prefix are the caller's responsibility — the
182prefix says how many bytes to expect, and the caller decides
183how to interpret a truncated trailer. Magic-byte mismatch is
184the signal for "this is not a record start"; the prefix layer
185treats it as a successful read of garbage values, leaving
186validation to the caller.
187
188Cross-host portability — what the format does and does not claim
189----------------------------------------------------------------
190The prefix's signed and unsigned integer fields are portable
191across any two two's-complement little-endian-or-big-endian hosts
192— the field-level LE encoding produces identical bytes on any
193host, and the two's-complement canary above verifies the signed
194assumption at run time on each deployment.
195
196The payload bytes that follow the prefix are **opaque to the
197wire format**. The library never interprets them, never re-encodes
198them, never checks them against a representation assumption. A
199model that writes raw signed integers, host-endian-encoded
200values, or any other representation-dependent bit pattern into
201its payload has its own portability story to manage; the wire
202format makes no claims about it. A record is portable as a whole
203iff *both* the prefix (covered here) and the payload format (the
204model's responsibility) survive the cross-host trip. Model
205authors who want payload portability are pointed at the
206"Recommended: defensive payload format" section of
207BMI_SERIALIZATION_PROTOCOL.md.
208*/
209#pragma once
210
211#include "byte_io.hpp"
212
213#include <cstddef>
214#include <cstdint>
215#include <istream>
216#include <ostream>
217
218namespace models {
219namespace bmi {
220namespace protocols {
221namespace serialization {
222namespace wire_format {
223
239enum class Status { Ok, Eof };
240
257 static constexpr uint32_t MAGIC = 0x5253474Eu;
258
273 static constexpr uint8_t VERSION = 1;
274
283 static constexpr std::size_t PREFIX_BYTES = sizeof(uint32_t) // magic
284 + sizeof(uint8_t) // wire_version
285 + sizeof(int64_t) // time_step
286 + sizeof(int64_t) // simulation_timestamp
287 + sizeof(int64_t) // checkpoint_epoch
288 + sizeof(uint16_t) // id_length
289 + sizeof(uint64_t); // payload_length
290
291 uint32_t magic = MAGIC;
293 int64_t time_step = 0;
295 int64_t checkpoint_epoch = 0;
296 uint16_t id_length = 0;
297 uint64_t payload_length = 0;
298};
299
310inline void encode_record_prefix(std::uint8_t* out, const RecordPrefix& p) {
311 // byte encode each field, advancing the pointer as we go.
312 byte_io::store_little_u32(out, p.magic);
313 out += sizeof(p.magic);
315 out += sizeof(p.wire_version);
316 byte_io::store_little_s64(out, p.time_step);
317 out += sizeof(p.time_step);
318 byte_io::store_little_s64(out, p.simulation_timestamp);
319 out += sizeof(p.simulation_timestamp);
320 byte_io::store_little_s64(out, p.checkpoint_epoch);
321 out += sizeof(p.checkpoint_epoch);
322 byte_io::store_little_u16(out, p.id_length);
323 out += sizeof(p.id_length);
324 byte_io::store_little_u64(out, p.payload_length);
325 out += sizeof(p.payload_length);
326}
327
333inline void decode_record_prefix(const std::uint8_t* in, RecordPrefix& p) {
334 // byte decode each field, advancing the pointer as we go.
335 p.magic = byte_io::load_little_u32(in);
336 in += sizeof(p.magic);
338 in += sizeof(p.wire_version);
339 p.time_step = byte_io::load_little_s64(in);
340 in += sizeof(p.time_step);
341 p.simulation_timestamp = byte_io::load_little_s64(in);
342 in += sizeof(p.simulation_timestamp);
343 p.checkpoint_epoch = byte_io::load_little_s64(in);
344 in += sizeof(p.checkpoint_epoch);
345 p.id_length = byte_io::load_little_u16(in);
346 in += sizeof(p.id_length);
347 p.payload_length = byte_io::load_little_u64(in);
348 in += sizeof(p.payload_length);
349}
350
368
379inline Status read_record_prefix(std::istream& in, RecordPrefix& p) {
380 if (!byte_io::read_u32_le(in, p.magic)) return Status::Eof;
381 if (!byte_io::read_u8(in, p.wire_version)) return Status::Eof;
382 if (!byte_io::read_i64_le(in, p.time_step)) return Status::Eof;
385 if (!byte_io::read_u16_le(in, p.id_length)) return Status::Eof;
387 return Status::Ok;
388}
389
390} // namespace wire_format
391} // namespace serialization
392} // namespace protocols
393} // namespace bmi
394} // namespace models
bool read_u32_le(std::istream &in, uint32_t &v)
Definition byte_io.hpp:154
bool read_u64_le(std::istream &in, uint64_t &v)
Definition byte_io.hpp:168
bool read_i64_le(std::istream &in, int64_t &v)
Definition byte_io.hpp:196
void write_i64_le(std::ostream &out, int64_t v)
Definition byte_io.hpp:190
void write_u32_le(std::ostream &out, uint32_t v)
Definition byte_io.hpp:148
uint8_t load_little_u8(const uint8_t *p)
Definition byte_io.hpp:106
void store_little_u8(uint8_t *p, uint8_t v)
Definition byte_io.hpp:102
void write_u8(std::ostream &out, uint8_t v)
Definition byte_io.hpp:120
void write_u16_le(std::ostream &out, uint16_t v)
Definition byte_io.hpp:134
bool read_u8(std::istream &in, uint8_t &v)
Definition byte_io.hpp:126
void write_u64_le(std::ostream &out, uint64_t v)
Definition byte_io.hpp:162
bool read_u16_le(std::istream &in, uint16_t &v)
Definition byte_io.hpp:140
void decode_record_prefix(const std::uint8_t *in, RecordPrefix &p)
Decode a RecordPrefix from the in buffer.
Definition wire_format.hpp:333
Status
Outcome of a wire-format read operation.
Definition wire_format.hpp:239
Status read_record_prefix(std::istream &in, RecordPrefix &p)
Read a RecordPrefix from in by reading each field through byte_io's stream adapters.
Definition wire_format.hpp:379
void write_record_prefix(std::ostream &out, const RecordPrefix &p)
Write a RecordPrefix to out by writing each field through byte_io's stream adapters.
Definition wire_format.hpp:359
void encode_record_prefix(std::uint8_t *out, const RecordPrefix &p)
Encode a RecordPrefix into the out buffer using field-by-field little-endian encoding.
Definition wire_format.hpp:310
Definition deserialization.hpp:40
Definition AbstractCLibBmiAdapter.hpp:6
Fixed-prefix wire header for a serialization record.
Definition wire_format.hpp:249
static constexpr std::size_t PREFIX_BYTES
Fixed prefix size on disk for the current wire_version, computed from the field widths below.
Definition wire_format.hpp:283
static constexpr uint32_t MAGIC
'NGSR' written byte-first.
Definition wire_format.hpp:257
int64_t simulation_timestamp
Definition wire_format.hpp:294
static constexpr uint8_t VERSION
The wire-format version this code writes.
Definition wire_format.hpp:273