Design — pure-functional acquisition kernel (elastic fleet)¶
Status: model + naming locked; build sequenced below. Companion to
dsss-acquisition.md (roadmap) and the
acquisition guide.
Mental model — two kernels, two faces¶
Two streaming kernels chained; one (ddc_fn, acq_fn) pipeline per coarse Doppler
channel; channels are the unit of parallel work. Every kernel is a pure
transducer — f(config, scratch, state_in, input) → (state_out, output),
nothing retained between calls. That purity is what makes the fleet elastic:
the shipped unit of work is (descriptor, state_in, input_block), and any pod
can continue a channel from it — time-split, restart, rebalance, un-rewindable
live socket.
Two faces over the one engine, both first-class:
| face | what it is | for |
|---|---|---|
OO object (Acquisition, Ddcr) |
stateful engine holding its own mutable state, threaded across push/execute |
the simple single-stream case; unchanged public API |
pure run (acq_run, ddcr_run + serializable state) |
f(engine-as-config, state_in, input) → (state_out, hits) |
the orchestrator, pods, Rust FFI — anything elastic |
The two faces are the same engine: the OO object owns its state; the pure
*_run face takes state_in / state_out blobs so a fresh engine (rebuilt
from the descriptor) can resume bit-identically. The serializable state triplet
(state_bytes/get_state/set_state) is generated by jm's serializable
flag — the same mechanism lo/cic/fir/ddcr use — so it is added
alongside the OO API, never a replacement.
Three roles (so "knows nothing" ≠ "rebuild plans every call")¶
- config — immutable; built once per pod from a small descriptor (PN
replica, FFT plans, thresholds, grid sizing). A
constinput to every call, never mutated → purity holds. Rebuilding pffft plans per call would be absurd; the descriptor→config build is the once-per-pod step, cached by descriptor. - state — the serializable flat POD that is "everything a fresh pod
needs." Threaded in → out each call via
state_in/state_out. - scratch — per-worker workspace; holds no meaning (reused, never state).
In the shipped engine this lives inside
acq_state_talongside the mutable state; the descriptor (theacq_create_burst/acq_create_continuousargs) plays the config role.
State blobs (flat, versioned POD)¶
acq_fnstate: unconsumed ring samples (the partial frame,< n) + running sample offset (the code-phase anchor — carried so a resumed pod keeps a continuous phase reference) +nc_surface[n]+nc_count. Header stampsmagic/version/n/n_noncohfor validation; a mismatch is rejected, never reinterpreted.ddc_fnstate: NCO phase + every filter's delay line (FIR history, CIC integrator/comb, halfband, resampler fractional phase). Heterogeneous — the harder serialization.
C API shape (acq)¶
The engine stays one opaque acq_state_t (descriptor = config, built by
acq_create_burst or acq_create_continuous; scratch + mutable state live
inside it). The pure face is the
serializable triplet + acq_run, mirroring ddcr:
#include <complex.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include "acq/acq_core.h"
int
main (void)
{
/* config: build once per pod from the physics descriptor. Two
constructors over one engine -- burst combines coherently across code
repetitions, continuous never does. (There is no bare acq_create, and
no max_noncoh: non-coherent looks are auto-selected against the
internal ACQ_N_NONCOH_SAFETY_CEILING.) */
acq_state_t *(*create_burst) (const uint8_t *, size_t, size_t, size_t,
double, double, double, double, double, int)
= acq_create_burst;
acq_state_t *(*create_cont) (const uint8_t *, size_t, size_t, double,
double, double, double, double, double, int,
size_t, double)
= acq_create_continuous;
/* serializable state (jm `serializable` flag generates the Python
triplet). */
size_t (*bytes) (const acq_state_t *) = acq_state_bytes;
void (*get) (const acq_state_t *, void *) = acq_get_state;
int (*set) (acq_state_t *, const void *) = acq_set_state; /* 0 ok / -1 */
/* pure run: (state_in, input) -> (state_out, hits). Either blob may be
NULL (NULL in = fresh; NULL out = discard). */
size_t (*run) (acq_state_t *, const void *, void *, const float complex *,
size_t, acq_result_t *, size_t)
= acq_run;
printf ("acq_fn surface: %d\n",
(create_burst != 0) + (create_cont != 0) + (bytes != 0)
+ (get != 0) + (set != 0) + (run != 0));
return 0;
}
Acquisition (the object) owns one acq_state_t and forwards push →
acq_push; the pure acq_run face reuses the same engine with explicit
state_in/state_out. Bit-identical to an uninterrupted run.
Elastic fan-out¶
- shard = one coarse channel = a
(ddc_fn, acq_fn)pure pipeline. - threads: share the descriptor, one engine + state per worker.
- processes/pods: ship
(descriptor, state, input_block). The node rebuilds the engine from the descriptor (cached), injects the state, runs, returns(state_out, hits). Only POD + samples cross a boundary — never plan pointers. - non-coherent integration is inside
acq_fn's state; no external merge.
Build sequence¶
- (done — PR #259) physics sizing API; the foundation.
- (done — PR #260)
acq_fnserializable state +acq_run— flat-POD state (unconsumed ring samples + nc surface + counters) on the existingacq_state_t;acq_state_bytes/get_state/set_state+acq_run. Bit-exact vs an uninterrupted run + state round-trip, verified intest_acq_core.c. (serializable = trueonacq.tomlfor theAcquisitionPython triplet landed same-day, PR #268.) - (done — PRs #261, #265)
ddc_fnserializable state —ddcr_run+ the heterogeneous leaf serializers (lo/cic/fir/resamp/hbdecim/hbdecim_r2c + RateConverter), adopted onLO/CIC/FIRvia theserializableflag. - (done — PRs #270, #297) Orchestrator over the two pure kernels —
src/doppler/dsss/orchestrator.py(Acquirer/CoarseChannel): thread-pool fan-out over coarse-Doppler shards, bit-identicalget_state/set_statepod hand-off.
The OO objects (Acquisition, Ddc/Ddcr) are preserved at every step.