State Serialization — the standard bytes interface¶
This is the internals doc — the envelope, the cursor helpers, and the CI gate. For the practitioner's path (the basic pattern, composed receivers, elastic pod hand-off) see the Checkpoint & Resume guide.
Every stateful doppler object can hand its running state to a fresh instance and resume bit-for-bit — across a thread, a process, or a pod. A decimator serialized at sample 1100 and restored into a brand-new decimator produces the exact same next sample it would have produced uninterrupted. This is the elastic face: scale a pipeline out, checkpoint it, migrate it, and the DSP doesn't notice.
The design rests on one distinction:
Serialization is module-specific; the bytes interface is not.
Only lo knows it holds a phase; only fir knows it holds a delay line; only
acq knows it holds a sample ring and a non-coherent surface. What to pack is
the module's business. But the envelope around those bytes — the type tag, the
version, the validation, the language faces — is identical for every object, and
is owned once, centrally, in native/inc/doppler/dp_state.h.
The two layers¶
| Layer | Owns | Where |
|---|---|---|
| Bytes interface (universal) | the envelope, cursors, validation, the ABI contract, the Python/test faces | native/inc/doppler/dp_state.h, native/inc/doppler/dp_state_pyhelp.h, native/tests/dp_state_test.h |
| Serialization (per-module) | which fields to pack, in what order | each native/src/<obj>/<obj>_core.c |
A module's get_state/set_state stamps the standard header (one call), then
packs or unpacks its own fields through the cursor helpers. The build system
asks jm to generate the Python binding for this triplet, never the C bodies
— jm can't see the runtime, config-dependent sizes (num_taps, ring capacity)
that live only in create().
The ABI triplet¶
Every serializable C object exposes exactly three functions (sibling to
reset), plus the optional pure-transducer run:
size_t obj_state_bytes(const obj_state_t *s); /* serialized size */
void obj_get_state (const obj_state_t *s, void *blob); /* serialize */
int obj_set_state (obj_state_t *s, const void *blob); /* restore: DP_OK / DP_ERR_INVALID */
set_state always opens with dp_state_validate(), so a blob from a
different object, a different format version, a foreign endianness, or a
different configuration is rejected (DP_ERR_INVALID) — never silently
reinterpreted. This closed a real latent bug: before the standard, leaf objects
had no envelope and accepted any blob of the right length, corrupting state.
The envelope¶
Every blob begins with a 16-byte self-describing header:
typedef struct
{
uint32_t magic; /* per-object FourCC type tag, e.g. DP_FOURCC('A','C','Q','R') */
uint16_t version; /* per-object blob format version */
uint8_t endian; /* DP_STATE_ENDIAN at serialize time */
uint8_t flags; /* reserved; 0 */
uint32_t bytes; /* total blob size; equals obj_state_bytes() */
uint32_t _pad; /* reserved; 0 */
} dp_state_hdr_t;
16 bytes keeps a following double/uint64_t naturally 8-aligned. The magic
is the type identity (a human-readable FourCC in a hex dump); bytes is the
one size invariant, and it agrees with the Python exact-size gate.
Layout¶
leaf: [ dp_state_hdr_t ] [ module payload ]
composition: [ dp_state_hdr_t ] [ extra? ] [ child blob ] [ child blob ] ...
state_bytes = sizeof(hdr) + sizeof(extra) + Σ child_state_bytes
A composition embeds each child as a self-contained sub-blob — the child
carries its own header, so it is independently validatable, and migrating a leaf
(which changes its size) propagates automatically because the parent sums
child_state_bytes. For example a ddcr blob is
[hdr][ddcr_extra{rate}][r2c][lo][rc], where r2c, lo, and rc are each a
full leaf/sub blob with its own envelope.
What goes in the blob — a mutator's value is state¶
A blob holds everything the next sample depends on that create() cannot
rebuild from its own arguments. Four rules decide each field (#2022):
- Running state is packed: a phase, a delay line, an accumulator, an RNG, a loop's integrator.
- A mutator's value is state. A value any post-create mutator can change
travels in the blob. A mutator is a setter, a writable property,
configure*,retune,reconfigureorreseed.set_statechecks the value with that mutator's own predicate, one helper both call. Only the config no mutator reaches is restored bycreate(). - Create-time config that sizes the blob, or changes what it means, is a
reject key:
sf,sps, a mode, a rate.set_staterefuses a blob whose key differs from the target's. It decodes into a temporary, checks it whole, then commits, so a refused blob changes nothing. - A blob that gains a field bumps the object's
*_STATE_VERSION, so a blob of the old layout is refused rather than read as the new one.
A mutator whose value sizes the blob makes the blob target-sized: it
restores only into a target the same mutator gave the same size.
BurstDespreader.set_acq() is the precedent (#2041). Its acq code travels,
and a blob taken with a 127-chip acq code restores only into a despreader
whose acq code is also 127 chips; any other target refuses it before reading
anything.
Never in a blob:
- Handles to the outside world: telemetry sinks, event logs, thread counts, refill callbacks.
- Addresses: a pointer
create()re-establishes. - The wall clock.
A blob is a function of the object. Two objects built and fed the same give byte-identical blobs: no uninitialised padding, and no unused buffer tail. A blob that varies cannot be judged by a restore (#2052, #2076).
The gate¶
src/doppler/tests/test_mutator_state.py holds every serializable object to
these rules, with nothing to register.
What it finds:
- every public member of the class Python gets, wherever it was bound: a
writable property, and every method but the lifecycle (
reset,close,destroy, the state triplet). Nothing is called a reader for its return type: a reader is a call the probe sees change nothing; - each member's signature, from the class's
.pyistub. Each defaulted parameter is varied on its own, by keyword, since a new defaulted keyword is how a setter usually grows; - every C setter Python cannot call, from the headers.
Two exact rules come from the manifest. A handle call (a telemetry sink
or event log, plus labels) passes as HANDLE, because a handle is never in a
blob. A stream call (a non-void arg_type, or jm's built-in
step/steps) takes the object's input, not a value to keep, so its effect
is judged against the uncalled object.
How it probes each member. It takes an instance and a feed from this
page's Python matrix (test_state_serialization.CASES). It picks values the
member accepts and that change what the object does. It then restores one
value's blob into a default target, a target at the same value, targets at
other values, and targets beside it: a scalar a hair either side, an array
of the same length with other content. Every target that accepts the blob
must then reproduce the source: its readback (every property), its
continuation output and its blob. That gives these verdicts:
| verdict | meaning |
|---|---|
TRAVELS |
passes: the default target and one at another value take the blob and match |
KEYED |
passes: the default target refuses it, and the same-value target takes it and matches |
READS |
passes: hands back a value, changes nothing in any row, and keeps no value (no required parameter, a stream call, or jm's <m>_max_out) |
HANDLE |
passes: attaches a telemetry sink or event log |
LOST |
fails: a target took the blob and then differed |
UNPROBED |
fails: the probe could not show it either way, and the list says why |
NONDETERMINISTIC |
fails: two identical builds differed |
CRASHES |
fails: the probe dies by a crash signal, confirmed in a child process |
The list. Anything that does not pass is listed once in
scripts/.mutator-state-exempt, with exactly its verdict and a reason. That
list only shrinks. An entry that now passes, names no mutator, or carries
another verdict is red. make tests-ssot refuses a key the merge base did
not hold, and a key that moved into LOST, NONDETERMINISTIC or CRASHES. Two more verdicts appear only there. C_ONLY marks a setter
with no Python face (a C-side twin is #2077). NO_RECIPE marks a class with
no matrix row (#2078). Each family of known violations has its issue
(#2079–#2084), and its fix deletes its own lines.
Cursors¶
Hand-packing is a few bounds-checked calls on a writer/reader cursor, not raw
pointer arithmetic. The cursors use a sticky-error model: an overrun sets
err and subsequent operations no-op, so call sites stay flat.
void
dp_lo_get_state (const dp_lo_state_t *s, void *blob)
{
dp_writer_t w = dp_writer_init (blob, dp_lo_state_bytes (s));
dp_w_hdr (&w, LO_STATE_MAGIC, LO_STATE_VERSION, dp_lo_state_bytes (s));
dp_w_f64 (&w, s->phase); /* pack the module's own fields */
dp_w_f64 (&w, s->phase_inc);
}
int
dp_lo_set_state (dp_lo_state_t *s, const void *blob)
{
int rc = dp_state_validate (blob, dp_lo_state_bytes (s),
LO_STATE_MAGIC, LO_STATE_VERSION);
if (rc != DP_OK)
return rc; /* wrong object / version / size → reject */
dp_reader_t r = dp_reader_init (blob, dp_lo_state_bytes (s));
r.off = sizeof (dp_state_hdr_t);
s->phase = dp_r_f64 (&r);
s->phase_inc = dp_r_f64 (&r);
return DP_OK;
}
The writer/reader pairs cover u32/u64/f64/cf32/f32/raw bytes, plus
dp_w_reserve/dp_r_reserve for handing a region to a child's get/set.
Helper macros — the three serializer shapes¶
Almost every serializer is one of three shapes, and each has a macro so the
triplet is a few lines, not a hand-rolled envelope. All live in dp_state.h.
POD — DP_DEFINE_POD_STATE(pfx, STATE_T, MAGIC, VERSION)¶
A pointer-free struct is its own state — snapshot it whole. Defines all
three functions; place it once beside reset. Restoring the config fields is a
harmless no-op into an identically-built instance. An embedded POD child
(e.g. a dp_loop_filter_state_t by value) is captured automatically, so a
composition of by-value POD members is still one DP_DEFINE_POD_STATE.
/* native/src/loop_filter/loop_filter_core.c */
DP_DEFINE_POD_STATE(dp_loop_filter, dp_loop_filter_state_t,
LOOP_FILTER_STATE_MAGIC, LOOP_FILTER_STATE_VERSION)
Field-wise — DP_GET_OPEN / DP_SET_OPEN¶
When the struct owns heap buffers, pack the running fields and every
mutator's value (What goes in the blob),
and let create() re-derive the buffers and the config no mutator reaches. DP_GET_OPEN(MAGIC, VER, BYTES) stamps
the envelope and opens a writer _w; DP_SET_OPEN(MAGIC, VER, BYTES) validates
and opens a reader _r positioned past the header (early-returns
DP_ERR_INVALID on a bad blob). The body is just dp_w_*/dp_r_* calls. The
function parameters must be named s (the state) and blob.
size_t dp_delay_state_bytes(const dp_delay_state_t *s)
{ return sizeof(dp_state_hdr_t) + sizeof(uint64_t)
+ 2 * s->capacity * sizeof(double _Complex); }
void dp_delay_get_state(const dp_delay_state_t *s, void *blob)
{
DP_GET_OPEN(DELAY_STATE_MAGIC, DELAY_STATE_VERSION, dp_delay_state_bytes(s));
dp_w_u64(&_w, s->head);
dp_w_bytes(&_w, s->buf, 2 * s->capacity * sizeof(double _Complex));
}
int dp_delay_set_state(dp_delay_state_t *s, const void *blob)
{
DP_SET_OPEN(DELAY_STATE_MAGIC, DELAY_STATE_VERSION, dp_delay_state_bytes(s));
s->head = (size_t)dp_r_u64(&_r);
dp_r_bytes(&_r, s->buf, 2 * s->capacity * sizeof(double _Complex));
return DP_OK;
}
A borrowed/owned pointer that
create()re-establishes (e.g. a code table) is config, not state. Don't serialize its address — it differs across instances and makes the blob non-canonical. Either skip it (field-wise) or, if you snapshot the whole struct, NULL it in the serialized copy and preserve the live value inset_state. Seedp_dll_get_state.Decode, check, then commit.
set_statewrites nothing of the object until every check that can refuse has passed. Read what the blob claims into locals or a temporary, check it whole, and only then assign.DP_SET_OPENis no guard for the payload: it checks the envelope (magic, version, endianness, and a size computed from THIS instance) and never reads a payload field, so a blob with one field forged keeps the size and passes it. Every field that sizes or indexes this instance's buffers (a code length, a segment count, a ring's geometry) is therefore a reject key. Every running field that indexes, or is cast to, an index is held to what a live instance can carry. A refused blob then leaves the object byte-identical. A child restored to check the parent is handed its own blob back on a mismatch.dp_dll_set_stateis the worked example (#2092): it used to read the whole struct in first, and a refusal left it holding the blob's NULLs in place of its code and buffers.A wall-clock or other non-deterministic quantity (a
dp_sample_clock_tanchor, a running sample counter kept only for cross-call bookkeeping) is runtime state, not resumable DSP state — never part of the blob. Bit-exact resume means "the exact same next sample," not "the exact same wall-clock reading" — a restored object re-observes or re-derives timing fresh, the same way a borrowed pointer is re-established bycreate()rather than carried in the blob.That rule does not reach a counter that is compared with a child's. A parent count that is diffed against a child's serialized position is in the child's timebase whether it is serialized or not.
DsssReceiverkept such a count,samples_fed, and subtracted it from its acquisition's hit offset. It was left out of the blob as runtime state, andconfigure_search_raw()reset the child but not the count, so after a restore or a reconfigure the two timebases parted and the hand-off read outside its input (#2042). Read the child's own position instead (dp_acq_position()), and keep no parallel counter.
Composition — DP_W_CHILD / DP_R_CHILD¶
Nest each serializable child as a self-validating sub-blob. state_bytes sums
<child>_state_bytes(child_ptr); get/set then writes/reads each child via
the reserve cursors. child_ptr may be a pointer member or the address of an
embedded-by-value member (&s->lf). DP_R_CHILD returns DP_ERR_INVALID from
the enclosing set_state if a child rejects, so a composite restore is
atomic-by-validation.
size_t dp_mpsk_receiver_state_bytes(const dp_mpsk_receiver_state_t *s)
{ return sizeof(dp_state_hdr_t) + dp_carrier_nda_state_bytes(&s->car)
+ dp_symsync_state_bytes(&s->sync) + dp_fir_state_bytes(s->mf)
+ /* running scalars … */; }
void dp_mpsk_receiver_get_state(const dp_mpsk_receiver_state_t *s, void *blob)
{
DP_GET_OPEN(MPSK_RECEIVER_STATE_MAGIC, MPSK_RECEIVER_STATE_VERSION,
dp_mpsk_receiver_state_bytes(s));
DP_W_CHILD(&_w, carrier_nda, &s->car); /* embedded by value */
DP_W_CHILD(&_w, symsync, &s->sync);
DP_W_CHILD(&_w, fir, s->mf); /* pointer member */
/* … then dp_w_* the running scalars … */
}
The run transducer¶
For a single-execute object, DP_DEFINE_RUN(pfx, STATE_T, IN_T, OUT_T)
generates the identical pure-transducer wrapper
pfx_run(state_in, state_out, in, n_in, out, max_out): optionally restore
state_in, run one execute, optionally emit state_out. (Frame/push shapes
like acq keep a hand-written run.)
The Python face¶
For a plain object, one manifest flag is the whole story:
jm apply then generates the Python binding triplet — state_bytes() -> int,
get_state() -> bytes, set_state(bytes) -> None (size-mismatch / rejected-blob
→ ValueError, non-bytes → TypeError) — and the matching .pyi stubs, over
the C ABI below the envelope. No hand-binding.
import numpy as np
from doppler.resample import RateConverter
a = RateConverter(0.5)
a.execute(np.ones(2048, dtype=np.complex64))
blob = a.get_state() # bytes; len(blob) == a.state_bytes()
b = RateConverter(0.5) # a fresh, identically-built instance
b.set_state(blob) # resume from a's exact state
As of jm 0.20.0 the flag is the whole story for the two harder kinds too — no hand-binding anywhere:
- Sacred fragments. An object whose
_ext_<obj>.cfragment is hand-owned (a bespoke property, a customexecute) is not regenerated, sojm applytransplants the triplet into it — injecting the wrappers +PyMethodDefrows idempotently, leaving every hand-written binding intact (gh-404).DDCandRateConverterare such objects. - Handle modules. A
kind="handle"module (ddc_fn'sDdcr) generates the triplet over its opaque handle whenserializable = "true"is set on[module.<name>](gh-403).
Testing¶
The C and Python faces are tested by shared harnesses, so each new serializable type subscribes to the same invariants rather than re-deriving them.
- C —
DP_STATE_ROUNDTRIP_TEST(pfx, a, b)innative/tests/dp_state_test.h:get_state(a)→set_state(b)isDP_OK, then a magic-clobbered blob isDP_ERR_INVALID. Eachtest_<obj>_core.calso splits a real stream and asserts bit-exact resume. - Python —
src/doppler/tests/test_state_serialization.py, a parametrized matrix over every block-executetype (LO, CIC, FIR, DDC, RateConverter) asserting bit-exact elastic resume across a mid-stream split and the self-validating rejects (short / long / clobbered →ValueError, non-bytes→TypeError).
Portability¶
Blobs are native-endian POD for same-machine / same-architecture resume
(thread, process, pod) — the realistic deployment for elastic scaling. The
endian byte is stamped and rejected on mismatch; there is deliberately no
cross-endian byte-swap. The format is not promised across doppler versions: a
version bump (or any size/layout change) is caught by dp_state_validate, so a
stale blob fails loudly instead of corrupting state.
Adding a serializable object¶
When you build a new object with just-makeit (see the workflow in
CLAUDE.md), serialization is a required step for anything stateful — every
object that carries running state between calls must speak this interface, so the
whole library stays uniformly resumable. The rule of thumb: if it has a reset
that does more than nothing, it needs the triplet. Stateless objects (pure
converters, FFT plans, by-value analyzers) are exempt.
-
Write the C triplet beside
resetin<obj>_core.c, with a per-object<OBJ>_STATE_MAGIC/_VERSIONin the header (#include "doppler/dp_state.h"). Serialize the running state and every mutator's value;create()restores only the config no mutator reaches, and a key that sizes the blob is checked (What goes in the blob). Pick the macro for the shape (see Helper macros):- pointer-free POD →
DP_DEFINE_POD_STATE(...)(one line). - owns heap buffers → field-wise with
DP_GET_OPEN/DP_SET_OPEN+ thedp_w_*/dp_r_*cursors; skip pointers (re-derived bycreate()). - composition →
DP_W_CHILD/DP_R_CHILDover each serializable child.
Add
DP_DEFINE_RUN(...)for the pure<obj>_runtransducer if it's a single-executeobject. - pointer-free POD →
-
Flip the flag —
serializable = "true"inobjects/<obj>.toml, thenjm apply. As of jm 0.20.0 the flag is the entire Python story for every object kind: jm generates thestate_bytes/get_state/set_statebinding +.pyi, transplanting the triplet into a hand-owned (sacred)_ext_<obj>.cfragment when one exists (gh-404), and generating it over the handle for akind="handle"module (gh-403). clang-format the touched fragment (jm emits 4-space; doppler is GNU 2-space). -
Test both faces — a C round-trip + reject in
test_<obj>_core.c(theDP_STATE_ROUNDTRIP_TESTmacro, plus a buffer/field equality check for field-wise/composition shapes), and an entry in the parametrized Python matrixsrc/doppler/tests/test_state_serialization.py. The matrixfeedreturns an array the continuation compare checks bit-for-bit; for an output-less object, returnnp.frombuffer(o.get_state(), np.uint8)so the post-block state blob itself is the resume observable. That row is also what the mutator gate probes your object's mutators with. -
Drop it from the burn-down — remove
<obj>fromscripts/.serializable-ignore.
Enforcement — the gate (it can't rot)¶
scripts/check_serializable.py (wired into the CI docs job) makes the stance
mandatory: every object in objects/*.toml must resolve to exactly one of —
serializable = "true"in its TOML, or- listed in
scripts/.serializable-stateless— a reviewed permanent opt-out for objects with no resumable state (pure converters, FFT plans, by-value analyzers). It lives in a sidecar file, not the TOML, because jm's manifest dumper only round-trips keys it knows.
An object that declares neither fails CI — unless it is still on the
rollout burn-down list scripts/.serializable-ignore, which shrinks to empty as
objects are completed. A stale ignore entry (now resolved) also fails, keeping
the list honest. Net effect: a new stateful object cannot ship without making a
conscious, reviewed choice.
That gate decides whether an object has a blob. What goes in it is held by the mutator gate: The gate, under "What goes in the blob".
Status¶
As of jm 0.20.0, serializable = "true" is the entire Python binding for
every object kind — regenerable, sacred-fragment (jm transplants the triplet,
gh-404), and kind="handle" (jm generates it over the handle, gh-403):
| Type | C triplet | Python ser/des | Binding |
|---|---|---|---|
LO, CIC, FIR, Acquisition, generators |
✅ | ✅ | jm-auto from the flag |
DDC, RateConverter, compositions, loops |
✅ | ✅ | jm transplant into sacred frag |
Ddcr (ddc_fn, kind="handle") |
✅ | ✅ | jm-auto over the handle |
The rollout is complete: every stateful object is serializable (the gate's
burn-down list is empty). A CI gate (scripts/check_serializable.py, see
Enforcement) holds the line going forward —
a new object must declare serializable = "true" or opt out as stateless.
The coverage spans every stateful family — generators and tracking loops
(a phase, an RNG), filters and resamplers (delay lines, integ/comb
chains), the pointer-free POD set (snapshot whole), the field-wise set
(structs with pointers pack running fields and skip them), compositions
(delegate to their children's triplets), and the
correlator/detector/analyzer family, whose opaque FFT plans and work
buffers are rebuilt by create() while ring/pending buffers are
zero-padded to a fixed capacity so blobs stay canonical. The
authoritative per-object roster is the code, not this page: every
objects/*.toml either declares serializable = "true" or is listed in
scripts/.serializable-stateless — the gate fails CI on any object that
does neither.
The payoff — elastic pod hand-off¶
The orchestrator cashes it in: CoarseChannel.get_state/set_state (and the
bank-level Acquirer.get_state/set_state) compose their children's blobs (DDC
mixer/decimator + the BurstAcquisition or BurstCapture search) behind a small Python envelope. So a
running acquirer is the documented (descriptor, state, block) triple —
checkpoint a bank mid-stream, rebuild it from its descriptor on another pod,
restore the blob, and the search continues detection-for-detection identical
to an uninterrupted run (test_bank_pod_handoff_resumes_bit_exact).
Language faces¶
The bytes interface is reached from every binding doppler ships, all over the same C triplet:
- C — the ABI itself (
<obj>_state_bytes/get_state/set_state). - Python —
serializable = "true"→ jm generatesstate_bytes()/get_state() -> bytes/set_state(bytes)(size/clobber/non-bytes rejects raiseValueError/TypeError). - Rust —
ffi/rust'simpl_serializable!macro exposesstate_bytes()/get_state() -> Vec<u8>/set_state(&[u8]) -> Result<(), StateError>onLo/Nco/Fir/AccF32/AccCf64.
The rollout is complete — there is no remaining open work on the standard itself.