# Design — pure-functional acquisition kernel (elastic fleet)

**Status:** model + naming locked; build sequenced below. Companion to
[`dsss-acquisition.md`](dsss-acquisition.md) (roadmap) and the
[acquisition guide](../guide/dsss-acquisition.md).

## 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** (`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 `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 `acq_state_t` alongside the mutable
    state; the descriptor (`acq_create` args) plays the **config** role.

## State blobs (flat, versioned POD)

- **`acq_fn` state:** 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 stamps
    `magic`/`version`/`n`/`n_noncoh` for validation; 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 `acq_state_t` (descriptor = config, built by
`acq_create`; scratch + mutable state live inside it). The pure face is the
serializable triplet + `acq_run`, mirroring `ddcr`:

```c
/* config: build once per pod from the physics descriptor (see acq_create). */
acq_state_t *acq_create (const uint8_t *code, size_t code_len, size_t reps,
                         size_t spc, double chip_rate, double cn0_dbhz,
                         double doppler_uncertainty, double pfa, double pd,
                         int noise_mode, size_t max_noncoh);

/* serializable state (jm `serializable` flag generates the Python triplet). */
size_t acq_state_bytes (const acq_state_t *);
void   acq_get_state   (const acq_state_t *, void *blob);
int    acq_set_state   (acq_state_t *, const void *blob); /* 0 ok / -1 reject */

/* pure run: (state_in, input) -> (state_out, hits). Either blob may be NULL
 * (NULL in = fresh; NULL out = discard). */
size_t acq_run (acq_state_t *, const void *state_in, void *state_out,
                const float complex *in, size_t n_in,
                acq_result_t *hits, size_t max_hits);
```

`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

1. **(done — PR #259)** physics sizing API; the foundation.
1. **(done — PR #260)** `acq_fn` serializable state + `acq_run` — flat-POD state
    (unconsumed ring samples + nc surface + counters) on the existing
    `acq_state_t`; `acq_state_bytes`/`get_state`/`set_state` + `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 is the follow-up flag flip.)
1. **(done — PRs #261, #265)** `ddc_fn` serializable state — `ddcr_run` + the
    heterogeneous leaf serializers (lo/cic/fir/resamp/hbdecim/hbdecim_r2c +
    RateConverter), adopted on `LO`/`CIC`/`FIR` via the `serializable` flag.
1. **Orchestrator** over the two pure kernels (mixer bank, fan-out, hit dedupe)
    — the next step.

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