Skip to content

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

source ─► ddc_fn (mix f_coarse [+decimate]) ─► acq_fn (acquire) ─► hits

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 (dp_acq_run, dp_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 const input 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 dp_acq_state_t alongside the mutable state; the descriptor (the dp_acq_create_burst/dp_acq_create_continuous args) plays the config role.

State blobs (flat, versioned POD)

  • acq_fn state (dp_acq_state_bytes / dp_acq_get_state in native/src/acq/acq_core.c, version 6), in the order it is written:

    1. the envelope, then acq_extra_t: n, n_noncoh, max_peaks, the running sample offset samples_consumed (the code-phase anchor, carried so a resumed pod keeps a continuous phase reference), nc_count, n_twins and the block epoch;
    2. the carry, as the ring framer's own snapshot, a child blob: the partial frame (at most frame_n - 1 samples, zero-filled past them) and the framer's written and frame counts;
    3. nc_surface[n_surf] when n_noncoh > 1;
    4. the last dwell's twin rows/columns (2·max_peaks, zero past n_twins);
    5. a tiled engine's block-coherent accumulator and the block's raw epochs, only the epochs this block has written, zero past them (#2052).

    set_state refuses, writing nothing, a blob whose envelope or n/n_noncoh/max_peaks differ from the engine; whose carry the framer refuses; or whose counters disagree with one another: the stream position must be the framer's frames times frame_n, and the block epoch and the non-coherent look count must be where that many frames leave them (#2042). A mismatch is rejected, never reinterpreted.

  • ddc_fn state: 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 dp_acq_state_t (descriptor = config, built by dp_acq_create_burst or dp_acq_create_continuous; scratch + mutable state live inside it). The pure face is the serializable triplet + dp_acq_run, mirroring ddcr:

acq_fn C surface
#include <complex.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>

#include "doppler/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 only inside a tile over declared code-only
     epochs. (There is no bare acq_create, and no max_noncoh: the continuous
     engine auto-selects its non-coherent looks against the internal
     ACQ_N_NONCOH_SAFETY_CEILING; a burst engine never adds any.) */
  dp_acq_state_t *(*create_burst) (const float _Complex *, size_t, size_t,
                                double, double, double, double, double, int,
                                double, size_t)
      = dp_acq_create_burst;
  dp_acq_state_t *(*create_cont) (const uint8_t *, size_t, size_t, double,
                               double, double, double, double, double, int,
                               size_t, double)
      = dp_acq_create_continuous;

  /* serializable state (jm `serializable` flag generates the Python
     triplet). */
  size_t (*bytes) (const dp_acq_state_t *) = dp_acq_state_bytes;
  void (*get) (const dp_acq_state_t *, void *) = dp_acq_get_state;
  int (*set) (dp_acq_state_t *, const void *) = dp_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) (dp_acq_state_t *, const void *, void *, const float complex *,
                 size_t, acq_result_t *, size_t)
      = dp_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 dp_acq_state_t and forwards push → dp_acq_push; the pure dp_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

  1. (done — PR #259) physics sizing API; the foundation.
  2. (done — PR #260) acq_fn serializable state + dp_acq_run — flat-POD state (then the unconsumed ring samples + nc surface + counters; since version 5 the carry is the framer's snapshot) on the existing dp_acq_state_t; dp_acq_state_bytes/get_state/set_state + dp_acq_run. Bit-exact vs an uninterrupted run + state round-trip, verified in test_acq_core.c. (serializable = true on acq.toml for the Acquisition Python triplet landed same-day, PR #268.)
  3. (done — PRs #261, #265) ddc_fn serializable state — dp_ddcr_run + the heterogeneous leaf serializers (lo/cic/fir/resamp/hbdecim/hbdecim_r2c + RateConverter), adopted on LO/CIC/FIR via the serializable flag.
  4. (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-identical get_state/set_state pod hand-off.

The OO objects (Acquisition, Ddc/Ddcr) are preserved at every step.