Skip to content

AsyncDsssReceiver — the continuous DSSS receiver, from spec to object

One page for continuous asynchronous DSSS, consolidated 2026-09-02 from five: the receiver specification (async-dsss-spec.md, §1–§2), the asynchronous despreader (async-symbol-despreader.md, §3) and its original working design (async-despreader-working-design.md, §3.6), the continuous use case that had grown inside burst-bank.md as its §11 (§5), and the searcher design written for it (acq-multi-peak.md, §6–§10, §12–§13). §4 is the receiver as built; §11 is what it must gain for the multi-emitter, always-searching use case. What the spec said about bursts and about fleet service boundaries is not this receiver's concern and was cut (git has it); burst-bank.md is bursts only.


1. The specification

The waveform and the receiver requirements, as given:

  • Level: Any
  • Nominal frequency: 2.5 GHz
  • Frequency uncertainty: +/- 50 kHz
  • Frequency rate of change: < 500 Hz/s (1)
  • Waveform: Continuous DSSS BPSK
  • Waveform exemplary use-case:
    • Code: CCSDS Command link Gold Code 1023 chips repeating
    • Chip rate: 3.069 Mcps
    • Modulation: Asynchronous Rectangular BPSK @ 2700 bps
  • Es/N0 >= 5 dB (2)

1.1 Target implementations

  • Complete C receiver in libdoppler.{a,so}, to compile into C/C++ applications.
  • Complete Python receiver, the same object through the binding.

What the application wants from it: continuous reception — tracking loops that run for the life of a pass, not bounded bursts — and parallelism on one server, not a cluster. The server has many cores, and the design should use as many processes and threads as the work needs: the searcher on its own, each assigned receiver on its own, all fed from one stream on one machine (§5, §11). What is ruled out is the fleet — pods, a scheduler, state hopping between nodes. The receiver's get_state/set_state remain for a checkpoint and restart mid-pass and for handing a receiver between processes on the same box, not for scaling across machines. The fleet and per-burst service shapes the original spec also described belong to the burst chain and are not this page's concern.

1.2 Notes on the specification

(1) The rate bound is the standard LEO worst-case nadir-pass figure, f_dot_max = (f_c/c)·(v²/h): at 2.5 GHz and a representative 800 km altitude it is ~579 Hz/s, so 500 Hz/s is that bound with a small margin.

(2) The Es/N0 floor is measured, not chosen: the receiver's characterization (#99) found a hard pull-in cliff between 4 and 5 dB — 3 and 4 dB never lock (BER near chance), 5 dB locks cleanly (BER matching theory) — independent of loop bandwidth (bn_car 0.005–0.02) and of Doppler rate (0–500 Hz/s). That cliff was measured on the coarse-hand-off pipeline, before the refining stage of §4 existed; it has not been re-measured with it, and may sit lower now. Treat 5 dB as the current floor, not a settled limit.

1.3 Derived: tracking loop bandwidths

Every tracking loop — the code DLL and the Costas carrier loop; there is no FLL — is sized to a loop SNR rho ≥ 20 dB at the Es/N0 floor, using the PLL relation rho(dB) = Es/N0(dB) − 10·log10(2·bn), where bn is the loop's noise bandwidth normalised to its own update rate (doppler.track.LoopFilter's convention, so the update rate cancels). At the floor, bn ≤ 10^((5 − 20)/10) / 2 ≈ 0.0158; the shipped rule is bn ≤ 0.01 for every loop, inside that bound. The code loop's per-epoch SNR is Es/N0 scaled by 1/epochs_per_symbol, which at this waveform is 3000/2700 ≈ 1.11 — within 0.5 dB — so the same bound applies to it without a separate derivation.

bn is not what sets the pull-in cliff of note (2): sweeping bn_car across 0.005–0.02 left it unchanged. The loop-SNR derivation sizes steady-state jitter once locked; pull-in below the floor is a separate behaviour, and the refining stage of §4 is what addresses it.

1.4 A second operating point

The C++ application's continuous waveform is the same shape at different numbers — a 1023-chip Gold code at 2 to 5 Mcps, a DDC from 13 MSa/s to twice the chip rate, D = 1, ±50 kHz to start and likely ±5 kHz after Doppler pre-compensation, up to ten emitters on one code at once — and a throughput floor of 30 MSa/s, comfortably. Those numbers, and what they do to the search and the receiver pool, are worked in §6.1 and §6.4; §11 is the receiver's side of them.


2. Acquisition

2.1 User-facing API

Two classes, Acquisition (continuous) and BurstAcquisition, over one C engine. Rather than one class with a mode and per-parameter "ignored in this mode" caveats, each exposes only the parameters that mean something for it. Both are thin front doors onto the same acq_state_t / acq_core.c — state, auto-sizing, push() and serialization shared — through two public constructors calling one internal builder with the mode fixed, the secondary-constructor idiom dll_core.h also uses.

One public name for the Doppler axis: doppler_bins. Rolling the shared epoch FFT by k bins produces a Doppler hypothesis exactly as a slow-time FFT row does. Internally the engine keeps two fields for the two mechanisms, only one of which is ever active: coherent_bins (the slow-time FFT depth from coherent multi-epoch integration — BurstAcquisition's axis) and window_bins (roll-tiled frequency windows, each a single-epoch FFT rolled to another hypothesis — Acquisition's axis). They are named for mechanism, not regime: the roll-tiled axis is not computed non-coherently, and "non-coherent" here means n_noncoh — repeated dwells accumulated for SNR at a fixed hypothesis set, an axis that composes with either mechanism.

Two sizing inputs for the coherent depth: code_only_epochs and doppler_rate. The continuous class runs a coherent depth D in blocks inside the waveform's pure-code window (§2.3), and D is auto-sized as the smaller of two bounds: ⌊(code_only_epochs + 1)/2⌋, so a whole block always fits in the window, and f_epoch/√1000 from doppler_rate, so the drift over one block stays inside half a slow-time bin. code_only_epochs is the count of whole epochs a window holds at any chip phase: the window is W symbols on the data clock (§5.4), which has no fixed relation to the code clock, so a partial epoch is lost at each edge and code_only_epochs = ⌊W · cps / L⌋ − 1 — 813 at 5 Mcps, 324 at 2, for the 450-symbol window. It defaults to 1, which is D = 1 and exactly the engine as it ran before — a waveform with no window loses nothing and sets nothing. Nothing else sizes it — no doppler_resolution, no max_noncoh. n_noncoh is auto-selected to meet pd at pfa and exposed read-only; its only bound is an internal safety valve (ACQ_N_NONCOH_SAFETY_CEILING, 256 looks) because the semi-analytical pd_predicted model turns non-monotonic past that — a modelling limit, not a sensitivity one.

Acquisition (continuous)

doppler_bins here is the window_bins mechanism (roll-tiled) for the span, with the slow-time coherent_bins axis inside each tile for the resolution and the gain — the two mechanisms together, which is what the window of §2.3 makes sound. Coherent combining across data is a structural mislock (task #67), and the block stride of §2.3 is what keeps the combined epochs inside the window.

Parameter Type Default Description
code NDArray[uint8] (required) Binary (0/1) code, segment, or preamble chips to search for; sets sf = len(code).
spc int 4 Samples per chip (>= 1).
chip_rate float 1e6 Chip rate in Hz (> 0).
symbol_rate float 1000.0 Continuous data-symbol rate in Hz (> 0).
cn0_dbhz float 50.0 Carrier-to-noise density in dB-Hz (> 0) -- the sensitivity used to size the search.
doppler_uncertainty float 0.0 One-sided Doppler search half-range in Hz; 0 = full native span (one doppler_bin). Tiles into doppler_bins windows whenever it exceeds one native span.
pfa float 1e-3 Target system (max-of-N) false-alarm probability, in (0,1).
pd float 0.9 Target detection probability, in (0,1).
noise_mode Literal["mean","median","min","max"] "mean" CFAR reference-cell aggregation mode.

BurstAcquisition

The burst front door over the same engine — doppler_bins there is the coherent_bins mechanism, auto-sized in [1, reps] for coherent gain over an unmodulated preamble. It is not this receiver's concern; its parameters and the burst chain are in dsss-burst-receiver.md.

2.2 Output data structure: DetectionEvent (the acquisition handoff)

DetectionEvent is the DATA -- the acquisition handoff is the ACTION (the process of converting a raw push() hit into this record and handing it to the next block/service); the two aren't the same thing, naming them separately on purpose.

The detection output has to be consumable by another thread or process — the orchestrator of §5 and §11, a C++ application — not just another Python object in the same interpreter, so it can't be the raw grid-relative indices (doppler_bin, code_phase) alone, since those are meaningless without also shipping the emitting object's own config (spc, doppler_res_hz, ...) alongside. Every field below is already converted to a physical unit, so the record is self-contained: a flat, pointer-free POD, safe to serialize across a thread or process boundary. In C it is what acq_build_handoff() produces from a hit and what seeds the receiver of §4.

One DetectionEvent record is emitted per detection event (i.e. once per push() hit, on both classes -- same shape, since both share the underlying engine):

Field Type Description
timestamp_ns uint64_t UNIX time (ns) this detection's samples occurred, per the codebase's existing dp_sample_clock_t convention (native/inc/timing/timing_core.h): epoch_real_ns + samples_consumed/fs, NOT a fresh syscall timestamp at emit time -- reproducible, and already how dp_header_t/SigMF-metadata timestamps are derived elsewhere in this project.
samples_consumed uint64_t The raw sample offset (since this engine's own stream start) this detection's epoch ended at -- the n that timestamp_ns above was derived from. Kept alongside timestamp_ns, not instead of it: replay-safe (no wall-clock dependency) and lets a consumer re-derive/cross-check the time against its own clock anchor.
chip_phase float Code phase in CHIPS (not raw samples) -- the code-tracking seed for the next stage.
doppler_hz_est float Coarse Doppler estimate in Hz, already folded/signed/scaled from the raw doppler_bin index.
doppler_res_hz float Width of that estimate -- the remaining uncertainty (±doppler_res_hz/2) a downstream refine/tracking stage still has to close.
cn0_dbhz_est float Estimated carrier-to-noise density (dB-Hz) -- informs downstream loop-bandwidth and dwell sizing.
peak_mag float Raw CFAR peak magnitude -- diagnostic/observability passthrough, not needed for tracking math.
noise_est float Raw CFAR noise-floor estimate -- diagnostic passthrough.
test_stat float Raw CFAR gating statistic -- diagnostic passthrough.

Timing. acq_result_t carries samples_consumed; the timestamp is dp_sample_clock_t's stamp_at(samples_consumed), and the stream layer carries an origin timestamp hop to hop rather than re-reading a clock. The engines themselves are clock-agnostic — pure sample-domain, no I/O — so the anchor comes from whatever feeds them samples and is threaded through by the composing layer (the receiver, or the orchestrator of §5).

No carrier_freq parameter on either class. The engine works in baseband Doppler Hz throughout; the carrier-aiding scale (doppler_hz_est · chip_rate / carrier_freq) is computed by the component that knows the carrier — the tracker takes carrier_freq_hz itself. That keeps the engine usable by a baseband-only caller with no carrier at all.

2.3 The wideband search, as settled

  • The native span is one epoch's bin. A D-point slow-time FFT sampled at the epoch rate has a fixed ±epoch_rate/2 range whatever D is — more bins subdivide the same range, they never widen it. At 3.069 Mcps and 1023 chips that is chip_rate/sf = 3.0 kHz per bin, a half-span of 1.5 kHz; the spec's ±50 kHz is 33 of them.
  • D > 1, in blocks, inside the pure-code window (decided 2026-09-03). Coherent multi-epoch combining across data aliases the data's own spectrum across the Doppler axis and mislocks structurally (dsss-acquisition.md). The waveform carries a 450-symbol pure-code window every 4950 symbols on the data clock (§5.4) — 813 whole epochs at 5 Mcps, 324 at 2, at any chip phase — and inside it there is nothing to alias. The searcher does not know any emitter's window phase, so it sums non-overlapping blocks of D epochs, one coherent surface per block, detected per block: the W whole epochs a window holds hold a whole block whatever the block's phase once W ≥ 2D − 1, and hold ⌊W/D⌋ of them in a row for n_noncoh to accumulate — W is the engine's code_only_epochs (§2.1), and D never exceeds what it holds. A block that straddles data spreads that emitter over its D rows, about 10·log10 D below an aligned block's peak at the same code phase — a weaker copy of an emitter the assigned table already excludes, not a mislock. D is bounded by the Doppler rate (§2.1): under 500 Hz/s the drift over a block stays inside half a bin while D ≤ f_epoch/√100061 at 2 Mcps, 154 at 5 — which is a bin of 32 Hz and a gain of 18–22 dB at either end of the range, 8 and 3 aligned blocks per window, and the same 3.2 k Doppler hypotheses at both rates. The floor inside an aligned block is the transition-free one (§12.2's −21 dB), not the −13 the data case measured.
  • The uncertainty is tiled by rolling one spectrum, not by a mixer bank. One forward FFT of the epoch, then the spectrum rolled by k bins per hypothesis against one precomputed replica spectrum: one forward plus one inverse per tile, against a forward and an inverse per tile for a bank of down-converters. Measured in the prototype at 1.2–1.55× faster; adopted as the engine's wideband mode (acq_core.c), so all tiles come from one object's per-epoch loop. The engine sizes the tile count itself, odd and symmetric (acq_cover_window_bins): 35 at 3.069 Mcps over ±50 kHz, 21 at 5 Mcps, 53 at 2.
  • What it costs is measured, per tile. bench_acq_core.c times a real acq_push() per dwell on this waveform and on the operating point of §6.1; the number is about 10 ns per tile per output sample (§12.1), which is what makes the searcher's cost the same at 2 and 5 Mcps and over a core at ±50 kHz.
  • Why the roll still carries the tiles at D > 1. dsss-acquisition.md §4 marks the roll OUT wherever coherent integration is viable, because a mixer bank with the slow-time transform does everything a roll without it does, plus the gain and a finer step. That compared the roll bare. Rolling by k bins is mixing by k/nx (the same page), so the roll with the slow-time transform inside each tile is the mixer bank with one forward transform shared across the tiles instead of one per tile — the 1.2–1.55× it measured at D = 1 — and the D rows per tile are the fine step. A bank of DDC-fed engines is not a third option on this signal: a tile is a Doppler hypothesis on a spread signal 2–5 MHz wide against 50 kHz of uncertainty, so a per-tile DDC cannot decimate and only adds a mixer per tile (§6.4's 14–33× real time). The comparison is a count until §12 step 14 makes it a number.
  • A roll per thread (decided 2026-09-03). The tiles are independent after the one forward transform: each reads the shared spectrum and writes its own rows of the surface, so the tile loop is a dp_parallel_for over tiles, one inverse transform and, at D > 1, one slow-time transform per tile on whichever thread takes it. The plan carries scratch, so each thread owns an inverse plan and a product buffer — a few KB — and nothing else is shared. This keeps the one forward transform the slice across engines repeated (§12.1's 6–11%), needs no LO in front of a slice, and keeps the peak list and the twin rule (§7.1) on one surface, where a slice boundary would have cut an exclusion zone in two. The workers are persistent — pthreads created once at create() and parked between pushes, a persistent form of dp_parallel.h's bounded parallel-for beside the per-call one its two callers use — so the fan costs a hand-off per push, not a thread creation per worker, and the granularity of a push is the coherence's choice (§2.3), not the threading's. Thread count is the engine's parameter, default the core count. The per-cell passes that decide a surface — the magnitude, the CFAR reference, the working mask and every scan of the peak list — run per tile as well, each into a slot of its own, and merge serially in tile order (a mean of the tiles' means over equal cells, the first of their first maxima), so what stays serial is per tile, not per cell (#1243).
  • What it costs, before it is measured. The slow-time transform runs once per block per tile, so per epoch it is of the order of the epoch transform it sits behind; the searcher's cost stays near §12.1's number until §12 step 14 says otherwise. The state is D epochs per tile: 53 MB per channel at either end of the range. n_noncoh across a window edge accumulates data blocks — a graceful loss, bounded by the 10·log10 D, not a mislock.

2.4 Observability — the searcher is watched, not trusted (decided 2026-09-05)

The searcher is the one stage nobody can check by its output alone: a hit says where a peak was, and nothing about what else stood on the surface, how close the gate came on the dwells that fired nothing, or whether one emitter's splatter was about to be listed as two. So the engine carries its own instruments, attach-on-demand like every other object's (Dll.set_telemetry): detached, a decided dwell costs three predicted-not-taken branches; nothing rides in a state blob.

  • Ten probes per decided dwell, set_telemetry(tlm, prefix, decim): the test statistic and the gate it was held to (threshold, or eta_nc on the non-coherent path — plotted together they show exactly where a hit fired, and how close the misses came), the CFAR reference, the strongest cell's value and its native row and column, the picks in the dwell and how many were held as same-code-phase twins (§7.1), the strongest pick's concentration, and whether the gate fired. decim thins by dwells.
  • The surface itself, keep_surface then surface(out): the dwell's whole surface, surface_rows × code_bins, every cell divided by the reference the gate used — so a cell reads as its own test statistic and the gate is a flat plane on a plot. surface_doppler_hz() and surface_chip_phase() are its axes, from the same fold and the same chip-phase mapping a DetectionEvent carries, so a plotted peak sits where the hand-off says. In C, acq_set_surface_sink(fn, ctx, decim) hands every decim-th dwell's surface to a callback on the pushing thread — a run of hours records the surface decimated in time without a copy per dwell it does not keep. The surface is normalised only while a reader is armed.
  • The concentration is the splatter discriminator. One emitter does not make one peak: a data transition inside the epoch splits it into equal twins on other tiles (§12.2), and at D > 1 a block that straddles a transition — or the edge of the pure-code window, which falls at no particular chip phase (§5.4) — spreads it over its slow-time rows, 10·log10 D down and smeared across the data's spectrum. All of that is at the emitter's own code phase; a second emitter is a second column. So the probe is the strongest pick's main-lobe power — its row and one either side, the exclusion zone's width, so an emitter halfway between two tiles is not charged for its own scalloping — over the total power of its column across every tile and row: near 1 for one clean emitter, about 0.5 for a transition's twins two or more tiles away, lower for a straddling block. Beside the two-epoch rule it is the number that separates one emitter's splatter from two emitters, and the surface tap shows the same thing in two dimensions. §12 step 11 measures it on aligned and straddling blocks.

3. The asynchronous despreader

Scope: the receive-side despreader when the data-symbol rate is on the order of the code-epoch rate but asynchronous to it. This is theory, the failure mechanism, and a validated robust architecture that composes existing doppler.track primitives. The reproducible study is src/doppler/examples/async_despreader_study.py (python -m doppler.examples.async_despreader_study).


3.1 The two-clock problem

A DSSS receiver despreads by integrating early/prompt/late correlations over one code epoch (TE = sf·sps samples) — an integrate-and-dump locked to the code clock. The data symbols are a separate stream; the despread prompt per epoch carries the data.

That works when the symbol clock is locked to the code clock at an integer ratio (GPS C/A: 20 code epochs per data bit, bit edges on epoch edges). It breaks when the symbol clock is independent:

T_sym = TE · (1 + delta)        # symbol period, samples
                                # delta = symbol-vs-code rate offset
phi_sym                         # independent symbol phase

with T_sym ≈ TE (symbol ≈ one epoch). This is the hard regime: ~one symbol per epoch, a transition roughly every epoch, and — crucially — delta ≠ 0 makes the symbol boundary slide continuously through the epoch at the beat rate delta / TE.


3.2 Why per-epoch despreading fails

The coherent prompt over an epoch whose data flips at fraction f ∈ [0,1]:

P(f) = A·[ f·d1 + (1−f)·d2 ]  =  A·d1·(2f−1)        (d2 = −d1)
  • f → 0, 1 (flip at an epoch edge): |P| = A (full despread).
  • f → 0.5 (flip mid-epoch): |P| = 0 — total coherent cancellation.

Because delta ≠ 0, f sweeps through every value, so ~half of all epochs straddle a transition and their prompts collapse. The consequences:

  1. Data: per-epoch decisions floor — the BER plateaus regardless of Es/N0 (the straddle epochs carry no usable energy). Measured floor ≈ 1e-1 even when the bound is < 1e-5.
  2. Code: the early/late discriminator (|E|−|L|)/(|E|+|L|) collapses to 0/0 on straddle epochs → the DLL is starved → the code loop wanders.

Root cause: at one prompt per epoch the symbol clock is unobservable (a single sample per symbol cannot drive a timing loop), and the integration window is forced to straddle transitions.

Diagnostic fingerprint

The straddle modulation is periodic at the symbol↔epoch beat. The spectrum of the prompt-magnitude stream |P[n]| shows a tone at |delta| cycles/epoch (centre panel of the figure). This is the signature to look for when a DSSS link shows unexplained despread fades — it identifies this failure class directly.


3.3 Robust architecture

Async despreader study

The fix gives the symbol clock its own observability and its own matched filter, and makes code tracking insensitive to data sign — composing primitives that already exist.

3.3.1 Data path — partial correlations + symbol matched filter + SymbolSync

  1. Partial correlations. Split each code epoch into K sub-epoch partial prompt correlations (each TE/K samples, known code phase). This yields K despread samples per epoch ≈ K samples per symbol — the symbol clock is now observable.
  2. Symbol matched filter. A length-K boxcar over the partial stream. This is a sliding, symbol-aligned coherent re-integration of the partials — the full-symbol despread the epoch-locked window could not form. It is essential: without it, the rectangular symbol pulse is sampled at one point and only ~1/K of the symbol energy is captured (the BER floors at ~2e-2).
  3. SymbolSync. track.SymbolSync (Gardner TED + Farrow interpolator) recovers the independent symbol clock (delta, phi) from the matched-filtered stream and decimates at the symbol-aligned peak.

Result (left panel): the BER follows the BPSK matched-filter bound within ~1–2 dB. A genie reference (coherent symbol-aligned despread with known timing) hits the bound exactly — the loss was only window misalignment, never SNR. The broken per-epoch path floors.

Es/N0 bound genie (known timing) partial+MF+SymbolSync broken epoch
6 dB 2.4e-3 2.5e-3 4.5e-3 ~7e-2
8 dB 1.9e-4 1.5e-4 5.8e-4 ~6e-2
9.6 dB 9.7e-6 0 0 ~5e-2

3.3.2 Code path — non-coherent partial combining

The DLL keeps tracking through data flips by combining the partial correlations non-coherently: |E| = Σ_k |E_k|, |L| = Σ_k |L_k|. A data flip changes a partial's sign, not its magnitude, so only the one straddling segment degrades (~1/K). This roughly halves the discriminator variance versus the coherent-epoch form (right panel) — keeping the (already validated, smooth sub-chip) code loop locked. It needs no symbol timing, so it works from cold start; the bootstrap order stays sequential: DLL (non-coherent) → SymbolSync → data.

3.3.3 Choosing K

K trades observability and straddle-robustness against the non-coherent squaring/Rician bias (which erodes the discriminator gain as K grows). The study shows K = 4 as the sweet spot for T_sym ≈ TE (best discriminator SNR; K = 8 loses more gain than variance). K must divide TE.


3.4 Scope: the despreader removes the code and outputs samples

The despreader's one job is to remove the PN code and output samples. The asynchronous symbol clock is merely why it despreads in K partial correlations (§3.3) — it is not a reason to recover symbols here. Carrier recovery and symbol extraction are downstream problems, handled by separate objects fed from the despreader's output:

              ┌──────────────── the despreader ───────────────┐
acq seed →    Dll(segments=K):  E/P/L correlate · partial dump · non-coherent
   (code phase)                 (|E|−|L|) code loop
              └───────────────── partial stream out ──────────┘
                         │  K oversampled async BPSK samples/symbol
                         │  (PN removed; residual carrier + data still on them)
   downstream:  Costas (carrier recovery)  →  SymbolSync (symbol timing) → bits

This is track.Dll(..., segments=K) — no new object. segments=1 is the classic coherent full-epoch DLL; segments=K>1 is the streaming async despreader. It composes downstream with Costas and SymbolSync, which already exist (the data path of §3 is exactly that composition).

Why the carrier belongs downstream

The DLL's |E|−|L| discriminator is non-coherent, so code tracking is carrier-blind — it locks with a residual carrier still on the samples. And because each output is a partial (a TE/K-sample integrate-and-dump, not a full epoch), a residual carrier barely dents it. For a ½-Doppler-bin residual after acquisition the I&D loss is sinc(Δφ/2) with Δφ = π/segments:

segments window Δφ at ½-bin residual despread loss
1 TE π −3.9 dB
4 TE/4 π/4 −0.2 dB

So short partials make the despread carrier-tolerant: the small residual just rides out on the output (a ring in the constellation; see the gallery demo), and a downstream Costas loop removes it at full symbol SNR. Putting a carrier loop inside the despreader would only matter for long coherent integration — which partials deliberately avoid.

The same scope rule applies to the DSSS-MPSK composition

Dll(segments=K) -> MpskReceiver (docs/gallery/dsss-receiver.md) is the other downstream composition, and the same rule bites the same way: the despreader's partial-correlation output rate is whatever K*chip_rate/SF comes out to — a sub-multiple of the chip rate, not chosen with MpskReceiver's sps in mind. An early version of that gallery page violated its own §3.4 by picking K specifically so round(K*T_sym/T_epoch) landed on an integer, coupling Dll's own tracking parameter to MpskReceiver's sample-rate requirement. That made a perfectly good Dll tuning look downstream-broken. The fix is doppler.resample.RateConverter between the two — an explicit, arbitrary- ratio resample stage, the same category of fix as Costas/SymbolSync being separate objects from Dll here. Choose segments for the despreader's own tracking quality; choose the demodulator's sps for its own reasons; bridge the two with a resampler, never by coupling the parameters directly.

3.5 Code-lock detection (always on)

A tracking channel must always answer one question: am I locked? The DLL carries an always-on lock detector that reuses acquisition's non-coherent test statistic, so acquire and track agree on what "detected" means.

Statistic. Each emitted look (a partial in segments mode, the full-epoch prompt when segments=1) contributes its prompt power |P_k|². The detector sums N = n_looks consecutive looks and forms

R = sqrt( 2 · Σ_{k=1}^{N} |P_k|²  /  E|O|² )

which under H0 (noise only) has P(R > η) = marcum_q(N, 0, η) — exactly the acquisition tail. So a caller sizes the threshold η = det_threshold_noncoherent(pfa, N) and the depth N = det_n_noncoh(snr, …) to meet a target (Pfa, Pd); configure_lock(pfa, n_looks) does the conversion (default pfa=1e-3, N=20).

The noise reference E|O|². Instead of a separate noise channel, the loop correlates each look a second time at a random off-peak code phase — a whole chip offset re-drawn every epoch and kept clear of the prompt/early/late lobe by noise_guard chips. For a low-sidelobe code (Gold, long PN) that offset correlation is signal-free, so |O_k|² is a sample of the per-look noise power. Cycling the offset and averaging recovers the same noise estimate a bank of fixed off-peak taps would, with O(1) state.

Why an EMA, and why it must be long. The reference is an EMA of |O_k|² (E|O|² += α(|O_k|² − E|O|²)), which is adaptive (tracks a drifting noise floor) and O(1) — matching the Costas lock-metric pattern. The subtlety, found by Monte-Carlo: the detection integrates a fixed N looks (that sets the χ²(2N) threshold), but the noise estimate must average many more cells than N, or its own variance inflates Pfa. One offset cell per look (L=N) drives Pfa ~400× high; 1/α = max(1024, 32·N) (L_eff ≫ N) holds Pfa at target with Pd ≈ 0.98. So the integration depth and the noise-averaging length are decoupled: N is the test, 1/α is the reference. The reference uses a cumulative-mean bootstrap — it is the running average until 1/α looks have accrued, then relaxes to the fixed-α EMA — so the noise floor is unbiased from the first look instead of seed-dominated for the ~1/α-look warm-up (otherwise Pfa runs ~10× high until the EMA settles, ~hundreds of epochs in). Verified end-to-end: empirical Pfa ≈ 9e-4 against the 1e-3 target right from the start of a noise stream.

Readouts. Dll.locked (bool, latched each N-look decision), Dll.lock_stat (the last R), Dll.noise_est (E|O|²). The detector runs inside the normal steps() — no separate method, no opt-in. The threshold conversion (the one detection-module call) lives in the binding so dll_core links only -lm.

3.6 The look-back window — the original working design

The note the C Dll's dwell-integral look-back was built from (native/inc/dll/dll_core.h cites it as its reference); kept verbatim, in NumPy, as the algorithm's own statement.

Important: This assumes at most one data symbol transition per code epoch

flowchart LR
subgraph TED

end
LUT["LOCAL CODE \n INTERPOLATED LUT"]
TED --> LF
RX["RX CODE"] --> TED
LF["LOOP FILTER"] --> SCALE["SCALE BY \n EPOCHS / SAMPLE"]
SCALE --> SH["SAMPLE\nAND\nHOLD"]
SH --> NCO["U32 NCO\n MAX = SAMPLES / EPOCH"]
NCO --$$i + \mu$$--> LUT
LUT --E / P / L--> TED
  • TED generates one error per epoch using the signal power formed by correlating the rx signal with local code replicas E, P, and L over a window which maximizes power of the prompt correlation and forms the error:

    code_phase_error = 0.5 * (early_power - late_power) / signal_plus_noise_power
    
  • This requires storing a buffer of the last received samples to "look back" in the case where a transition occurs in the current sample buffer so a transition free epoch may be obtained

  • This is scaled down and repeated driving the NCO at 2x chip rate

  • Local code is 2 samples per chip and linear interpolation is used to compute fractional samples

  • LUT outputs early, prompt, and late codes offset by 1/2 chip (1 sample)

# Init
code_size = 1023
samples_per_chip = 2
max_error = 0.5 # dB async correlation loss
phases = code_size * samples_per_chip
phase_resolution = 1 - 10 ** (-max_error / 10)
phase_step = int(np.ceil(phases * phase_resolution))
factors = [i for i in range(1, phases + 1) if phases % i == 0]
phase_step = factors[np.abs(np.array(factors) - phase_step).argmin()]
windows, window_size = int(phases / phase_step), phase_step
last_backard_sums = np.zeros(windows, np.complex128)
last_early_sums = np.zeros_like(last_backward_sums)
last_late_sums = np.zeros_like(last_backward_sums)

def find_max_power(x, windows, step_size, last_backward_sums):
    """Find max correlation over different output phase offsets."""

    # First compute the partial sums of the current correlation
    partial_sums = x.reshape(windows, step_size).sum(axis=1)

    # Now sum up the portions of the windows this epoch contributes
    sums = partial_sums.cumsum()
    backward_sums = partial_sums[::-1].cumsum()

    # Use the last epochs backward looking sums and the current
    # epochs forward looking sums to comput the overlapping correlation
    # at each phase across the two epochs and keep the maximum
    correlations = np.zeros(sums.size)
    correlations[-1] = np.abs(sums[-1] / (code_size * samples_per_chip))
    correlations[:-1] = (
        np.abs(sums[:-1] + last_backward_sums[::-1][1:])
        / (code_size * samples_per_chip)
    )
    max_window = correlations.argmax()
    max_abs = correlations[max_window]
    max_power = max_abs ** 2

    # Use partial sums as integrate and dump downsampled output
    integrate_and_dump = partial_sums / (step_size * max_abs)

    # Compute window index. This is the offset from the end of the last
    # correlation window that is the start of the max power correlation
    # window.
    window_index = (windows - 1 - max_window) * step_size

    return (
        max_power,
        max_window,
        backward_sums,
        integrate_and_dump,
        window_index
    )

def get_window(x_window, x, last_x, index):

    if index:
        x_window[:index] = last_x[-index:]
        x_window[index:] = x[:-index]
    else
        x_window = x[:]

    return x_window

# In your loop

while signal_buffer,more_data:

    # NCO + interpolated LUT
    early, prompt, late = pn_gen.steps(
        pn_control
    )

    b = signal_buffer.get()
    x = b * prompt
    power, window, last_backward_sums, integrate_and_dump,window_index = find_max_power(
        x, windows, window_size, last_backward_sums
    )
    signal_plus_noise_power = power

    b_win = get_window(b_window, b, last_b, window_index)
    last_b = b[:]
    early_win = get_window(early_window, early, last_early, window_index)
    last_early = early[:]
    late_win = get_window(late_window, late, last_late, window_index)
    last_late = late[:]

    early_power = np.mean(b_win * early_win) ** 2
    late_power = np.mean(b_win * late_win) ** 2
    code_phase_error = 0.5 * (early_power - late_power) / signal_plus_noise_power
    loop_filter.step(code_phase_error)
    pn_control = np.full(loop_filter.out / (code_size * samples_per_chip))

3.7 Symbol-timing-aided lock looks — the max-power search at symbol scale

Designed and built 2026-09-02, after §12.3 measured the code-lock flag reading "unlocked" 96% of the time at Es/N0 5.7 dB on a loop that never lost the code, and the telemetry showed why (§12.4).

The partial-and-non-coherent form of §3.3 is forced by the data: a full-epoch coherent look collapses on a transition, so the code-lock detector's look was the quarter-epoch partial, the smallest integration the asynchronous data allows when nothing is known about where its transitions fall. That is also the weakest look. At the operating point a partial carries −2.9 dB per look at Es/N0 5.7 dB, and the detector's default 20 looks, sized for nothing in particular, sat below threshold.

The look-back of §3.6 already knows how to find a transition-free window: it picks, per epoch, the one-epoch window with the most power. What it does not know is the symbol period, and the receiver does — it is segments · chip_rate / (sf · symbol_rate) partials, 7.24 here. With the period the same search lifts to the symbol scale:

  • ceil(P) boundary-phase hypotheses, each placing a boundary every P partials and owning a window of L = min(floor(P) − 1, 4 · segments) partials after it — short enough to sit inside one symbol under the hypothesis's quantisation, capped so a slow data clock never asks for coherence across more carrier than the wipe-off holds;
  • each hypothesis sums its window coherently and keeps an EMA of the window's power over ~32 symbols; the hypothesis with the most power is the symbol timing, and its windows are the detector's looks.

A look then integrates L partials coherently and never straddles a transition: six instead of one here, 7.8 dB more per look, and det_n_noncoh sizes the detector at 10 looks for Pd 0.99 at the floor instead of 161. The search needs no decision and no external timing, so it costs nothing at cold start and follows a drifting symbol clock by itself. An external phase from the demodulator can be accepted later as an additive hook; it was not needed to reach the result.

The code discriminator runs on the same window. The loop steers once per symbol on the early/prompt/late sums over the winning window, its filter re-timed to the symbol interval so bn keeps its per-epoch meaning and the tracked rate is continuous when the aid is switched on or off. What that buys and costs is measured in §12.5: a loop about 20% faster to pull in and tighter above 45 dB-Hz, and 1.2–1.4× the jitter at the floor, where the noise sets it and the window's unused partials cost more than its coherence buys — hundredths of a chip either way. The emitted partial stream is untouched: the look-back still supplies its normalisation.

The receiver applies it at chain build: dll_set_symbol_period from its configuration, n_looks from det_n_noncoh over the window at its cn0_dbhz, and the drop count from det_verify_count(1 − pd, 1e-6) — three consecutive misses, against the DLL's fixed two — so the verify hysteresis is a budget, not a constant. Pinned by test_dll_core.c §6b (per-partial looks up 35% of the time, aided 100%, the chosen phase within one partial of the truth) and §6c (the loop steers on the window; the two modes' step transients agree, which a filter left at its per-epoch gains fails; the rate is continuous across the switch), both sabotage-proven, and measured in §12.4 and §12.5.


4. The receiver as built

AsyncDsssReceiver (native/inc/async_dsss_receiver/async_dsss_receiver_core.h) is the composed continuous receiver, one C object, the production port of the validated Python search → refine → track prototypes. It has three states, read back through get_refining()/get_tracking():

  • searching — samples feed an embedded continuous Acquisition (§2, window-tiled over doppler_uncertainty, D = 1). A hit becomes a hand-off through acq_build_handoff(), which seeds the refine stage; the unconsumed tail of the same call is handed straight to it.
  • refining — a frozen-carrier derotation at the coarse estimate feeds a collection Dll whose look-back segments oversample each epoch, then a RateConverter to CarrierAcquisition's own rate, then CarrierAcquisition itself. When it reports ready or gives up, the live tracking chain is built fresh from the original hand-off chip phase and the refined (or, on give-up, unrefined) Doppler.
  • tracking — the refined carrier is unfrozen into a live pre-despread Costas loop (costas_update() once per code period, driven by a non-data-aided squaring discriminator over the period's coherent partials) → Dll (§3, segments = K) → RateConverterMpskReceiver. Two lock detectors run: the Dll's own CFAR-based code lock (get_code_locked(), §3.5) and a hysteretic symbol lock on the emitted symbols (get_locked(), the cos(2φ) statistic over a 30-symbol dwell, declared after 30 consecutive symbols at or above 0.5 and dropped after 15 below 0.3).

DsssReceiver is the same object without the refining stage — a hit's coarse Doppler goes straight to tracking — and §1.2's note (2) is why the refine exists: the 4–5 dB pull-in cliff the coarse-only hand-off left. reset() on either returns to searching: a receiver that has locked cannot be reset back onto the same signal, only back to the hunt. Both are serializable (state_bytes/get_state/set_state), every child included.

4.1 Status

  • Shipped — the despreader. Dll(..., segments=K) (the §3.3 code+symbol path; segments=1 = the classic coherent DLL). Validated carrier-present: code lock holds with a residual carrier on the samples, and the partial output is losslessly recoverable by a downstream carrier wipe + symbol despread (test_dll.py::test_segments_carrier_present_*). The streaming binding returns an independent array per call (block-size invariant).
  • Shipped — the inline symbol-loop primitive. symsync_step() (the per-sample SymbolSync composition API); symsync_steps() is it in a loop.
  • Shipped — the always-on code-lock detector (§3.5). Dll.locked / lock_stat / noise_est, tuned by configure_lock(pfa, n_looks); reuses acquisition's non-coherent statistic with a random off-peak EMA noise reference. Validated signal-vs-noise in test_dll.py / test_dll_core.c.
  • Downstream, already available: Costas (carrier recovery) and SymbolSync (Gardner + Farrow symbol timing). A receiver is the pipeline Dll(segments) → Costas → SymbolSync; the §3.3 study and the async_despread_demo gallery example show the composition.
  • End-to-end validated with a real acquisition front end. Dll(segments=K) → MpskReceiver (MpskReceiver already fuses matched filter + NDA carrier acquisition + Gardner/Farrow timing + acq↔track handover into one object — its own docstring names this exact composition) is now proven at real physical parameters — a continuous 1023-chip code at 3 Mchips/s, async 2100 sym/s BPSK data, with a genuine Acquisition search in front (see the DsssReceiver gallery page, src/doppler/examples/dsss_receiver_demo.py). Note that K=4 (§3.3.3) is tuned for the DLL's own code-discriminator variance, not for feeding a downstream matched filter — each partial is K-times weaker than a full coherent epoch, so a downstream receiver needs a much larger K (34, in the validated example) to reconstruct real coherent gain before its own carrier/timing loops can converge. The acquisition hand-off also needs two non-obvious unit conversions (Dll's init_chip is phase-inverted relative to Acquisition's code_phase; MpskReceiver's init_norm_freq is cycles per its own partial-rate input, not per raw ADC sample) — see the example's docstring for the exact formulas.
  • Shipped — hand-off mode and the release (§11.1, §11.2). HandoffAsyncDsssReceiver, a [[views]] entry over the same core with no embedded Acquisition: starts idle, seed(chip_phase, doppler_hz_est, cn0_dbhz_est) starts the refine → track chain (a method of both flavors; refused on a receiver that already holds one), reset() returns to idle. The lost state: both flags down, continuously, for longer than lost_confirm_s (a constructor parameter; 2 s on the hand-off flavor, 0 = never on the searching one) stops the loops and sets lost until reset(); one flag down is a degrade and does not run the clock. The clock also runs from the first tracking sample, so a seed that never locks within the interval is released the same way. Pinned in test_async_dsss_receiver_core.c (hand-off decode, refusals, the rule firing not a sample early, one-flag-down never firing, the flavor-keyed blob) and test_async_dsss_receiver.py.
  • Shipped — the status record (§11.3). status() on both flavors, a by-value ReceiverStatus record (the single = true shape ToneMetrics uses): state, the live Doppler (the carrier loop's estimate once tracking, the seed while refining, frozen in lost), chip phase, code rate, C/N0, both flags with the symbol-lock metric and threshold, both residual carrier errors, and the two clocks in input samples. No timestamp, by §8.1's rule. Pinned field-by-field against the getters in every state in test_async_dsss_receiver_core.c.

Possible refinements

  • Symbol MF length. A downstream length-K boxcar matched filter follows the BPSK bound within ~1–2 dB; matching it to the tracked symbol period closes the gap.
  • Closed-loop code-jitter asset. Drive the non-coherent partial code loop under async data + code Doppler; confirm lock retention and the low-SNR threshold (bn≈1e-5 held to 4 dB Es/N0; bn≈0.002 lost lock at 6 dB).

5. The continuous case — the C++ application's waveform

The use case as the maintainer described it, 2026-09-02, and every "settled" or "answered" item on this page below traces to that description; the numbers are derived from §1's waveform and the measurements in burst-bank.md §10.4, and the questions at the end are open or answered in the sections that follow.

The C++ application does not receive bursts. It receives continuous DSSS with asynchronous data — the CCSDS command-link shape async-dsss-receiver.md already specifies (a 1023-chip Gold code, 3.069 Mcps, ±50 kHz) — and the stream carries a data-free period of one code period just before each frame sequence. Several emitters are in the air at once on the same Gold code, and what tells them apart is Doppler: each emitter's frequency difference is its Doppler. There is one frequency channel: every emitter is in the same band on the same code, and what distinguishes them is code phase, power and Doppler.

5.1 What the data-free window changes

Everything the burst family assumes about a preamble holds for that window and for nothing else in the stream:

  • There is no coherent gain to buy. The data-free window is one code period, so reps = 1 and the coherent depth is one epoch — exactly the continuous Acquisition engine's search (D = 1, sensitivity from non-coherent looks, dsss-acquisition.md's warning). The window buys one clean epoch without a data transition inside it, which the continuous engine already prices as a straddle loss and survives. The bank's reason to exist in this use case is therefore not gain — §5.3 says what it is.
  • The hand-off is to a tracking receiver, not to a frame demodulator. A burst ends; a continuous signal is tracked from the seed onward (carrier_acq → Dll + Costas, the monolithic C receiver). So the channel's product is the DetectionEvent the async spec defines — Doppler, code epoch, C/N0 — and the window copy BurstCapture makes is not needed for the signal's sake. What may still be needed is the capture's refine: the frame begins where the data-free window ends, so which code period the window ended on is the frame epoch, and acquisition alone cannot say (§3.1 of the receiver design). Whether the tracking receiver's own frame sync makes that redundant is question 3 below.
  • The channel repeats. A burst is acquired once; a continuous signal is re-acquired at every data-free window, and between windows it drifts (< 500 Hz/s in the spec). The claim rule across windows is then "same signal, next frame", not "same preamble".
  • Emitters come and go, at their own frequencies, and the bank is always on the air. An emitter comes into view in the band at some Doppler, is acquired at its next data-free window, is handed to a tracker, keeps transmitting while others come into and leave view around it, and eventually leaves. The bank never stops searching: a channel that has handed one emitter off must go on watching its band for the next, and an emitter that drops out must be noticed and re-acquired when it returns. That is a lifecycle — searching → acquired → tracked → lost → searching — the burst family has no state for; a BurstCapture is done when the window is out. It is also a duration requirement: the process runs for hours or days, so nothing in the bank may grow with time (samples_fed is 64-bit; the per-push scratch reaches its high-water mark and stays; the rings are fixed) and a checkpoint is for a restart mid-pass, taken while everything is live.

5.2 The numbers, from the spec and burst-bank.md §10.4

  • Native span 3.069e6 / (2·1023) = 1.5 kHz; channel spacing 3.0 kHz; covering ±50 kHz takes 2·ceil(50/3)+1 = 35 channels — one bank, since there is one code.
  • At spc = 2 the source is 6.14 MSa/s; at burst-bank.md §10.4's 48 ns/sample a channel is 0.29× real time, so the bank is ~10× real time — eight cores at the measured 5.8× pool speedup do not keep up. Two things follow: the C++ application's own threads (burst-bank.md §10.1, the primary path) are not optional, and the per-channel cost is the number to attack first — 48 ns/sample was measured for DDC → BurstCapture, and a channel that hands off a DetectionEvent rather than a window needs neither the capture's ring nor its refine.
  • The continuous engine's own window_bins tiling covers ±50 kHz in one engine at the same D = 1 — the same tiling this bank does with DDCs, at the same sensitivity. What the single engine cannot do is §5.3's first item, and that, not gain, is what the K-fold cost buys.

5.3 The async tools, and what the bank adds to them

The continuous chain exists and is the thing to compose, not to rebuild. AsyncDsssReceiver is one object with a three-state machine — searching (the continuous Acquisition, window-tiled over the uncertainty), refining (acq_build_handoff → a frozen-carrier DllCarrierAcquisition), tracking (Costas → DllRateConverterMpskReceiver) — and it is the validated C port of the search → refine → track prototypes. DsssReceiver is the same without the refining stage. Both cover the whole ±50 kHz in one engine at D = 1.

So the C++ application's channel is not DDC → BurstCapture. Against what already exists, the bank adds exactly three things, and each is a design decision rather than a given:

  • Resolution on the (Doppler × code phase) surface. Every emitter is a peak on the same 2-D surface a channel already computes, at its own Doppler bin and code phase, with its own power. A Doppler bank partitions one axis of that surface: emitters more than a span apart land in different channels and are found independently, with independent CFAR references. But emitters within a span — the normal case, since there is one band and only Doppler separates them — share a surface, and a detector that takes the maximum of it reports one of them per dwell, the strongest, and masks the rest. So the channel's detector must report every peak above threshold in a dwell, each with an exclusion zone around it (a bin in Doppler, a chip in code phase) so one emitter is not reported as several — a multi-peak report the engine does not make today. Then power: a 1023-chip Gold code's cross-correlation floor is about −24 dB — on the searcher's actual surface, with data and a Doppler straddle, −13 to −16 dB (§12.2) — so an emitter that much weaker than the strongest in the same surface sits under the strongest one's sidelobes and is found only by cancelling the strong one first (successive interference cancellation) — and two emitters at the same Doppler and code phase within a chip are one peak, distinguishable by nothing. Question 7 is therefore answered: emitters do share a span, and the bank's channel count buys parallel surfaces and independent references but not resolution; the resolution is the detector's, per surface, and it is the piece to design.
  • Many emitters, one band. One AsyncDsssReceiver tracks one signal; its state machine has no "lost" state and no second emitter. The bank is what holds the pool: which emitters are up, which channel each is in, which tracker it went to, and when it stopped being heard. That is §5.3's question 5, and the tools do not answer it today.
  • The frame epoch. The refining stage recovers carrier, not which code period the frame started on; if the application needs that from the bank, it is the capture's refine, transplanted.

Everything else — the DDC, the tiling rule, the tracker, the hand-off record — is already there.

Two rules from the maintainer (2026-09-02) fix the channel's shape:

  • It always has to be searching. A channel never stops acquiring: the emitter it just handed off keeps transmitting in its band while a second one comes into view beside it, and the first one's loss has to be noticed by something that is still looking. That rules out AsyncDsssReceiver as the channel — its state machine replaces the search with refining and then tracking, feeding every sample to the tracker. In the bank, search and track are concurrent per channel: the search engine runs on every block, and each hand-off spawns a consumer that is fed the same samples beside it. Two things follow. A channel that keeps searching re-detects the emitter it handed off at every data-free window, so something must recognise "that one is already handed off" — a suppression keyed by emitter (its Doppler and code phase), the analogue of the capture's suppress_until keyed by time — and that is the bank's, which settles the minimum of question 5. And the per-channel cost in §5.2 is the search alone; each tracked emitter adds a tracker's cost on top, on the application's threads.
  • The hand-off logic is selectable. What a detection becomes is a policy, not a property of the channel: hand a DetectionEvent to a tracker (this use case), capture a window for a frame demodulator (the burst use case), or report and do nothing (surveillance). The channel owns the search and the event; the policy owns what happens next and is chosen per bank, possibly per channel. This answers question 1 — the channel is DDC → search, and BurstCapture's ring and refine are one policy's apparatus, attached only when that policy is selected.

5.4 Questions this raises (open)

  1. ~~Hand-off target.~~ Answered: selectable — a policy on the detection (track / capture a window / report), not a property of the channel. The channel is DDC → search, always searching.
  2. ~~One Gold code per signal.~~ Answered: one Gold code, shared; emitters differ by Doppler. One bank; the multi-signal case is within it, across channels.
  3. The frame epoch. Partly answered: the block that detects an emitter lies inside its window, which locates the window to within D epochs; the exact boundary is the tracking chain's to find, and the receiver is not told it (§8.2). As built (2026-09-05), the roll per thread: dp_pool_* in dp_parallel.h is the persistent form of the parallel-for — the same contract over helpers created once and parked between runs — and a tiled continuous engine creates one with itself, the online cores by default (set_threads, 0 = cores, 1 = serial). The per-epoch tile loop and the block-end column loop run through it; the scratch is per tile rather than per thread (a pocketfft plan carries its own work buffers, and a tile lands on whichever worker takes it), so the serial and fanned paths run the same code and the surface is byte-identical at any count — pinned at 1, 2, 4 and 8 in test_acq_core.c, under TSan in the C suite. Burst and single-tile engines never fan. §12.8 has the cost. As built (2026-09-05): the engine now allows a coherent depth to accommodate waveforms with code-only windows: Acquisition(code_only_epochs, doppler_rate) sizes D exactly so; the per-tile epoch rows are gathered for D epochs and a zero-padded slow-time FFT per code-phase column turns each tile into D rows, scattered onto one uniform Doppler grid of window_bins · D native bins of chip_rate/(sf·D) over the tiled span, in FFT-bin order — so doppler_bin, the exclusion zone, the native-row report, the hand-off's fold and the surface axis are all the one dp_fftfreq_index over that count. The CFAR counts every row of every tile; the C/N0 estimate counts the block. The block accumulator rides in the state blob (v3), so a mid-block split resumes bit-for-bit. code_only_epochs = 1 is D = 1 and the engine byte-for-byte as before. Pinned in test_acq_core.c: the two bounds, an emitter one row above tile +1 reported at bin 1·D + 1 and handed off at its frequency, the cell count, the mid-block split, and a block straddling a data transition reading weaker and spread at the same code phase — the §2.4 concentration at work.
  4. ~~The data-free window's length.~~ Answered (2026-09-03, in symbols 2026-09-05): 450 symbols of code only, then 4500 of data — a frame of 4950 symbols on the data clock. There is no fixed relation between the chip and data clocks, and a frame edge falls at no particular chip phase — never on a code epoch (maintainer, 2026-09-05); 500/5500 epochs was this frame at the spec's 3.069 Mcps. So one (re)acquisition opportunity every 1.83 s at any chip rate, and up to 0.92 kHz of drift between them at 500 Hz/s; in whole epochs the window is 813 at 5 Mcps and 324 at 2, long enough for any coherent depth the Doppler rate allows (§2.3), and it is why the searcher has one.
  5. ~~Who owns the lifecycle.~~ Answered (2026-09-03): a C object in doppler, the pool of §8.2, owns the receivers and the assigned table; the receiver's half — how "gone" is decided and what it releases — is §10.
  6. ~~How many emitters at once, and how long an emitter is typically in view.~~ Answered: at least one always on, up to 10 at once, each on for 5 to 15 minutes and never more than 15 (maintainer, 2026-09-05) — a bound, and an adjustable one. The pool and the soak follow in §6.1, §5 and §6.
  7. ~~Can two emitters sit within one span of each other?~~ Answered: yes — one frequency channel, one code; emitters are separated by code phase, power and Doppler on one surface. A channel therefore needs a multi-peak report per dwell with exclusion zones, and the engine has none. Open in its place: the power spread between emitters that are up at once — inside the floor a multi-peak report suffices; beyond it the weak ones need the strong ones cancelled first, which is a different object. The floor is measured: −13 dB in the operating case, not the Gold bound's −24 (§12.2), and −21 inside the pure-code window the searcher now detects in. The spread is 10 dB (maintainer, 2026-09-03): inside the floor, so the list branch ships and no cancellation object is built (§9).

6. The searcher — every emitter on one surface

The searcher's design, written 2026-09-02 as its own page and folded in here the same day. Nothing in §6–§10 and §12 is implemented, and nothing has been measured; §12 is the work that would measure it. Follow adding an algorithm.

6.1 What is settled, and what the page is for

The C++ application's waveform fixes the frame this page works in, and none of it is re-derived here (§5):

  • One Gold code, one frequency channel. Every emitter is on the same 1023-chip code in the same band; what tells them apart is Doppler, code phase and power — three coordinates on one (Doppler × code phase) surface, the surface a channel already computes.
  • A 450-symbol pure-code window every 4950 symbols. The search is the continuous engine's, run in coherent blocks inside that window (§2.3): 18–22 dB of coherent gain, a 32 Hz Doppler bin, and the transition-free floor, at a cost the engine already pays per tile.
  • The channel always searches. It never hands its samples over to a tracker and stops; search and track are concurrent.
  • The hand-off is a policy — track, capture a window, or report — chosen per bank, and not a property of the channel.
  • The population: at least one emitter is always on, there may be up to 10 at once, and each is on for 5 to 15 minutes15 minutes is the maximum on-air time of a single emitter (maintainer, 2026-09-05), and it is adjustable: the pool's max_emitter_on_time_secs (§8.2), the soak's draw (§12 step 7) and the false-release budget (§10) all take it as a parameter, whose default is the one constant MAX_EMITTER_ON_TIME_SECS = 15*60; nothing else bakes it in. So the surface never has fewer than one peak, has up to ten, and an emitter comes into or leaves view about once a minute at the full population. An emitter transmits continuously, and coming into view is not powering up (maintainer, 2026-09-03): it appears at whatever point of its frame it has reached, mid-payload as often as not, and its first window arrives at its own phase, uniformly within one frame — so the acquisition latency after an emitter appears is bounded by a frame (1.83 s) and averages half of one. Every window of every emitter is a re-acquisition opportunity, and an emitter appearing between two of them is the normal event the searcher exists for. This answers burst-bank.md §11.4's question 6: the receiver pool is sized at ten plus release headroom (§10), and the soak's population is known (§12 step 7).
  • The rate: all of it — the front end, the searcher, every receiver, and the cancellation if it is built — must run comfortably at 30 MSa/s or more, and running at exactly 30 MSa/s counts as slow. That is the machinery's floor; the waveform's own operating point is below it (13 MSa/s in, next table), and the page prices every option at both (§6.4), not as a benchmark to run at the end.

The numbers the page is worked at — these supersede §5.2's, which were the async spec's waveform:

quantity value from
chip rate 2 to 5 Mcps — design to the worst case, which is per quantity: 5 Mcps for anything priced per sample, 2 Mcps for anything priced per tile given
code 1023 chips → one epoch is 204.6 µs at 5 Mcps, 511.5 µs at 2 given
pure-code window / frame 450 / 4950 symbols on the data clock — 167 ms / 1.83 s at any chip rate; 813 / 8960 whole epochs at 5 Mcps, 324 / 3584 at 2; a frame edge at no chip phase given (2026-09-03; in symbols, no epoch alignment, 2026-09-05)
coherent depth D ≤ f_epoch/√1000 in non-overlapping blocks: 154 at 5 Mcps, 61 at 2 — a 32 Hz bin, 3 and 8 aligned blocks per window the Doppler rate (< 500 Hz/s) over one block, §2.3
DDC input 13 MSa/s given — chosen to force the arbitrary-ratio path (§6.4)
DDC output 2× chip rate: 10 MSa/s at 5 Mcps, 4 at 2 (spc = 2) given; the ratios 1.3 and 3.25 both lack an integer factor
samples per epoch 2046, at every rate 1023 · spc
chip pulse rectangular — no pulse shaping on the chips given (2026-09-03); every §12 harness renders rect chips and correlates against a rect replica
Doppler tile 1/T_epoch = 4.89 kHz at 5 Mcps, 1.96 kHz at 2; a tile spans ± half that, subdivided into D rows of 32 Hz the window_bins tile index × the slow-time row
uncertainty ±50 kHz to start; Doppler pre-compensation will likely bring it to ±5 kHz given — design at the full width, and record what the narrow one saves
tiles over ±50 kHz 21 at 5 Mcps, 53 at 2 the engine's own rule, acq_cover_window_bins: 2·ceil((U − span)/(2·span)) + 1, measured in §12.1; the searcher's worst case is the low rate
tiles over ±5 kHz 3 at 5 Mcps, 7 at 2 same rule, after pre-compensation
cores at least 48 on the one server given (2026-09-03) — the population's ~7.6 cores at the operating point and ~17 at the floor (§12.1) are a third of the box, not a fit
budget, one core, operating 77 ns per input sample; per output sample 100 ns at 5 Mcps, 250 at 2 1/13e6, 1/10e6, 1/4e6
budget, one core, at the floor 33 ns per input sample; 43 per output at 5 Mcps 1/30e6, same ratio

The maintainer's description of the running system (2026-09-02) adds the lifecycle the policy serves, and it is the shape everything below is fitted to:

The acquisition part continuously looks for signals, and async receivers track them as they are found, until they are gone. A receiver does not stop tracking once it has been assigned.

So there are two kinds of thing on the air side of the bank. A searcher per channel (DDC → search), which runs on every block for the whole life of the process. And a pool of async receivers, one per emitter, each spawned by the track policy from one detection, fed the same samples as the searcher, and living from that hand-off until its own loss decision — the searcher never stops one, never re-seeds one, and never assigns a second receiver to an emitter that already has one. The searcher's product is therefore not "the strongest signal present"; it is every emitter present that is not yet assigned, per dwell.

The receiver is the object that exists. AsyncDsssReceiver (native/inc/async_dsss_receiver/async_dsss_receiver_core.h) is the validated search → refine → track chain in one C object: its searching stage feeds an embedded Acquisition, a hit is turned into a hand-off by acq_build_handoff(), and that hand-off seeds the refine stage. What the lifecycle needs from it is two things and no new receiver : an acquisition input — the searcher's detection arrives from outside as the hand-off — and an internal acquisition bypass for that mode, so the object starts in refining from the given seed and its own Acquisition never runs. That is a difference in constructor, not in method, so it is the ddc/MatchedDDC shape: a second create over the same state, a view in the manifest, the chain past the seed shared verbatim. The receiver already carries a symbol lock detector (lockdet, hysteretic, on the emitted symbols), which is where "until they are gone" is decided — what it lacks is the transition that decision drives (§10).

6.2 What one maximum per dwell loses

The classic detector reports one cell: the maximum of the surface, gated — det_result2d_t on the burst detector, and on the acquisition engine the two maxima dsss-acquisition.md §9.1 describes, the interpolated one to gate and the native one to report. That is still what both do at max_peaks = 1, the default, and it is the gap this section is about; §7.1 is the list that closes it, and §8 (a) is where it lives — one det_peak_list beside det_noise_estimate in det_private.h, under both detectors, with Acquisition.set_max_peaks as the engine's face of it (§12.6 measures it).

With K emitters up, the surface has K peaks, and a maximum reports the strongest. The rest are not below threshold; they are simply not looked at. In the burst use case that costs little — bursts are short and rarely overlap in one channel. In the continuous case the strongest emitter is up for hours, and every dwell for those hours reports it and nothing else, so a second emitter appearing beside it is never acquired while the first is on the air. Nor does hand-off help: the assigned receiver goes on tracking the first emitter, the searcher goes on re-detecting it at every data-free window (the suppression-by-emitter §5.3 asks the bank for), and after the suppression drops that re-detection the dwell has reported nothing at all. The single maximum is the gap, and it is the searcher's, not the bank's — the bank's channel count partitions Doppler into spans, but emitters within one span share a surface, and that is the normal case here.

6.3 What the power spread decides

Two emitters at different Dopplers or code phases are two peaks on the surface, and a detector that reports every peak above threshold finds both — provided the second is a peak above threshold. A strong emitter does not only put one peak on the surface: a 1023-chip Gold code's cross-correlation with itself at every other lag is not zero, and the maintainer's figure for that floor is about −24 dB below the peak (§5.3), and §12.2 measured it on the engine's own surface: exactly that where the bound applies, and −13 dB once the emitter carries data and sits off its tile's centre — the operating case. That floor lies across the whole surface — every Doppler bin, every code phase — so an emitter weaker than the strongest by more than the floor plus the detection margin is under the strongest one's sidelobes: it is not a peak, and no peak detector reports it.

Two things follow, and they are why the mechanism forks on the spread:

  • The CFAR reference is right to rise. det_noise_estimate measures the surface's floor, and with a strong emitter present that floor is the strong emitter's sidelobes. The threshold moves up with it, which is what CFAR means — the weak emitter is genuinely below the floor of the surface as it stands.
  • Only removing the strong emitter lowers that floor. A peak list cannot; that needs cancellation, and cancellation needs a replica of the strong emitter — which is a different object with a different information source (§7.2).

So the decision is the emitters' power spread, §5.4's question 7, and it is open. Inside the floor a peak list suffices; beyond it the weak emitters need the strong ones cancelled first. This page covers both branches (§9), so that whichever way the number falls the page already says what to build.

The −24 dB is the three-valued bound for a full-period, zero-Doppler cross-correlation, and §12.2 shows why it is not the design number: a data transition inside the epoch or a half-tile Doppler offset — the searcher's normal case — raises the worst cell at another code phase to −16 dB, and both together to −13. The fork below is at −13 dB.

6.4 The throughput floor

At the operating point one core has 100 ns per DDC-output sample for everything after the front end, and 77 ns per input sample for the front end itself; at the 30 MSa/s floor those are 43 and 33 ns. "Comfortably" means a margin under that, and this page takes half as the working target — the whole population inside 50 ns per output sample per core at the operating point, 21 at the floor, across the cores the application gives it — with the margin a number the benchmark reports, not one it assumes. Equality with the budget is a failure by the requirement's own words.

The decimation is only 1.3× at the top of the rate range, and that is the fact that shapes the cost: nothing runs at a fraction of the input rate. At 5 Mcps the searcher and every receiver run at 10 MSa/s, three-quarters of what the front end sees, so the population's cost is (searcher + 12 receivers + 10 replicas) per output sample, not that divided by anything. The rate range splits the worst case in two. Every receiver and every replica is priced per output sample, so their worst case is 5 Mcps. The searcher is priced per tile per output sample, and tiles go up as the rate comes down — 21 at 5 Mcps, 53 at 2 — so its tile-samples per second are nearly the same at both ends (210 M against 212 M over ±50 kHz) and its worst case is the low rate, by a small margin, at the full uncertainty. Doppler pre-compensation to ±5 kHz takes the searcher to 3 or 7 tiles, an eightfold cut in its cost and none in anyone else's; the page designs at ±50 kHz and step 8 records both.

The per-stage numbers are measured (§12.1): the searcher over ±50 kHz is 2.1× real time on one core at either chip rate, one tracking receiver is 0.44 of a core, and the arbitrary-ratio front end is 0.18 — so the chain is over the budget on one core before the population is on it, and the population is about 7.6 cores at the operating point. Three things follow for the shapes, and the first two are now requirements rather than expectations:

  • One front-end DDC, shared, on its slowest path — on purpose. There is one frequency channel, so the only stage at the input rate is one conversion, 13 to 10 MSa/s. That ratio was chosen for the budget, not the radio: the DDC's RateConverter builds the cheapest cascade the ratio allows — CIC, halfband, then a polyphase resampler — and 1.3 has no integer factor, so no CIC or halfband stage exists and the whole conversion runs through the polyphase arbitrary resampler, the most expensive sample the front end can produce. The budget is therefore priced with the slow path baked in; a deployment whose rate happens to give an integer factor can only be cheaper, and a bench that ran at a convenient ratio would have measured the wrong front end. The receivers take chip-rate input already (AsyncDsssReceiver ingests at chip_rate · spc), so they share this one front end rather than each owning one.
  • The searcher is one window-tiled engine, not a DDC bank. A bank of 21 to 53 DDC → search channels at anything like 48 ns each is 14 to 33× real time at the operating point on one core and fits on no node; the continuous engine's own window_bins tiling covers the uncertainty in one engine at the same D = 1 sensitivity (burst-bank.md §11.2), and with the peak list inside it (§8 (a)) it lacks nothing the bank had for this use case. That is a change to what §11.2 assumed, and the throughput floor is what forces it.
  • The receivers are the population's cost, and they parallelize; the cancellation does not. Twelve receivers at 10 MSa/s on the application's threads scale across cores; the replicas on the strong branch are subtracted on the searcher's path, serially, ten of them per block — so (iii)'s coupling has a per-sample price on one thread, and it is the searcher's.

What is not known is every per-stage number at this rate: the front-end DDC per input sample; the searcher per output sample with the list at both ends of the rate range; one receiver per output sample; one replica per output sample. §12 step 8 measures them, and the bench that does it must count what it acquired and tracked beside the rate — a throughput that was reached by missing an emitter is not a throughput.


7. The two mechanisms

7.1 The peak list with exclusion zones

The list is the maximum, iterated:

repeat up to max_peaks times
  take the maximum of the surface
  if it is below eta · noise_est: stop
  if it is within ±1 chip of a listed peak's code phase, at any tile:
     hold it as that emitter's twin; list it only if it is still there,
     at the same tile, on the next epoch
  report it (at its native row where the surface is interpolated)
  exclude ±1 Doppler bin × ±1 chip around it

The second rule was added after §12.2 measured that one emitter makes more than one peak: a data transition inside the epoch splits it into equal twins two or more tiles apart, and a half-tile Doppler offset throws a −9.5 dB sidelobe two tiles away — every one at the emitter's own code phase. A twin moves with the transition's position from epoch to epoch and is absent in the emitter's data-free window; a real second emitter at the same code phase stays at its tile. So the rule holds a same-phase peak for one epoch rather than dropping it, and costs no resolution at other code phases, where the adjacent tiles remain candidates.

The Doppler axis is the window_bins tile index, 1/T_epoch apart — 4.89 kHz at 5 Mcps, 1.96 at 2 — with D slow-time rows inside each tile (§2.3), so the interpolated-vs-native split of dsss-acquisition.md §9.1 applies as on the burst engine: the gate reads the interpolated slow-time axis and the report is the native row. (§12.6 measured the list at D = 1, where the two collapse.)

Why one bin and one chip. They are the widths of one emitter's main lobe: an epoch's frequency response is the sinc of a one-epoch rectangle, whose first nulls fall one tile (1/T_epoch) either side, and the code's autocorrelation triangle reaches zero one chip either side of its apex. Inside that zone the surface belongs to the emitter just reported — its own shoulders would otherwise be the next "peak" — and outside it a second emitter has its own maximum. The zone is therefore also the detector's resolution: two emitters within one bin and one chip of each other are one peak, distinguishable by nothing on this surface (§5.3), and that is a property of the code and the dwell, not of the detector. In surface units the zone is ±interp rows (one row at D = 1) and ±spc columns — two, here — circular in code phase; on the native report it is ±1 and ±spc.

The threshold does not change. eta is sized from N = searched_bins · code_bins cells (dsss-acquisition.md §9.1); it counts the noise's chances over the surface, and a second reported peak is another draw from the same cells against the same gate, so the per-dwell false-alarm event — any reported peak is false — is bounded by the same union. Exclusion zones remove a few cells from the count, in the safe direction and negligibly. What does change is the floor under a strong emitter (§6.3): the reference rises, so does eta·noise_est, and false peaks in the strong emitter's sidelobes are what §12 step 4 measures.

Fixed size. max_peaks is configuration; a dwell's list is up to that many acq_result_t records from push(), strongest first, sharing the dwell's samples_consumed and noise_est; nothing allocates per dwell and nothing grows with time — the duration rule of §5.1. The classic single-peak result is the same list at max_peaks = 1, the default. A held twin takes one of the slots that dwell without being reported. The population sizes it: on the branch where the searcher sees every emitter (§9) the list must hold all ten plus the false peaks the gate admits, so max_peaks is of order 16; on the branch where assigned emitters are cancelled it holds only what rose since the last window, a few.

As built. det_peak_list (native/inc/detector/det_private.h) is the iterated maximum with the zone, circular on both axes, over a caller-initialised mask; the engine seeds the mask with the cells outside its searched band, sets the gate in the surface's own units (eta · noise_est on the coherent surface, eta_nc² · noise_pow / 2N on the non-coherent one), maps each pick to its native row within its own zone, and applies the two-epoch rule with the held candidates carried in the state blob (v2). Acquisition.set_max_peaks(n) / BurstAcquisition.set_max_peaks(n) set the capacity, 1 to 64. Pinned by test_acq_core.c (the primitive on a synthetic surface; the API and the blob) and validate_acq_peak_list --check (two emitters, the split twin held then listed, twins under PRBS data, the rate under noise), measured in §12.6.

7.2 Cancellation

Cancellation subtracts a replica of a strong emitter so the surface underneath it can be searched. The replica needs the emitter's code phase, Doppler, amplitude and carrier phase — and, for any epoch that is not that emitter's own data-free window, its data. That last item decides the shape, because emitters' frames are not aligned: while emitter A is in its data-free window, emitter B is carrying data, and B's contribution to A's dwell is a data-modulated, straddle-lossed correlation whose sign flips at a place the searcher does not know.

Where the replica's information comes from is therefore the design axis:

  • From the peak (acquisition-side). The detection gives code phase and Doppler to within a cell; amplitude and phase must be estimated from the complex peak; the data is unknown. Exact only in the strong emitter's own data-free epoch — which is not, in general, the epoch being searched.
  • From the assigned receiver (decision-directed). The receiver already tracking the strong emitter knows its chips, its carrier, its amplitude, and its decided bits, block by block, and refines all of them continuously. Its replica is exact to the tracker's own error, data included.

And where the subtraction happens is the second axis: on the surface (subtract the emitter's known response, the code's autocorrelation across lag times a sinc across Doppler, scaled by the complex peak — the radio astronomer's CLEAN) or on the samples (regenerate the chip stream, subtract, correlate again).


8. The shapes — where each piece lives

The air side of the bank, end to end, as built — every box is a shipped object and every number the operating point of §6.1:

flowchart LR
    E["up to ten emitters on ONE Gold-1023 code<br/>5 Mcps, 2700 sym/s, 450 code-only symbols of every 4950<br/>each at its own Doppler within ±50 kHz (§6.1)"]
    E --> DDC["DDC: 13 MSa/s → 2 samples per chip (§6.4)"]
    DDC --> X["one block per push,<br/>stamped by the feeder's clock (§8.1)"]
    subgraph POOL["AsyncDsssPool (§8.2) — one push(), in this order"]
        direction LR
        X --> S["the searcher: Acquisition, continuous<br/>D = 154 epochs coherent per window tile (§2.3)<br/>21 tiles, a roll per thread; a list of 16 peaks (§7.1)"]
        S -->|every peak| Z{"within a chip of a<br/>live row's code phase?"}
        Z -->|yes| OWN["that emitter's own:<br/>nothing"]
        Z -->|no, a slot free| SEED["acq_build_handoff() → seed()<br/>«seeded»"]
        Z -->|no slot free| DROP["«dropped»"]
        SEED --> RX["12 hand-off receivers, idle until seeded<br/>refine → track (§4, §11); every one fed every block,<br/>across the threads"]
        X --> RX
        RX -->|"status(): Doppler, chip phase, flags"| T["the assigned table:<br/>one row per slot, keyed on locked loops"]
        T --> Z
        RX -->|"both flags down for 2 s (§10),<br/>or held past the on-time"| REL["release: row cleared, reset() to idle<br/>«lost», «released»"]
        REL --> RX
    end
    RX --> OUT["per slot: status() by value,<br/>symbols() borrowed"]
    POOL --> LOG["event log (§8.1): every transition<br/>a sample-stamped SigMF annotation"]
    S -.->|"acq.* (§2.4)"| TLM["telemetry"]

No replica leaves a receiver and nothing is subtracted before the searcher: the operating spread is inside the knee (§9, §12.6), so branch one is what shipped and §11.4 is not built. The lifecycle of one slot is §8.2's state diagram; the measurement that certifies the whole is §12.14.

The peak list has one place it belongs and two it could be put:

mechanism fits cost
(a) one primitive under both detectors a peak-list function beside det_noise_estimate in det_private.h: (mag, ny, nx, gate, excl_rows, excl_cols, mask, out[], max_peaks) → count; both callers use it, the burst detector at max_peaks = 1 one argmax instead of the two private copies; CorrDetector2D can gain the list when it needs it; the interpolated/native split stays where it is, in the caller acq_result_t is unchanged — a dwell is up to max_peaks records sharing samples_consumed — and det_result2d_t is untouched; the cost is the mask and the held table, fixed-size
(b) inside acq_compute_stat only the engine's loop iterates with exclusion; detector2d stays single-peak the engine alone changes a third private copy of the pick, and the two detectors' behaviours diverge on the same surface
(c) a second pass over the surface, outside the bank asks the engine for its surface and picks peaks itself no engine change the surface is the engine's scratch, not a product — exporting it is a copy of ny·nx·interp floats per dwell, and the gate's eta leaves the engine

(a) is the repository's rule applied — fix it where the primitive is defined, once — and the only one under which the burst detector and the acquisition engine keep agreeing. It is what shipped (§7.1, as built).

Cancellation is a separate object, and its shape follows its information source:

mechanism fits cost
(i) surface CLEAN, from the peak subtract A·acf(τ − τ_i)·sinc(f − f_i) from the complex surface for each strong peak, then re-pick no second correlation; stays inside the engine needs the complex surface where the engine keeps \|·\|; the response is exact only in the strong emitter's own data-free epoch, and a data transition inside the dwell leaves a residual the model does not have
(ii) sample SIC, from the peak regenerate the strong emitter from its detection, subtract from the epoch, correlate again one object, no dependency on the tracker pool one extra correlation per cancelled emitter per dwell; the same unknown-data residual as (i); amplitude and phase from a single cell's estimate
(iii) sample cancellation fed by the assigned receivers DDC → cancel(assigned) → search: each assigned receiver publishes its replica for the block (or the estimates that make one: code phase, Doppler, amplitude, phase, decided chips); the searcher subtracts every replica before it correlates the only replica that is right through data; makes the searcher see exactly what is not assigned, which retires the suppress-by-emitter table (§5.3) — an assigned emitter is not re-detected because it is not there couples the searcher to the receiver pool on the push path; a receiver that has lost lock publishes a wrong replica, so the subtraction must be lock-gated; one replica per assigned emitter per block; and the receivers still see the raw stream

(iii) is the shape the lifecycle already asks for. The receivers own the emitters and keep tracking them regardless of what the searcher does; the searcher wants to see only what they do not own; and only they know the data — AsyncDsssReceiver's track stage holds exactly the replica's ingredients per block: the live carrier loop's phase and frequency, the Dll's code phase, the despreader's amplitude, and the decided symbols. It is also the option that makes the two branches of §9 one mechanism at two settings. Its cost is a real coupling — whoever holds the receiver pool must also stand on the searcher's push path — which is why §5.4's question 5 (who owns the lifecycle) becomes load-bearing the moment the strong branch is chosen, and not before.

A refinement (iii) opens but this page does not take: a receiver can be fed the stream with every other assigned emitter cancelled, which lowers its own floor as well. That is the receivers' concern, on their own path, and it changes nothing about the searcher.

8.1 The holder — time, events, telemetry (decided 2026-09-02)

The searcher, the receivers and their records are sample-domain and clock-agnostic (§2.2): every record carries a stream position, never a time. The holder of the pool — the orchestrator, whichever language it is written in — is the one component that owns a clock, and it is fed by one of two sources. The decisions, and the reasons:

  • The feeder owns the clock; nothing below it sees a timestamp. One dp_sample_clock_t per stream, anchored from the source's own metadata and stamping every record as stamp_at(n). A live BLUE file (the reader's read_follow()) anchors from the header's timecode and xdelta when they are present, and from the wall clock at open — flagged as such through the reader's provenance enums — when they are not. A NATS stream anchors from the first frame that carries a timestamp_ns and then counts samples; a sequence gap is an event, not a re-anchor, because the sample count is what the DSP consumed and a per-frame re-anchor would move every record under it. One replay and one live run then produce identical records, and stamping is one function at the edge.
  • Events are SigMF annotations, appended live, finalized at close. A transition — seeded, tracking, degrade, lost, released, a sequence gap — is an annotation: sample-indexed (core:sample_start, core:sample_count), which is exactly the rule above, with the receiver's fields under a doppler: namespace. A .sigmf-meta is one JSON document, which a streaming writer cannot keep rewriting, so the run appends annotation objects to a flat, tail-able, crash-safe file, and a finalize step writes the proper sidecar — global, captures, annotations — the same way the writer already produces its sidecar at close. For a BLUE input the sidecar names that file as the dataset; for NATS it names whatever the recorder wrote, or is metadata-only. core:freq_lower_edge/upper_edge need the channel's fc, which a BLUE header carries and a NATS frame does not: omitted when unknown, never guessed.
  • Telemetry stays the flat record file, and the sidecar points at it. dp_tlm records are a time series at thousands per second, stamped by the same clock; that is the wrong shape for annotations and the right one for np.fromfile. A doppler:telemetry global field carries the path and the record dtype, so one sidecar indexes the dataset, the events and the telemetry, each in the format that suits its rate.
  • C first, one emitter. The event log is a C object — append an annotation, finalize to SigMF — over the writer's existing JSON emitter, not a second one; the holder calls it from Python today and from the application's C++ tomorrow.

As built. dp_event_log (native/inc/dp_event_log/dp_event_log_core.h, telemetry.EventLog) is that object. append() renders one annotation as a line of JSON and flushes it, so the file is tail-able live and a kill costs at most the event being written; finalize() collects the lines into wfm_sigmf_meta_json_ex() — the writer's emitter, extended to take extra global members and extra annotations, with wfm_sigmf_meta_json() now the call with both absent, so there is still exactly one place that spells global and captures. The doppler: fields are staged before an append from a fixed table, which is what keeps the object ignorant of any particular receiver's record and allocation-free per event. A run's flat file can also be rendered afterwards, by another process, with dp_event_log_write_meta() — which is what the crash-safe half is for.

What this asks of the receiver is what §11.3 built: a record with the state and the clocks in samples and no time in it. The gap the first decision opened on the reader's side is closed: dp_isotime_parse() — the inverse of the formatter that header already owns — reads SigMF's core:datetime, so a SigMF capture anchors on its own timeline as a BLUE one does and reports t0_source "sigmf" where it used to report "none". A stamp carrying no timezone is refused rather than read as UTC: being wrong by hours looks authoritative in a way that reporting nothing does not.

8.2 The pool — one object holds the population (decided 2026-09-03)

Decided by the maintainer 2026-09-03; the surfaces it composes are the shipped ones of §4.1, §7.1 and §8.1. Built 2026-09-06 as async_dsss_pool (AsyncDsssPool), §12.13.

The other half of §5.4's question 5 is answered the way this library answers it: the holder is a C object, async_dsss_pool, and the Python face is glue. It is the one composition on the air side of the bank, and nothing about this waveform or this population is baked into it: every number below — the maximum on-air time of an emitter among them, max_emitter_on_time_secs, default MAX_EMITTER_ON_TIME_SECS = 15*60 (§6.1) — is a create parameter whose default is the operating point of §6.1, the searcher's and the receivers' own parameters pass through it untouched, and the pool knows only what it was given — another code, another frame, another population is another create(). Everything it holds is sized once, at create:

  • One searcherAcquisition in continuous mode with the block coherence of §2.3 and max_peaks of order 16 (§7.1) — its tiles fanned a roll per thread across the threads the pool is given, the forward transform and the list on the calling thread.
  • n_slots hand-off receivers — twelve here, §10's ten plus release headroom — created idle. An idle or lost receiver consumes and discards what it is fed, so every receiver is fed every block and the feed has no per-state branch; the receivers run under dp_parallel.h across the thread count the application gives (§6.4: they are the population's cost, and they parallelize).
  • The assigned table: one row per slot — the seed's coordinates, and the receiver's current Doppler and chip phase, refreshed from status() before every dwell is read (§9: an emitter drifts up to 0.92 kHz between windows, so the seed is the wrong key).
  • The clock and the event log, borrowed at create (the dp_tlm_capture shape): the pool is the one component that stamps, and it stages the slot, the Doppler, the chip phase and the C/N0 on every transition it logs.

One push() per block does, in order: feed the searcher; refresh the table; drop every peak within one chip of a live row's code phase, at any Doppler, as that emitter's own (the code axis alone, not §7.1's one row by one chip: a tracked emitter's data blocks put smeared copies of it at its own phase rows away, §12.14); for each survivor, acq_build_handoff() and seed() into a free slot, or count it dropped when there is none; feed every receiver; then, for each slot whose receiver reports lost, clear the row, reset() the receiver to idle, and log released. seed()'s own refusal while a receiver is live is the second guard behind the table (§11.1), so a bookkeeping error cannot become a double assignment. The transitions — seeded, tracking, degrade, lost, released, dropped — are the event log's annotations, at the sample the receiver's record reports.

One slot, as the pool drives it — the receiver's own states, the pool's transitions between them, and the label each one writes to the log:

stateDiagram-v2
    direction LR
    [*] --> idle : create(), reset()
    idle --> refining : a peak at no live row's code phase, a free slot — seed(), «seeded»
    state assigned {
        refining --> tracking : the refine hands over — «tracking»
        tracking --> tracking : one flag down — «degrade»
        tracking --> lost : both flags down longer than lost_confirm_s — «lost»
    }
    lost --> idle : the row cleared, reset() — «released» (lost)
    assigned --> idle : held past max_emitter_on_time_secs — «released» (on_time)
    note right of idle
        A peak within a chip of a live row's code phase is that
        emitter's own: nothing happens and nothing is logged.
        A peak with no free slot is counted and logged «dropped»
        and moves no slot.
    end note

The receiver decides lost (§10) and the pool acts on it; the pool alone decides the on-time release, and nothing else takes a slot from a live receiver. idle is the hand-off flavor's resting state — waiting for a seed, never searching — and a released emitter still on the air re-enters at its next window as a new detection.

What comes out, per slot and by index, the burst_capture shape: the status record by value, and the symbols the receiver decided on this push, borrowed by pointer from a buffer sized at create by steps_max_out(). Nothing allocates per push or per transition, the pool never exceeds n_slots, and a released emitter still on the air is a new detection at its next window into whichever slot is free — the one re-assignment the lifecycle permits. Replay and live runs produce the same records, because nothing below the pool sees a time.

The pool is off the searcher's push path: the spread is 10 dB (§5.4), inside the floor, so no replica is subtracted and §11.4 is not built.


9. The two branches

Both branches share the peak list (a) and the assigned-emitter table the bank keeps in any case: which emitters have a receiver, at what Doppler and code phase now (the receiver's estimate, since an emitter drifts at up to 500 Hz/s between windows, not the detection's). The branches differ in what the searcher is allowed to see.

Spread inside the floor — the list is enough. The searcher sees every emitter, assigned or not, and reports every peak above eta. The bank drops any peak within one exclusion zone of an assigned emitter's current estimate and hands the rest to the policy. An assigned receiver is never touched by a re-detection of its own emitter. What has to hold: no unassigned emitter above the floor is missed while a stronger one is up (§12 steps 2–3), and the re-detection of an assigned emitter never becomes a second receiver (§12 step 7).

Spread beyond the floor — cancel, then list. The searcher's input has every lock-gated assigned replica subtracted (iii), and then runs the same list. The assigned table does the same job as before, now only as a guard against the residual: a cancelled emitter that is imperfectly cancelled leaves a peak at its own coordinates, and the zone around the receiver's estimate is what keeps that residual from becoming a detection. What has to hold: the residual after cancellation sits below the unassigned emitters the application needs to find (§12 step 5).

The branch is chosen by one number — the application's operating spread against the knee §12 step 3 measures — and it is chosen: 10 dB, inside the 18–21 dB knee measured at D = 1 (§12.6) and further inside it in the coherent blocks, where the floor is −21. The list branch ships; the cancellation branch strictly contains it and stays designed here (§7.2, §11.4) for a waveform whose spread is not this one.


10. The release — the lock detector decides "gone"

"Until they are gone" is a decision the receiver makes about itself, and the pieces of it exist. AsyncDsssReceiver carries two de-chattered lock flags, each a lockdet — level hysteresis between a declare and a drop threshold, time hysteresis of consecutive looks either way, a NaN look counted as a miss (native/inc/lockdet/lockdet_core.h):

  • Code lock, get_code_locked(): the live Dll's own CFAR-based, verify-counted detector — "am I despreading". This is the fundamental DSSS lock: an emitter that leaves takes its code with it, and the correlation at the tracked code phase and Doppler falls to the floor.
  • Symbol lock, get_locked(): the BPSK statistic cos(2φ) over the emitted symbols, SNR-weighted over a 30-symbol dwell, declared after 30 consecutive symbols at or above 0.5 and dropped after 15 consecutive below 0.3 (ASYNC_DSSS_RX_LOCK_*). This is the health of the carrier leg: a cycle slip or a deep fade drops it while the code is still being despread.

What is missing is the transition. Today a receiver whose flags fall keeps running its loops on noise, and the only exit is reset(), which returns to searching — a state the hand-off mode of §6.1 does not have.

The rule. An emitter is gone when both flags are down, continuously, for longer than the longest fade the link must ride. §12.3 first measured code lock chattering three times a second on a healthy signal and off 96% of the time at the floor, which looked like the wrong flag; §12.4 traced that to the detector's looks — 20 quarter-epoch partials, sized for nothing — and §3.7 fixed it: sized for the C/N0 and coherent over a symbol, code lock drops within 4–12 ms of a real loss, holds through a phase step, and never dips on a healthy signal. It is the presence flag. Symbol lock, a 30-symbol dwell with hysteresis, is the carrier leg's health. Both are still CFAR flags on power, so a fade takes both down for its duration and brings both back — which is why the release is both down, for longer than the fade, and why the confirm interval is set by the fade the link must ride, not by the detectors.

The transition. Hand-off mode adds a fourth state, lost, beside searching / refining / tracking, and the receiver enters it on the rule above. In it the loops stop updating, the replica (§8 (iii)) is no longer published — its gate is code lock, which after §3.7 drops within milliseconds of a real loss and not otherwise, so publication stops at that drop, before the confirm interval has run — and the receiver reports lost to whoever holds the pool. The holder then releases the assignment: the emitter leaves the assigned table, so the searcher may report those coordinates again, and the receiver is reset to the hand-off mode's idle — waiting for a seed, not searching — for the pool to reuse. Nothing else moves: the searcher was never told to stop looking there and the other receivers are untouched. The one re-assignment the lifecycle permits is this one: an emitter released while in fact still present is re-detected at its next data-free window and seeded into a fresh receiver, which is a recovery, not a hand-back.

What the interval costs, and what it buys. Against on-times of 5 to 15 minutes — 15 the maximum, adjustable (§6.1) — release latency is nothing: both flags are down within 25 ms of a switch-off (§12.3), and a confirm interval of even two seconds — longer than the one-second fades measured — is under 1% of the shortest on-time. The number that matters is the other one, the false release. A receiver that releases an emitter still on the air loses that emitter's data until the next data-free window plus a refine (the cadence of §5.4 question 4), and on the cancellation branch its replica leaves the searcher's input for the same interval, so the floor rises under every weaker emitter for a frame. The confirm interval is therefore sized from a false-release budget — far rarer than once per on-time, per receiver, the on-time being the 15-minute maximum, not a typical one — in exactly the vocabulary lockdet documents: at the per-look miss probability the tracked C/N0 gives, n_down consecutive misses set the false-drop rate, and det_verify_count() sizes n_down against the budget. Both the miss probability and the resulting interval are measurements (§12 step 6).

The pool. Ten emitters at once plus the receivers still inside a confirm interval on emitters that have just left: at one departure a minute and a confirm interval of a second, the headroom is one. A pool of about twelve hand-off-mode receivers, each a tracker chain on the application's threads beside the searcher's own cost (burst-bank.md §11.2), is the whole population.

The read-back. Whoever holds the pool needs to know, for each receiver, which signal it is tracking, for how long, and in what condition. The facts have two owners, and the split falls out of who produced each one:

  • The orchestrator owns the assignment. It handed the seed to the receiver, so it holds the DetectionEvent verbatim — timestamp_ns, samples_consumed, chip_phase, doppler_hz_est, cn0_dbhz_est — beside the receiver it went to. It fed every sample since, so it holds the sample count at assignment and the count now; duration is their difference over the rate, the repository's dp_sample_clock_t arithmetic, replay-safe. And it recorded the state changes it was told about — refining to tracking, tracking to lost — with the sample count at each. Nothing here needs the receiver to remember its own history, which keeps the receiver thin: it tracks; the orchestrator keeps the books. This is the assigned table of §9 with three more columns, and it is what question 5's holder holds.
  • The receiver owns its condition. Only it knows where the emitter is now — the live carrier loop's Doppler, the Dll's code phase, the C/N0 the despreader sees (the drift since the seed is that against the orchestrator's row) — and its health: the state it is in, both lock flags, the symbol-lock metric against its declare threshold, the residual carrier errors the header already exposes, and, in lost, the samples since the code flag dropped. Today that is a scatter of getters — get_locked, get_code_locked, get_lock_metric, get_car_nco_freq, and the rest — each a separate call, so a reader that wants one consistent picture across a push on another thread cannot get one. The shape that fits is one status record, returned by value — the measure objects' single record (ToneMetrics), a jm-generated structseq over a C struct — read on demand and never pushed.

The orchestrator's now columns are refreshed from the receiver's record at whatever cadence it reads, and the exclusion zone of §9 is keyed on those, not on the seed. So one read per receiver per window is the minimum, and the table is the join of the two owners' facts.

The record is a read of live state, distinct from get_state(): the bytes triplet is for resuming the receiver elsewhere, the record is for describing it here, and the two must not be confused — a record that tried to be both would be a serialized blob a human cannot read. On the cancellation branch the replica output is a third thing again, per block and on the push path, and rides neither.


11. What the multi-emitter use case needs from the tracking receiver

§6–§10 fix the lifecycle: a searcher per channel that never stops, and one AsyncDsssReceiver per emitter, assigned once from a detection and tracking until its own loss decision — never stopped, never re-seeded, never doubled up by the searcher. Up to ten emitters at once, each on the air for 5 to 15 minutes, on one Gold code, on a stream the whole pool must consume at 30 MSa/s comfortably. The receiver of §4 is the right object for that and needs five things, none of which is a new receiver. Each is a Phase-1 design here and an implementation item in adding an algorithm's order; the measurements that size them are §12.

11.1 The hand-off mode: an acquisition input, and an internal bypass

Today the only way in is the receiver's own search. In the pool the search is the searcher's, so the receiver needs to take a detection from outside — the DetectionEvent of §2.2, exactly as its own acq_build_handoff() would have produced it — and to skip its own acquisition entirely in that mode: no embedded Acquisition is built (a 23-to-53-tile engine per receiver, twelve times over, is memory and work nothing uses), the searching branch of push() is unreachable, and the object starts in refining from the given seed.

That is a difference in constructor, not in method, so it is the ddc/MatchedDDC shape: a second create over the same state, declared as a [[async_dsss_receiver.views]] entry in the manifest, the chain past the seed shared verbatim. Two consequences follow:

  • seed(event) is a method on the base type, not the view's alone. The base receiver's own hit already takes this path internally — a hit is a seed the object made for itself — so exposing it is honest on both flavors, and a view shares methods verbatim in any case. On a receiver that is not idle it refuses: "assigned once" is enforced by the object, not by the orchestrator's discipline.
  • reset() in hand-off mode returns to idle — waiting for a seed — not to searching, because there is no search to return to. Samples pushed in idle are consumed and discarded, so the feeding loop has no special case, and the pool reuses the object without reallocating.

11.2 The lost state, and the release

"Until they are gone" is the receiver's decision, and §4's two lock flags are the pieces of it; what is missing is the transition. Today a receiver whose flags fall keeps running its loops on noise, and the only exit is reset().

The rule, argued in §10 and measured in §12.3: an emitter is gone when both flags are down continuously for longer than the longest fade the link must ride. With the detector sized and symbol-aided (§3.7), code lock is the presence flag — off within milliseconds of a real loss, held through a carrier disturbance, never dipping on a healthy signal — and symbol lock the carrier leg's health; but a fade takes both down for its duration and returns both, so neither alone is the release. One flag down is a degrade, reported and not acted on; both down is the clock starting.

Hand-off mode adds a fourth state, lost, beside searching / refining / tracking. On the rule above the receiver enters it: the loops stop updating, the replica of §11.4 stops being published — at the code-lock drop, before the confirm interval has run — and get_lost() reports it. The holder of the pool then releases the assignment and calls reset(), which in this mode goes to idle. The confirm interval is a time, not a verify count: the measured fades take both flags down for their whole duration and bring them back after, so the interval must exceed the longest fade the application wants ridden, and against 5-to-15-minute on-times two seconds costs nothing. What a false release costs is a frame of that emitter's data plus, on the cancellation branch, a frame of raised floor under every weaker emitter; §12.3's on-time run puts the both-down rate on a healthy signal at zero in thirty seconds, and a longer run is what bounds it.

11.3 The status record

The holder of the pool needs to ask each receiver what it is doing. The facts split by who owns them (§10): the orchestrator made the assignment and fed the samples, so it holds the seed event verbatim, the sample counts at assignment and at each state change, and the duration they give by the dp_sample_clock_t arithmetic — nothing the receiver has to remember. The receiver owns only what it alone knows, and today that is a scatter of getters (§4's get_* family, one call each), which a reader on another thread cannot assemble into one consistent picture across a push().

So the receiver gains one status record, returned by value — the measure objects' single = true record (ToneMetrics is the model), a jm-generated structseq over a C struct, read on demand and never pushed — carrying: the state (idle / searching / refining / tracking / lost); where the emitter is now — the live carrier loop's Doppler, the Dll's chip phase and code rate, the despreader's C/N0 estimate; both lock flags, the symbol-lock metric and its threshold, the two residual carrier errors; and the samples since the state was entered and, in lost, since the code flag dropped. The existing getters stay as the same fields' other face. The orchestrator refreshes its now columns from this record at whatever cadence it reads — once per data-free window is the minimum, because the searcher's exclusion zone is keyed on the receiver's current estimate, not the seed. The record is a read of live state and is not get_state(): the bytes triplet resumes the receiver elsewhere, the record describes it here.

11.4 The replica output

On the strong branch of §9 — emitters more than the measured floor (−13 dB, §12.2) apart in power — the searcher cancels every assigned emitter from its input before it correlates, and the only replica that is right through data modulation is the assigned receiver's: it holds the live carrier's phase and frequency, the Dll's code phase, the despreader's amplitude and the decided symbols, block by block. So the receiver gains a replica output: after a push(), the reconstructed chip stream of the samples just consumed — code at the tracked phase and rate, carrier at the tracked phase and frequency, amplitude from the prompt, data from the decisions — into a caller buffer, for the searcher to subtract.

Three things about it are design, not detail:

  • It is lock-gated on code lock. A receiver whose code lock is down publishes nothing, so a wrong replica is never subtracted. That is safe only because §3.7 made the flag honest: before the fix it dipped a few times a second on a healthy signal, and a gate on it would have dropped the replica, and raised the searcher's floor, that often for nothing (§12.3, §12.4).
  • It lags by the decision latency. The data on a block's chips is known only once the matched filter and the symbol timing have decided the symbols under it, some symbols after the block was pushed. The replica for block k is therefore complete only later, and the searcher's input is a delayed copy of the raw stream — a ring of the raw samples sized by that latency, which the holder owns. The receivers themselves always see the raw stream, live.
  • It is per output sample, on the searcher's thread. Ten replicas subtracted serially per block is the cancellation's price, priced in §6.4, and the reason the pool's holder stands on the searcher's push path on this branch and not otherwise.

The replica is not needed on the weak-spread branch, which is the branch the 10 dB spread picks (§9); it is not built.

11.5 The cost

Twelve receivers at twice the chip rate — 10 MSa/s at the top of the range — on the application's threads, beside one searcher and one front-end DDC; the budget is 100 ns per output sample per core at the operating point and 43 at the 30 MSa/s floor, half of that as the working margin (§6.4). What that asks of the receiver: nothing allocates per push() or per state change (the pool runs for hours), the replica writes into a caller buffer, the status record is by value, and one receiver's cost per output sample is a number the bench of §12 step 8 reports beside the count of emitters it kept.


12. The work that answers it

  1. Measure the floor one emitter puts on the surface. One emitter, no noise, design C/N0; tabulate the surface's maximum and RMS relative to the peak over (Δf, Δτ) — at zero Doppler across every lag, and at Doppler offsets of 0.5, 1, 2, 4 bins — with and without a data transition inside the epoch. Beside it, noise_est with and without the emitter present: how far the CFAR reference rises. Expected: the three-valued −24 dB at zero Doppler, something between that and −30 dB elsewhere. This is the number the branch decision uses, and it is the engine's, so it belongs in acq's characterization. Done (§12.2): the Gold bound exactly where it applies; −16 dB with a data transition or a half-tile offset, −13 with both; the reference does not rise. The Doppler-offset axis was taken at 0 and 0.5 tile, not the four values planned — the twin peaks a transition makes settled the question before the rest of the sweep was needed.
  2. Separability of two equal emitters. Two emitters at the design C/N0 separated by Δf ∈ {0.5, 1, 2, 4} bins and Δτ ∈ {0.5, 1, 2, 4} chips, 200 trials per cell: Pd of both under the list, and the coordinates each is reported at. Expected: both found outside the exclusion zone, one found inside it, and no cell where the second is reported off its own coordinates by more than a cell. This pins the zone's edges as the resolution.
  3. The power-spread knee, list only. Strong emitter fixed at the design C/N0, weak stepped from 0 to −40 dB below it in 3 dB, 200 trials each, at a Δf/Δτ well outside the zone: Pd(weak). Expected a knee at the floor plus the detection margin. The knee is the decision — an operating spread inside it means branch one and no cancellation object.
  4. Pfa under the list. Pure noise, max_peaks ∈ {1, 4, 8}, the same frame count dsss-acquisition.md §9.1 used: realized per-dwell Pfa against configured. Expected unchanged, since N counts cells, not peaks. Then with one strong emitter present: the rate of false peaks in its sidelobes — if it is not the configured rate, the reference is not tracking the raised floor and that is a CFAR finding, not a list finding.
  5. Cancellation depth, (iii). An assigned receiver locked on the strong emitter; measure the residual after subtraction, relative to the strong peak, against C/N0 and against the receiver's steady-state phase and timing error; then re-run step 3 with cancellation on. The residual is the new floor and the distance it moves the knee is what the object buys. Run it once with (ii) as the control: the gap between the two is the price of not knowing the data.
  6. The release. A hand-off-mode receiver locked on one emitter at the design C/N0; the emitter is switched off mid-track, faded 10 and 20 dB for a second, and given one carrier cycle slip, 100 trials each: the time from the event to code-lock drop and to symbol-lock drop, and whether the code flag survives the fade and the slip. Then, with the emitter left on for the length of an on-time, the per-look miss probability of each flag — the number det_verify_count() turns into the confirm interval for a false-release budget. Expected: the code flag rides through the slip, both flags drop within tens of milliseconds of switch-off, and the deep fade is the case that decides the interval. Done (§12.3), and the expectation was wrong on the code flag: it rides nothing — it drops on a fade as fast as on a switch-off and dips a few times a second on a healthy signal. The rule in §10 was rewritten to both flags down for longer than the fade.
  7. The lifecycle soak. The population of §6.1 — one emitter always on, up to ten, on-times drawn around 5 to 15 minutes and capped at the adjustable maximum (§6.1) — at random Dopplers within one span and a spread on each side of the knee. Each emitter's synth runs for the whole soak and visibility is a gain of 1 or 0 at the sum, so an emitter appears at a random frame phase and nothing in the source restarts (§6.1); the phase at first sight is a random burn-in, the way the harnesses already set code phase. Each is acquired once, assigned once, tracked by the same receiver until it leaves, released by the rule of §10, and re-acquired on return; no receiver is ever assigned twice to a live emitter, no emitter above the floor is missed while others are up, and the pool never exceeds twelve. An hour sees about sixty arrivals at the full population, enough to count misses and false releases; the hours-long form with the memory and scratch checks is §5.1's duration requirement and runs once the bank exists. Done (§12.14), scaled to what a harness runs: ten emitters over two minutes at each C/N0, nothing missed, every arrival held within 0.2 s at 45 dB-Hz and 1.3 s at 40; the pool's zone was the wrong shape for its own depth (fixed), and the release fires one to three intervals late because the code flag comes back on noise (open).
  8. The budget, per stage. Its own bench target, on one core, minimum of runs, at the operating point's numbers (§6.1): the front-end DDC in ns per input sample at 13 MSa/s — confirmed to be on the polyphase arbitrary path, not a cascade the bench's ratio let it shortcut (§6.4); then, in ns per output sample, the window-tiled searcher with max_peaks = 16 at both ends of the rate range — 21 tiles at 5 Mcps and 53 at 2 over ±50 kHz, then 3 and 7 over the ±5 kHz pre-compensation leaves — one hand-off-mode receiver tracking at 5 Mcps, and one replica subtraction. Then the whole population — the front end, the searcher, ten receivers, and on the strong branch ten replicas — on the 48 cores the application gives (§6.1), reported as the fraction of real time beside the count of emitters acquired and tracked in the same run, twice: at the operating point and at the 30 MSa/s floor (the same chain fed 2.3× faster). Target: under 0.5 at both. At 1.0 the requirement is missed by its own words, and the stage that owns the excess is the next thing to attack — §6.4's channel number says today's chain is already priced near it. Done, per stage (§12.1): DDC 0.18 of a core, searcher 2.1 at ±50 kHz, one receiver 0.44, slicing +6–11%, receivers add linearly. Not done: the whole population as one run with its detection count beside the rate, which needs the orchestrator.
  9. Decide by the spread. The application's operating spread (§5.4 question 7) against step 3's knee: inside, branch one ships and (iii) is not built; beyond, (iii) is built and step 5's residual is the number its characterization pins. Done (2026-09-03): 10 dB, inside; branch one, no replica.
  10. The stimulus with the window. wfm_synth's continuous DSSS gains the frame of §5.4 as two parameters, code_only_symbols and frame_symbols: W symbols of pure code every F symbols on the data clock, the symbol clock free-running through the window and a frame edge at no particular chip phase, at each emitter's own frame phase — 450 and 4950 here, and 0 is today's waveform, no window at all. C first, in the synth, so every harness below renders the same waveform the application sends.
  11. The block-coherent searcher. The continuous engine with coherent_bins = D inside the roll-tiled span, sized from doppler_rate as §2.1 says, summed in non-overlapping blocks and detected per block (§2.3). Then steps 1, 3 and 4 again on it: the floor in an aligned block (expected −21 dB, the transition-free number), the knee, and Pfa per block; and the sensitivity against D at the design C/N0 — the gain is 18–22 dB on paper and a number here. **Engine built (2026-09-05, §2.3 "as built"), with §2.4's instruments first; the characterization — steps 1, 3 and 4 on the block engine and the sensitivity against D — is the next PR.
  12. The tracker through the window. A hand-off receiver locked at the design C/N0 across ten frames: the symbol-lock flag through 500 epochs without a transition (the symbol clock is unobservable there and coasts), the code flag, and the pull-in after the data resumes. Expected: code lock holds, the symbol flag may drop and recovers within its dwell, and no release fires — one flag down is a degrade. If the flag reads a pure-code stretch as unhealthy, that is a detector finding to fix, not a rule to loosen. Done (§12.9): both flags hold through every window at both C/N0s — code lock never drops, the symbol flag's only dips are its own chatter at the floor and no worse in a window than in the data — and the release never fires. The ramp found a pull-in defect from the searcher's seed instead (#1249).
  13. The searcher's cost with D, and its scaling across threads. Step 8's per-tile number again with the block transform in it, at both ends of the rate range and both uncertainties, beside the memory per channel; then the same push at 1, 2, 4 and 8 threads with a roll per thread — the fraction of the tiles' cost that scales, and the persistent pool's hand-off per push beside the per-call creation it replaces. Done (§12.8): 624 ns per sample serially at the operating point (3.6× the epoch-by-epoch searcher), 288 on four threads; the fan was Amdahl's, 88% fanned at D = 1 and 72% at D = 154, because the per-cell passes after the fan grew with the surface. #1243 fanned those passes per tile and chunked the block-end gather: 523 serially, 164 on four threads (92% fanned), 125 on eight. Memory per channel at D = 154: the block (53 MB), the surface (106 MB complex + 53 MB magnitude), and the kept surface when read.

Steps 1–4 are Python over the shipped engine plus the peak-list primitive, and are the same harness the burst characterization already runs. Steps 5–6 need the hand-off-mode AsyncDsssReceiver (§6.1) with the lost state of §10 and, for step 5, a replica output it does not have today. Steps 7–8 need the pool of §8.2, which needs steps 10–11 first: the population it holds is acquired in the window.

12.1 What was measured (2026-09-02) — step 8, the budget

Three component benches gained operating-point rows and were run on an 8-core build box, one core, minimum of rounds (bench_ddc_core, bench_acq_core, bench_async_dsss_receiver_core; make bench runs all three; the rows are rate=0.77, op5M_*/op2M_* and *,op5M):

stage ns per sample of one core, operating of one core, 30 MSa/s floor
front-end DDC, 13 → 10 MSa/s 13.6 per input 0.18 0.41
searcher, 5 Mcps, ±50 kHz, 21 tiles 214 per output 2.14 4.9
searcher, 2 Mcps, ±50 kHz, 53 tiles 523 per output 2.09 4.8
searcher, 5 Mcps, ±5 kHz, 3 tiles 36 per output 0.36 0.83
searcher, 2 Mcps, ±5 kHz, 7 tiles 75 per output 0.30 0.69
one receiver, tracking (warm), 5 Mcps 44 per output 0.44 1.0
one receiver, cold (search + refine) 89 per output

Seven things this settles, and one it corrects:

  • The arbitrary-ratio front end is 4.2× the integer cascades — 13.6 ns against 2.4–4.0 for rates 0.05–0.5 — so choosing 13 MSa/s to force it (§6.4) priced the front end at its real cost; it is still under a fifth of a core at the operating point.
  • The searcher over ±50 kHz does not fit on one core at any chip rate: 2.1× real time at both ends of the range. Its cost is ~10 ns per tile per output sample, and the tile count rises exactly as the rate falls, so the tile-samples per second — and the core count — are the same at 2 and 5 Mcps. That confirms §6.4's arithmetic and turns its third bullet into a requirement: the tiles must be partitioned across cores, three at the operating point, five at the floor, before any margin. The tiles are independent inverse FFTs off one shared forward FFT, so the split is either inside the engine (a parallel-for over tiles per epoch, which keeps one forward FFT) or across engines each given a slice of the uncertainty (which repeats the forward FFT per slice but needs no threading inside the engine and matches the "processes as needed" shape of §1.1). Measured, the same day: one engine over a third of ±50 kHz (op5M_U17k, 7 tiles; op2M_U17k, 19) costs 75 and 192 ns per output sample, so three of them are 225 and 576 against the single engine's 213 and 520 — 6% and 11% for the slice, the forward FFT repeated per slice being worth about one tile. Superseded (2026-09-03): the split is a roll per thread inside the engine, on persistent workers — see the bullet below §2.3 and §8.2. The slice was the shape to take while the only parallel-for created its workers per call at ~15 µs each (burst-bank.md §10.4) against a 205–512 µs epoch — 25–60% of the work for eight workers. A persistent pool pays that once.
  • Doppler pre-compensation is worth 6–7× on the searcher — 0.36 and 0.30 of a core over ±5 kHz — and nothing on anyone else. With it the searcher fits on one core with room; without it the partition above is mandatory.
  • One tracking receiver is 0.44 of a core, and receivers add. The pool of twelve is 5.3 cores at the operating point and 12 at the floor — the largest single line in the budget. Run as concurrent processes on the 8-core box, the warm row went from 43 ns alone to 46 with four running and 46–55 with eight (the top of that spread is the core the operating system was also using), so cores add nearly linearly for the receivers and the memory system is not the limit at this scale.
  • The population, one process, ±50 kHz, 5 Mcps: about 7.6 cores at the operating point (0.18 + 2.14 + 12 × 0.44), 17.5 at the floor; with pre-compensation 5.8 and 13.4. At the 2× margin §6.4 asks for, that is 15 and 35 cores without pre-compensation, 12 and 27 with. The server "will have a lot"; this is what a lot means.
  • Correction: the engine's tile rule gives 21 tiles at 5 Mcps over ±50 kHz, not the 23 this page derived from burst-bank.md's channel formula; the table in §6.1 now carries the engine's number. Nothing else moved.

Not measured here: the searcher with the peak list (it does not exist; the pick is one pass over the surface and will not move the ~10 ns per tile), a replica subtraction (no replica output yet), and the whole population as one run with its detection count beside the rate — that needs the orchestrator, and is what step 8 still owes.

12.2 What was measured (2026-09-02) — step 1, the floor

native/validation/acq_emitter_floor.c (make validate-c; its --check is in the C suite): one emitter rendered by the shipped continuous-DSSS synth (wfm_synth, the generator wfmgen uses) on the engine's own single-look surface at the operating point, ±50 kHz, Gold-1023 (CCSDS

365), read back from mag_buf after the dwell and binned outside the

one-tile × one-chip exclusion zone. Everything in dB below the emitter's peak:

emitter same tile, other lags worst cell, any tile worst cell at another code phase CFAR reference
tile-centred, no data transition −23.9 (the Gold bound, exactly) −21.0 (far tiles) −21.0 −32.9
centred, a transition in the epoch −18.7 0.0 — an equal twin two tiles away; the reported tile is one off −16.0 −28.8
half a tile off centre, no data −18.2 −9.5 (two tiles away) −16.1 −28.8
half a tile off, a transition −14.6 0.0 — twins two and three-plus tiles away −12.8 (5 Mcps), −11.9 (2 Mcps) −25.2

The two chip rates agree to 0.1 dB except in the last row, where the lower rate's narrower tiles spread the split emitter further. With noise, one strong emitter moves the CFAR reference by 0.18 dB at 55 dB-Hz and by nothing measurable at 45 and 40.

Four things this settles:

  • The design number is −13 dB, not −24. The Gold bound holds exactly where it applies — full period, zero Doppler, no data — and that is the spot check. But the searcher looks at every epoch, an emitter's data puts a transition in 55% of them at 1.8 epochs per symbol, and it sits anywhere in its tile; in those cases the worst cell at another code phase is 16 dB down, and with both at once 12 to 13 dB. So a second emitter more than about 13 dB weaker than the strongest, less the detection margin, is under the strong one's floor and is the cancellation branch's (§9); §6.3's fork is at −13 dB.
  • One emitter can make more than one peak, and tile distance does not bound it. A transition in the epoch splits an emitter into equal twins, two tiles apart when centred and three or more when it is also off centre; a half-tile offset alone puts a −9.5 dB sidelobe two tiles away. Every one of them is at the emitter's own code phase. The peak list therefore needs a rule beside the zone that keys on code phase, not tile distance: a peak within one chip of an already-listed peak's code phase is a candidate twin. Two real emitters can share a code phase at different Dopplers, so the twin is not dropped on one epoch — it is held, and the next epochs decide: a twin moves with the transition's position and vanishes in the emitter's data-free window, a real emitter stays put. That is a two-epoch rule, and it belongs in §7.1.
  • The reference does not hide the weak emitter; the sidelobes do. A strong emitter leaves the CFAR reference where the noise put it, so a weak emitter's gate is unchanged; what stops it being a peak is the strong one's cells standing over it. That is why removing the strong emitter (cancellation) is the only fix on that branch, as §6.3 argued.
  • Where the peak list is taken matters. In an emitter's own data-free window the other emitters are still carrying data, so −13 to −16 dB is the operating floor everywhere; the data-free window buys the emitter its own clean, single peak, not a clean surface.

12.3 What was measured (2026-09-02) — step 6, the release

native/validation/async_dsss_receiver_release.c (make validate-c; its --check is in the C suite): the receiver as built, tracking one emitter from the shipped continuous-DSSS synth at the operating point (5 Mcps, 2700 sym/s asynchronous BPSK, PRBS data) with the shipped awgn at two C/N0s, fed one epoch (0.2 ms) at a time with both lock flags read after every block. Once tracking with symbol lock held for 200 blocks, one event per trial; 30 trials per event, 10 of 3 s for the on-time.

C/N0 (Es/N0) event code lock off symbol lock off both off back by 1.5 s (code / symbol)
45 dB-Hz (10.7 dB) switch-off 1.8 ms 25 ms (max 38) 25 ms, stays off 0 / 0 of 30
10 dB fade, 1 s 1.8 ms 44 ms (max 83) for 0.99 s 30 / 29
20 dB fade, 1 s 1.8 ms 26 ms for 1.47 s 29 / 27
π/2 phase step 164 ms median, 783 max held in 29 of 30 never 29 / 30
nothing, 30 s off 0.6% of blocks, 79 dips never never
40 dB-Hz (5.7 dB) switch-off already off 20 ms (max 34) 20 ms, stays off 0 / 0
10 dB fade, 1 s already off 22 ms for 1.09 s 0 / 23
20 dB fade, 1 s already off 20 ms for 1.48 s 2 / 17
π/2 phase step already off 9 ms, held in 17 of 30 ≤ 52 ms 2 / 30
nothing, 27 s off 96% of blocks, 466 dips 0.5% of blocks, 4 episodes 0.5%, longest run 36 ms

What it settles, and what it overturned:

  • Code lock is not a presence flag. At Es/N0 10.7 dB it dips for a block or two three times a second on a healthy signal; at 5.7 dB it is off 96% of the time while the receiver is tracking and decoding. It drops on a 10 dB fade in the same 2 ms as on a switch-off, and a phase step that symbol lock rides takes it down 160 ms later. It is the Dll's per-decision CFAR flag on prompt power, and it does exactly that. The page's original rule — release on code lock — would have released on every fade and, near the floor, continuously.
  • Symbol lock is the stable one. Never a dip in 30 s at 10.7 dB; four episodes in 27 s at 5.7 dB, the longest 36 ms. It drops 20–45 ms after a switch-off or the start of a fade, and stays down for the fade's length.
  • The rule is both flags down for longer than the fade. A switch-off holds both down indefinitely; a 1 s fade holds both down for 1.0–1.5 s and then brings them back at 10.7 dB (less reliably at 5.7 dB, where symbol lock returned in 17–23 of 30 within the watch); a healthy signal's longest both-down run is 36–52 ms. Two seconds separates those with a margin of forty on the healthy side and two on the fade side, and costs under 1% of the shortest on-time. The confirm interval is a time, and the fade sets it — not a verify count.
  • The replica's gate is symbol lock. A gate on code lock would drop the replica, and raise the searcher's floor, three times a second.
  • A recovered receiver is the same assignment. After a fade both flags return on the same receiver with the same code phase — the emitter never restarted — so a release that fires during a fade would hand a fresh receiver an emitter one is already tracking. That is the false release the interval is sized against.

After the fix (§3.7, §12.4) — the same sweep, the receiver's detector sized and symbol-aided:

C/N0 (Es/N0) event code lock off symbol lock off both off back by 1.5 s (code / symbol)
45 dB-Hz (10.7 dB) switch-off 3.5 ms (max 4.3) 25 ms 25 ms, stays off 0 / 0 of 30
10 dB fade, 1 s 3.7 ms 44 ms for 0.56 s 30 / 29
20 dB fade, 1 s 3.5 ms 26 ms for 1.12 s 29 / 27
π/2 phase step held, 30 of 30 held in 29 of 30 never 30 / 30
nothing, 30 s never never never
40 dB-Hz (5.7 dB) switch-off 11.5 ms (max 15) 20 ms 20 ms, stays off 0 / 0
10 dB fade, 1 s 12 ms 22 ms for 0.99 s 30 / 23
20 dB fade, 1 s 11.5 ms 20 ms for 1.48 s 24 / 17
π/2 phase step held, 30 of 30 9 ms, held in 17 of 30 never 30 / 30
nothing, 27 s never 0.5% of blocks, 4 episodes never

Code lock is the presence flag the page first wanted, once its looks are sized and symbol-aligned: off within 4 ms of a switch-off at 10.7 dB and 12 ms at the floor, held through a phase step in every trial, back after every fade at 10.7 dB and after 24 of 30 deep fades at the floor, and not one dip in 57 s of on-time across both C/N0s. Symbol lock is now the one that moves on a carrier disturbance. The rule of §10 keeps its shape — both flags down for longer than the fade — because a fade still takes both down for its duration; what the fix buys is a clock that starts within milliseconds of a real loss and never starts on a healthy signal.

Not measured yet: the false-release rate over a whole on-time — the 15-minute maximum of §6.1 — rather than half a minute (the both-down rate at 5.7 dB is 0.5% of blocks in runs of tens of milliseconds; whether a run ever reaches seconds is what fifteen minutes would say), and any of this on the hand-off-mode receiver, which does not exist.

12.4 What was measured (2026-09-02) — the DLL's telemetry, and the aid

The receiver's DLL alone (bn 0.002, segments 4), fed the shipped synth's continuous DSSS at the operating point with a Telemetry context attached (receiver_lock_demo.py's pattern), 2 s per run; code.lock against its threshold, code.locked, the discriminator and the tracked rate, for three detectors on the same signal (src/doppler/dsss/tests/characterization/dll_lock/):

Es/N0 detector per-look Es/N0 looks R vs eta miss per decision off drops per s code rate
10.7 dB 20 partials (default) 2.1 dB 20 9.5 vs 8.7 5.1% 1.4% 4.5 1.000000
partials sized (det_n_noncoh) 2.1 dB 25 above 1.7% 0.4% 1.0 1.000000
symbol-aided, sized 10.7 dB 3 well above 0.0% 0.1% 0 1.000000
5.7 dB 20 partials (default) −2.9 dB 20 7.5 vs 8.7 86% 97% 11 1.000000
partials sized −2.9 dB 161 22 vs 20 2.2% 1.6% 0.5 1.000000
symbol-aided, sized 5.7 dB 10 well above 0.4% 0.2% 0 1.000000

What it settles:

  • The loop was never the problem. In every run the tracked code rate is 1.000000 within 3 ppm and the discriminator is zero-mean with no drift, including the run where the flag read "unlocked" 97% of the time. §12.3's chatter and 96% were the detector's default integration — 20 quarter-epoch partials, 1 ms, sized for nothing — sitting under its own threshold at the floor and grazing it at 10.7 dB.
  • Sizing alone fixes the 96%; the aid fixes the margin. Sized partials need 161 looks at the floor and still miss 2% of decisions; the symbol-aided look needs 10 and misses 0.4%, with the statistic well clear of its threshold at both C/N0s and no drop in 2 s.
  • The hysteresis is now a budget. At the aided miss rate, det_verify_count(0.01, 1e-6) gives three consecutive misses to drop, which the receiver sets; at two, the floor's 0.4% would have produced a false drop about every four minutes of decisions.
  • What this does to the release rule (§10). Code lock is a usable presence flag again — §12.3's post-fix sweep shows it off within 4–12 ms of a switch-off, held through a phase step in every trial, and not dipping once in 57 s of on-time. The both-flags-down rule stands because a fade still takes any CFAR flag down for its duration; what changes is that the "both down" clock now starts within milliseconds of a real loss and never on a healthy signal.

12.5 What was measured (2026-09-02) — the discriminator on the aided window

native/validation/dll_aid_jitter.c (make validate-c; its --check is in the C suite): the receiver's DLL alone (bn 0.002, half-chip spacing, four partials per epoch) fed the shipped synth's continuous DSSS at the operating point with the shipped awgn, one epoch per call, its tracked code phase against the generator's after every block; the per-epoch look-back and the symbol-aided window on the same stream. One seed per cell, 12 000 epochs measured after 3 000 settling, so a ratio is good to about 8%.

C/N0 (Es/N0) jitter, per-epoch jitter, aided ratio pull-in from 0.25 / 0.5 / 0.75 chip, per-epoch aided
50 (15.7 dB) 0.0078 chips 0.0062 0.79
45 (10.7 dB) 0.0132 0.0136 1.03 179 / 234 / 251 ms, 10 of 10 each 145 / 202 / 222
42 (7.7 dB) 0.0173 0.0225 1.30
40 (5.7 dB) 0.0208 0.0289 1.39 249 / 298 / 328 ms, 10 of 10 each 204 / 238 / 258
38 (3.7 dB) 0.0341 0.0371 1.09
36 (1.7 dB) 0.0421 0.0524 1.25
34 (−0.3 dB) 0.0606 0.0650 1.07

Neither loop lost the code in any cell, and both read a code rate of 1.000000. Both discriminators zero at the same code phase (−0.004 chips, clean, both).

What it settles:

  • Above 45 dB-Hz the aided loop is tighter, and the reason is the look-back. On a data-free stream the per-epoch loop reads 0.0060 chips at 50 dB-Hz; with data, 0.0088. Its handling of the transitions — a window borrowed from the previous epoch at the previous phase, and a transition in the first partial that no candidate can exclude — is what sets its jitter there. The aided window pays nothing for the data.
  • At the floor the noise sets the jitter, and the aided window's unused partials cost. The window is six of the 7.24 partials a symbol spans; the transition partial and the slack are left out. With the hypothesis pinned at the truth the aided loop reads 0.025 chips at 40 dB-Hz, with its own argmax 0.027–0.030, the per-epoch loop 0.022. A power EMA four times longer, or a window one partial shorter, moves it by less than the trial spread; a hypothesis a partial off reads 0.04–0.06. So the loss is the window, not its choice.
  • Pull-in is 15–20% faster in every cell, and the loop gain is the same: under a 100 ppm code-rate step the two modes' integrators agree to under 1% of the step mid-transient (test_dll_core.c §6c), where a filter left at its per-epoch gains reads 1.8× slower.
  • What it means for the receiver: hundredths of a chip either way. A 0.03-chip RMS code error is under 0.1 dB of despreading loss. The receiver keeps the one declaration — the symbol period aids the looks and the loop — and the number to beat, should this be revisited, is 0.022 chips at 40 dB-Hz.

12.6 What was measured (2026-09-02) — steps 2–4, the peak list

native/validation/acq_peak_list.c (make validate-c; its --check is in the C suite): the continuous engine at 5 Mcps, ±50 kHz (21 tiles), sized by its own physics at a design C/N0 (15 looks per dwell at 45 dB-Hz), one shipped synth per emitter with PRBS data at 2700 sym/s, the shipped awgn at the strong emitter's C/N0, max_peaks = 4; 200 scenes per cell, two dwells each and the second scored, since the two-epoch rule lists a same-code-phase emitter from the second dwell on.

Step 2, separability (two equal emitters at 45 dB-Hz; P(both listed)):

Δτ \ Δf 0.5 tile 1 tile 2 tiles 4 tiles
0.5 chip 0.00 0.00 1.00 1.00
1 chip 0.00 0.07 1.00 1.00
2 chips 0.95 1.00 1.00 1.00
4 chips 0.94 1.00 1.00 1.00

Inside one tile and one chip the two are one peak, as §7.1 says the zone makes them; outside it both are listed in every dwell, including the same-code-phase pairs the twin rule holds for one dwell. Half a tile off costs 5%, the straddle. Every listed peak was on its emitter's tile and within a chip of its code phase; no false peak in 3 200 dwells.

Step 3, the knee (strong emitter fixed, weak stepped down 4 tiles and 100 chips away, the engine sized at the weak emitter's C/N0 so the floor decides rather than the sizing; the weak emitter alone as the control):

strong spread weak C/N0 looks P(weak, with strong) P(weak alone)
55 dB-Hz 3–15 52–40 2–88 0.98–1.00 1.00
18 37 256 1.00 1.00
21 34 256 0.12 0.66
24 31 256 0.00 0.03
45 dB-Hz 0–9 45–36 15–256 1.00 0.99–1.00
12 33 256 0.24 0.27
15 30 256 0.01 0.00

The knee is where the two curves part: at a 55 dB-Hz strong emitter, between 18 and 21 dB of spread — deeper than §12.2's −13 to −16 dB single-look floor, because the non-coherent sum favours the weak emitter's consistent peak over the strong one's data-dependent sidelobes. At 45 dB-Hz the weak emitter is noise-limited before the floor reaches it: the two curves fall together from 12 dB, and no floor-limited miss is seen down to 33 dB-Hz. The fork of §6.3 stays at −13 dB as the single-look worst case; a receiver that integrates buys a few dB past it.

Step 4, pfa under the list (pure noise, engine sized at 45 dB-Hz, configured pfa 1e-2, 20 000 dwells): reported dwells 0.0091 / 0.0102 / 0.0085 at max_peaks 1 / 4 / 8, one peak per reported dwell — the list does not change the false-alarm rate, and on the same noise the same dwells report at 1 and at 4 (the --check pins that). With one strong emitter present at 45 dB-Hz, false peaks at other code phases run at 0.0005 per dwell, the configured 1e-3 pfa or under; the emitter is listed in 2 000 of 2 000 dwells with no twin listed.

What it settles, and the one thing it raised:

  • The zone is the resolution and costs nothing outside it; the twin rule costs one dwell for a real same-code-phase emitter and nothing else.
  • The rule's table must carry every pick of the previous dwell, listed or held: two equal emitters at one code phase swap places as the strongest, and holding only the held ones listed both in 30% of dwells (measured before the fix; 100% after).
  • Under long non-coherent integration a strong emitter's same-code-phase sidelobes persist and pass the rule. At 256 looks (52 ms) a 55 dB-Hz emitter lists 1.8 twins per dwell at its own code phase on other tiles: the sum averages the data-free window away, so "still there next dwell" no longer separates a twin from a second emitter. A power rule would — a same-code-phase peak more than the floor below its parent is the parent's, and an emitter that far under is the cancellation branch's anyway (§6.3) — and it is open (#1190). At the operating point's 15 looks no twin was listed in 2 000 dwells.

12.7 What was measured (2026-09-05) — step 11, the block-coherent searcher

native/validation/acq_block_coherent.c (make validate-c; its --check is in the C suite): the continuous engine with the depth its window buys — 813 whole code-only epochs at 5 Mcps and 324 at 2 (§2.1), the rate bound of 500 Hz/s deciding: D = 154 and 61, a 31.7 Hz row — over ±50 kHz (21 and 53 tiles), one emitter from the shipped synth at tile 5 plus a quarter row, sized for one look, the surface read back through §2.4's tap in the gate's own units. Everything in dB below the emitter's peak, cells outside the exclusion zone, by Doppler-row distance; other is the worst cell at another code phase.

The floor and the straddles (clean, 5 Mcps; 2 Mcps agrees to 1 dB):

block peak/gate conc row 1 row 2 row 3+ other
aligned — pure code 1874 / 5.4 0.92 −14.0 −16.9 −20.8 −21.0
one transition mid-block 1121 / 5.4 0.49 −5.8 −0.0 −11.4 −20.9
PRBS data, the whole block 106 / 5.4 0.04 −1.1 −2.9 −0.0 −20.5
the window's edge mid-block 420 / 5.4 0.43 −10.1 −8.2 −8.2 −20.9

Five things this settles:

  • The aligned block gives the transition-free floor, −21 dB at another code phase — the number §12 step 11 expected, and 8 dB below the −13 the single-look surface has under data (§12.2). Inside the emitter's own column the slow-time transform's rectangular window puts its first sidelobe at −14 dB one row out; a taper would trade that for a wider main lobe, and nothing here needs it.
  • A block that straddles a data transition splits the emitter into twins. One transition mid-block halves the peak and puts an equal copy two rows away at the same code phase; the reported row is one off the truth (32 Hz). That is §12.2's twin rule again at the row scale — at the emitter's own code phase, so the two-epoch rule and the concentration see it, and the floor at other code phases is untouched.
  • A block inside the data section is a weak, smeared copy, still at its code phase. PRBS data through the whole block spreads the emitter over every row of its column at −1 to −3 dB of the peak, which itself is 25 dB below the aligned block's (about 10·log10 D and the data's spectrum) — a copy a real C/N0 leaves under the floor, and the assigned table excludes in any case (§2.3).
  • The window's edge mid-block — the maintainer's case — is half of each. Half pure code, half data: the peak is 13 dB down, the column spread at −8 to −10 dB, and the peak is exactly at the emitter's code phase (47 for that block's chip offset). The edge falls at no chip phase (§5.4), so one block per window sees this at each end.
  • The concentration is the discriminator. 0.92 aligned, 0.49 for the twins, 0.43 at the edge, 0.04 under data: a second emitter is a second column and leaves its neighbour's column alone, so a low conc at one code phase is one emitter's splatter and never two emitters. The engine emits it as acq.conc (§2.4), for the strongest pick only; the pool does not read it — the list carries no per-peak concentration, and the pool keys its zone on the code axis alone (§8.2, §12.14).

Pfa per block (pure noise, D = 16, 21 tiles, 300 blocks): configured 0.10, realized 64 of 300 = 0.21; configured 0.20 over 100 blocks, 37. That is 1 − (1 − pfa)^2 to within a sigma both times: the slow-time axis is interpolated twofold and the maximum runs over the interpolated surface while the threshold's N counts native cells — doppler#1064, the open finding on the burst engine, which the continuous engine inherits with its slow-time axis. The CFAR counts every row of every tile (the cell count is pinned in test_acq_core.c); the factor is #1064's, and the --check pins the realized rate against 1 − (1 − pfa)^interp so the finding cannot be mistaken for a regression, or a fix for one.

Sensitivity (5 Mcps, one look, 20 trials, realized Pd and the mean peak-to-gate ratio):

D 30 dB-Hz 34 dB-Hz 38 dB-Hz 42 dB-Hz
1 0.00 (0.8) 0.00 (0.8) 0.00 (0.8) 0.00 (0.8)
16 0.00 (0.9) 0.00 (0.9) 0.35 (1.0) 1.00 (1.5)
154 0.90 (1.2) 1.00 (1.7) 1.00 (2.7) 1.00 (4.3)

A single epoch over ±50 kHz detects nothing to 42 dB-Hz with one look; D = 16 turns on between 38 and 42; D = 154 is on at 30. From 16 to 154 the knee moves about 9 dB for 9.8 dB of depth — the depth buys what it says, less the straddle. What the operating point buys against the epoch-by-epoch searcher of §12.1 is therefore not one number but the whole gap between "never" and 30 dB-Hz at one look; and sized at the 45 dB-Hz of §12.6, the engine's own sizer buys 15 non-coherent looks at D = 1 and one at D = 154 (measured on the same code and span). Its cost per epoch, and how the fan across threads takes it, is §12 step 13.

12.8 What was measured (2026-09-05) — step 13, the searcher's cost with D, and the fan

bench_acq_core (make bench), on a 20-core Ryzen AI 9 465, minimum of 15 pushes; the searcher's cost in ns per output sample and as a multiple of real time, the roll per thread (§2.3) at 1, 2, 4 and 8 threads:

row (±50 kHz) 1 thread 2 4 8
5 Mcps, 21 tiles, D = 1 174 (1.75×) 98 (0.98×) 58 (0.58×) 50–60
5 Mcps, 21 tiles, D = 154 624 (6.2×) 398 (4.0×) 288 (2.9×) 258 (2.6×)
5 Mcps, ±5 kHz, 3 tiles, D = 154 95 (0.95×) 53 (0.53×)
2 Mcps, 53 tiles, D = 61 2293 (9.2×) 892 (3.6×)

Four things this settles:

  • The fan works, and it is Amdahl's. At D = 1 four threads buy 3.0× — about 88% of the work is in the tiles — and eight buy little more; the searcher over ±50 kHz that was 1.75× real time on one core is 0.58 on four. The pool's hand-off per push is not visible at this granularity (an epoch is 0.2 ms; the D = 1 rows are 254-epoch pushes).
  • The depth costs 3.6× per sample serially, and the transforms are not why. The design estimated the slow-time transform "of the order of the epoch transform it sits behind" — it is, but the per-cell passes around it are not: the magnitude of every cell, the CFAR reference over the whole surface, the mask copy and the list's scans grow with the surface, 13.2 M cells per block at the operating point against 43 k per epoch at D = 1, and they run after the fan, serially. That is why four threads buy only 2.2× at D = 154 (72% fanned) — and inside the fanned block-end loop the scatter folds a row index per cell and the column gather strides by code_bins. Both are named in #1243 with the fix: the magnitude, the reference and the list per tile with a serial merge; a per-tile row table; a chunked gather.
  • Pre-compensation is worth what §12.1 said. Over ±5 kHz the D = 154 searcher is 0.95× real time on one thread and 0.53 on four.
  • The low chip rate is the worst case, by more than before. 53 tiles of D = 61 rows is the same 13 M cells per block for 61 epochs instead of 154, so the per-cell passes cost 2.5× more per sample: 9.2× real time on one thread, 3.6× on four. The fix above is what brings it in.

What the budget said (§6.4: 100 ns per output sample per core, half as the margin): at the operating point the block searcher on four threads was 288 ns of wall per sample, about 1150 core-ns — a quarter of a 48-core server's budget, beside twelve receivers at 44 each (§12.1). It fit; it was not comfortable, and #1243 was the next thing to attack.

#1243, measured the same day, same box, same rows. The per-cell passes now run per tile on the pool and merge serially in tile order; the block-end scatter reads a per-tile row table instead of folding a row index per cell; the column gather goes 32 columns at a time so a cache line of the block serves eight columns instead of one. The surface and the hits are byte-identical at any thread count, as before, with a second emitter in the comparison so the list's second scan is part of it.

row (±50 kHz) 1 thread 2 4 8
5 Mcps, 21 tiles, D = 1 174 (1.74×) 97 (0.97×) 59 (0.59×) 55–62
5 Mcps, 21 tiles, D = 154 523 (5.2×) 283 (2.8×) 164 (1.6×) 125–138 (1.3×)
5 Mcps, ±5 kHz, 3 tiles, D = 154 80 (0.80×) 35 (0.35×)
2 Mcps, 53 tiles, D = 61 2010 (8.1×) 580 (2.3×)
  • The fan is now 92% of the work at D = 154 (four threads buy 3.2×, eight 4.2×), up from 72%; the serial cost fell 16% (the row table and the chunked gather), the four-thread cost 43%. D = 1 is unchanged to the nanosecond: its passes were 43 k cells per epoch, never the cost.
  • What is left is the transforms. At the operating point the block is 21 tiles × 2046 columns of a 308-point slow-time transform plus 154 × 21 inverse transforms of 2046 points; the passes around them are now a fraction of that on any thread count. 308 = 4·7·11 is not a smooth length; bounding D to a 5-smooth number below the window's is the one lever left in the engine, unmeasured.
  • The budget: four threads at 164 ns of wall per sample is about 656 core-ns — 14% of a 48-core server's budget at the operating point, beside twelve receivers at 44 each; over ±5 kHz, 0.35× real time on four threads. Comfortable. Bit-identity across thread counts is pinned in test_acq_core.c and the C suite runs under TSan.

12.9 What was measured (2026-09-06) — step 12, the tracker through the window

native/validation/tracker_through_window.c (make validate-c; its --check is in the C suite): the hand-off receiver, seeded by the shipped searcher's first hit on the same received blocks (the pool's own path, §8.2), tracking one emitter from the shipped synth with its own window (450 code-only symbols of every 4950 on the data clock: a 167 ms window in a 1.83 s frame) at the operating point and the shipped awgn, fed one epoch (0.2 ms) at a time, both lock flags read after every block, the release clock at the design's 2 s. Once tracking with symbol lock held for 200 blocks, ten windows per trial, three trials per C/N0; per window the fraction of blocks with each flag off, the longest both-off run, whether lost fired, and the pull-in after the data resumes. Three conditions: no Doppler, and SPEC's two worst cases through the shipped doppler_channel, apart, since they do not coincide on a pass — the offset (20 ppm of a 2.5 GHz carrier, 50 kHz, the chip clock dilated with it) and the rate (500 Hz/s from zero).

condition, C/N0 (Es/N0) settled window blocks code lock off symbol lock off both off release pull-in after the window
static, 45 dB-Hz (10.7) 3 of 3, 68 ms 24 435 0 0 0 never none needed
static, 40 dB-Hz (5.7) 3 of 3, 110–163 ms 24 435 0 1.5% (two windows) 0 never 6 ms
offset, 45 dB-Hz 3 of 3, 76–81 ms 24 435 0 0 0 never none needed
offset, 40 dB-Hz 3 of 3, 0.30–2.8 s 24 436 0 0 0 never none needed
rate, 45 dB-Hz 3 of 3, 68–70 ms 24 435 0 0 0 never none needed
rate, 40 dB-Hz 3 of 3, 145–172 ms 24 435 0 0 0 never none needed

(Measured with the hand-over fix of #1249 below and the object's default refine length. The first run took the offset and the rate together and, before the fix, settled 2 of 3 at 45 dB-Hz and 0 of 3 at 40.)

What it settles:

  • The window costs the tracker nothing. Code lock never drops in a window, at either C/N0, with or without the ramp: the symbol-aided detector's looks are as good on a constant symbol as on data. Symbol lock holds too — the phase-lock statistic cos(2φ) reads a constant symbol as locked, and the symbol clock coasts 167 ms without a transition and picks the data up with no pull-in at all (the one dip, at the floor, is the flag's own chatter: 3.6% of that frame's data blocks were off too, §12.3's 0.5% under a different seed). The release never fires. The expectation that the symbol flag "may drop and recover within its dwell" was pessimistic; nothing here needs a rule loosened or a detector fixed.
  • The pool must seed from the searcher, not from the truth. The harness first seeded the hand-off receiver with the stimulus's own chip phase; through the channel the received code is five chips late (the resampler's delay), the code loop sat outside its pull-in and nothing downstream locked. acq_build_handoff() of the searcher's hit — measured on the received stream — is the seed, and with it the same trials lock in 70–280 ms.
  • What the ramp found was a hand-over defect, not a window one (#1249). From the searcher's seed at 20 ppm with the 500 Hz/s ramp on top (the first run took SPEC's two worst cases together, which a pass never does) the chain settled in 2 of 3 trials at 45 dB-Hz and 0 of 3 at 40, the searching flavor alike. The refine → track hand-over re-seeded the live chain with the seed's code phase, rounded to whole code periods — zero net advance only on an undilated clock. At 20 ppm the code runs 100 chips/s ahead: 1.2 chips over the 12 ms refine at 45 dB-Hz, 5 over the 53 ms refine at 40, and a Dll seeded 1.5 chips off never pulls in; the trial that lost at 45 dB-Hz was 1.45 chips off, the one that won 1.13. (The refine-stage Dll's own tracked phase is not the answer either: at the floor it wanders 13 chips over the same 53 ms.) The hand-over now advances the seed's phase by the refined Doppler's dilation over the refine's whole periods: 45 dB-Hz under the ramp settles 10 of 10 seeds in 70 ms, and test_async_dsss_receiver_core pins it at SPEC's 20 ppm with the floor's refine length, where the old hand-over was 5 chips off.
  • What remains at the floor is the offset case, and it is the estimate, not the window (#1252). SPEC's two worst cases do not coincide — the largest Doppler is at the horizon where the rate is nil, the largest rate at closest approach where the Doppler is nil — so they are measured apart. The rate (500 Hz/s from zero) is no problem: 10 of 10 seeds settle at both C/N0s, 113–432 ms at the floor. The offset (50 kHz) settles 10 of 10 in 80 ms at 45 dB-Hz but 9 of 10 at 40, in 0.3–3.6 s, several past the 2 s release clock — the code loop locked from the first block, the carrier slow to follow. The refine lands the Doppler 200–460 Hz low on every seed with the floor's dwell (a bias, worse than the shortest dwell's ±20 Hz), and loop 1 — bn 0.04, 195 Hz at the code-period cadence, a measured pull-in bound of 60 Hz — then acquires it slowly or not at all, at 17 dB of loop SNR (15.5 after the squaring loss) where the rule wants 20. Narrowing the loop toward the rule is measured to cost the floor entirely (under the combined stress: bn 0.02, 10 of 10 at 45 dB-Hz and 0 of 10 at 40; 0.01, 6 and 0), so the two rules conflict as built and the way out is the estimate the loop starts from. (This paragraph called the floor's dwell "18 blocks"; it is 7 — §12.10 measured the margin → dwell table. And §12.10 found the bias was the harness's own configuration, not the estimator's.)

12.10 What was measured (2026-09-06) — the refine's Doppler on its own

Harness: native/validation/refine_bias.c (validate_refine_bias; --check is a ctest entry). A static capture from the shipped C stimulus (dp_dsss_capture: Gold-1023 at 5 Mcps, 2700 sym/s async BPSK, a fixed carrier offset of ±1500 Hz, AWGN from the C/N0), the hand-off receiver seeded with the stimulus's chip phase and the truth plus a chosen error, fed one epoch at a time until get_tracking() first reads 1, and get_doppler_hz() read there. The error of that reading against the truth, over 10 noise seeds per point: the mean is the bias, the standard deviation the noise. Three axes — the seed's error (0, ±500, ±1100, ±2000 Hz) at the floor's dwell, the dwell through the design margin at a fixed +1100 Hz, and the despread stream the refine estimates on: refine_max_error_db sets the collection Dll's dumps per epoch through dll_lookback_segments(), and the shipped default (0.5 dB, eleven dumps, 53.8 kHz) was compared with 100 dB (one dump, the 4.9 kHz epoch rate), which objects/async_dsss_receiver.toml records as retired because a stream below the 2700-baud data lobe's own width aliases any residual.

The finding before the measurement: every C harness and test of this receiver — tracker_through_window.c, the hand-over test, all fourteen create() calls in test_async_dsss_receiver_core.c — passed the retired 100 dB, positionally, and had since the default moved. The measurement behind #1252 was taken on that stream. Both are now on 0.5 dB.

Bias vs the seed's error, at 45 dB-Hz, margin 19 dB (7 blocks, 42 ms), truth +1500 Hz (mean ± sd over 10 seeds; the −1500 Hz rows are alike):

seed error shipped (11 dumps) retired (1 dump)
0 −11 ± 77 Hz −17 ± 45 Hz
+500 −16 ± 77 +140 ± 47
−500 −10 ± 76 −150 ± 61
+1100 −26 ± 77 +351 ± 115
−1100 +14 ± 90 −367 ± 125
+2000 −53 ± 79 +752 ± 91
−2000 +26 ± 70 −787 ± 99

Bias vs dwell, at +1100 Hz (the margin → dwell table is the same on both streams; det_n_noncoh() sizes it from the derated C/N0 alone):

margin dwell shipped retired
14 dB 2 blocks −31 ± 210 Hz +341 ± 184 Hz
17 4 −17 ± 117 +357 ± 189
19 7 −26 ± 77 +351 ± 115
22 18 −21 ± 36 +329 ± 87
25 55 −20 ± 26 +368 ± 95
  • On the retired stream the bias is a third of the seed's error, in the seed's direction, at every dwell. ±0.35 × the error, the same with the truth at −1500 Hz, the same at 2 blocks and at 55: the refine removes two thirds of the residual and hands over the rest. That is the aliasing the manifest describes, not the template's edge and not the accumulation; and it is #1252's 200–460 Hz at the searcher's ~1.1 kHz seed. A two-pass refine on this stream would converge geometrically; the shipped stream does not need it.
  • On the shipped stream the estimate is unbiased — within ±60 Hz at every seed error out to ±2000 Hz at the floor's dwell, inside loop 1's 60 Hz pull-in — and its noise falls with the dwell as an average should (77 Hz at 7 blocks, 36 at 18, 26 at 55); at 40 dB-Hz the 7-block point is −74 ± 152 Hz and 18 blocks give −59 ± 71. A residual −20 Hz survives long dwells; nothing downstream notices.
  • The floor's dwell is 7 blocks, not 18. Margin 19 dB at 45 dB-Hz (the hand-over test's stand-in for 14 at 40) sizes 7; 18 needs 22 dB. The test's comment, #1252 and §12.9 all said 18.
  • What the shipped stream did at the floor was the searcher's seed, not the refine (#1254, §12.11). With the window harness on 0.5 dB, the offset case at 45 dB-Hz settled 10 of 10 in 76–81 ms, as before; at 40 dB-Hz it settled 2 of 10 at the shipped margin, 4 at 17 dB, 6 at 19 and 6 at 22 (122 blocks, 0.7 s) — every failure a give-up, the refine's detector not firing within the dwell and the hand-over the unrefined seed, 1.1 kHz off, which loop 1 can never acquire. The same seeds failed at every dwell, so it was not the averaging; the frame's window was not it either (disabled: 1 of 10). On the retired stream the detector always fired, because the aliasing folds the whole data lobe into the band — which is why #1252 saw slow locks and not give-ups.

12.11 What was measured (2026-09-06) — the searcher's seed under the dilated clock

Where the give-up came from. The refine's detector statistic at the floor's hand-over was noise-like on every failing seed (1.15–1.30 against a 1.69 gate, the peak at a random lag), while at 45 dB-Hz it was 8.3 at the right lag after two blocks. A static capture at 40 dB-Hz with an exact seed fires 10 of 10 at 7 blocks, so two variables separated the stimuli: the seed's chip phase and the dilation. The seed's phase alone, on the static capture (validate_refine_bias with the seed offset, 40 dB-Hz, the shipped stream): 0 and 0.25 chip off fire 10 of 10; 0.5 chip, 5 of 10; 0.75 chip and beyond, none — the refine Dll's pull-in is under half a chip, and from a chip off it sits where it was seeded (its own lock statistic reading 5–8 regardless). The searcher's seed's error through the channel, measured at 45 dB-Hz where the live chain converges to the truth: +0.05 chip on every seed; at 40 dB-Hz, on the one seed that eventually settled, +0.91 chip.

Why: the dwell's centroid. The continuous searcher decides a hit on a non-coherent sum over n_noncoh epochs — 15 at 45 dB-Hz (3.1 ms), 88 at 40 (18 ms) — and the code phase it reports is that sum's peak, the phase at the middle of the dwell. The hand-off applies it at the dwell's end. Under 20 ppm the code moves 100 chips/s, so the seed is late by the drift over half the dwell: 0.15 chip at 45 dB-Hz (inside the pull-in), 0.9 at the floor (past it, and exactly the +0.91 measured). The same class as #1249, one hand-over earlier; and it is why the retired 1-dump refine "worked" — its Dll wanders chips across the code and crosses the truth, and its aliasing folds the whole lobe into the band, so its detector fired on a seed a chip off and handed over a biased estimate instead of none.

The fix. acq_build_handoff() takes the RF carrier (0.0 = no coupling) and advances the hit's phase by doppler_hz_est / carrier_freq_hz × n_noncoh × coherent_bins × code_len / 2 chips — the drift over half the dwell — folded with dp_fmod_pos(). The searching receiver passes its own carrier_freq_hz; the hand-off flavor's holder passes the same carrier it gives the receiver. The formula and its sign are pinned in test_acq_core (positive Doppler, fast chip clock, advances). Measured after it, the window harness's offset case at 40 dB-Hz on the shipped stream and margin:

before after
seed error (chips) +0.91 +0.16, −0.30, +0.29, −0.06
settled, of 10 2 10, in 114–205 ms
45 dB-Hz, of 10 10, 76–81 ms 10, unchanged

test_async_dsss_receiver_core's hand-over test now runs both operating points — 45 dB-Hz with margin 19 and the 40 dB-Hz floor with the shipped 14 — and asks every seed to lock the code and decode; with the carrier zeroed at the receiver's call site the floor's seeds go red and the 45 dB-Hz ones survive, which is why the floor is in the test. The rate case and the static condition are unchanged.

What this left open. The block-coherent searcher (§12.7) had been measured with the Doppler rate but never through a dilated chip clock: at 20 ppm its D = 154 epochs (31 ms) coherent sum spans 3 chips of code drift — §12.12 measured it and gave the engine the code-rate hypothesis (#1256); the carrier now lives on the engine (set_carrier_freq_hz), one declaration for the hand-off's advance and the block's alignment. And doppler.dsss.handoff.dll_init_chip_from_acq, the Python lag → phase helper for a hand-built Acquisition → Dll chain, restates acq_build_handoff()'s fold and does not carry the advance — #1257.


12.12 What was measured (2026-09-06) — the block-coherent searcher under the dilated clock

Harness: the dilated section of native/validation/acq_block_coherent.c (--check on ctest). One aligned block of a clean emitter at SPEC's Doppler, 20 ppm of 2.5 GHz = 50 kHz, three ways: the synth's own carrier offset with the code standing still; the synth at baseband through the shipped doppler_channel, the chips dilated with it (100 chips/s at 5 Mcps); and the same, the engine told the carrier. Per depth D of 1, 16 and the window's 154: the peak against the gate, conc, the peak's width along the code axis, and the hand-off's chip phase raw and with the half-dwell advance. Then the depth's realized Pd, 20 trials per C/N0, noise from the shipped awgn after the channel.

D code peak/gate conc width drift/block
1 still 40 / 4.7 0.97 0.5 chip 0
1 dilated 34 / 4.7 0.98 1.0 0.02 chip
16 still 273 / 5.1 0.86 0.5 0
16 dilated 256 / 5.1 0.87 0.5 0.33
16 dilated, carrier told 262 / 5.1 0.86 0.5 0.33
154 still 2065 / 5.4 0.85 0.5 0
154 dilated 459 / 5.4 0.72 3.0 3.15
154 dilated, carrier told 1462 / 5.4 0.85 1.0 3.15
D = 154, realized Pd (mean peak/gate) 34 dB-Hz 38 dB-Hz 42 dB-Hz
still 1.00 (1.7) 1.00 (2.7) 1.00 (4.2)
dilated 0.00 (0.9) 0.65 (1.0) 1.00 (1.4)
dilated, carrier told 1.00 (1.5) 1.00 (2.5) 1.00 (3.9)
  • Told nothing, the depth is gone at 20 ppm. Across a D = 154 block the code drifts 3.15 chips; the coherent sum at any fixed lag sees the emitter for a third of the block, so the peak is 13 dB down and three chips wide, and at 34 dB-Hz — where §12.7's aligned floor detects every block — it detects none. D = 16 (a third of a chip) loses 0.6 dB; a single epoch nothing. The pool's searcher, as measured in §12.7, had this loss hidden in it.
  • The code-rate hypothesis is the tile's own frequency. A window tile at signed_r bins of fs/nx implies a chip clock dilated by f_tile / carrier, signed_r × fs / carrier samples of drift per epoch (0.041 at 50 kHz). acq_tile_epoch shifts each epoch's correlation along the code axis to the block's middle — a linear phase over the signed frequency index on the tile's product before its inverse transform, exact for a fractional shift, one complex multiply per bin — so the slow-time transform sums a standing peak. The sign was measured, not derived: the other one doubles the smear (6 chips, 247). No new search dimension: the hypothesis rides the tile.
  • Told the carrier, the block reads as a still one. 1462 against 2065 is 3.0 dB, of which 1.4 dB is the channel's own resampler (the D = 1 row shows it, and the peak's width of a chip instead of half), so the alignment leaves about 1.6 dB — the emitter sits a quarter row off its tile's centre, and a tile's hypothesis is one number for its ±2.4 kHz. conc is the still block's; Pd at 34 dB-Hz is 20 of 20 at 1.5× the gate against the still 1.7×.
  • The hand-off is the block's end within a tenth of a chip. With the epochs aligned to the block's middle the peak IS the middle (raw 385.00 against the D = 1 start of 383.50 plus 1.58), and the half-dwell advance of #1254 — coherent_bins was already in its formula — lands 386.57 against a truth of 386.65 at the block's end. Told nothing, the smeared peak's argmax wandered (385.50) and the advanced seed was 0.4 chip off, inside the refine's pull-in only by luck.
  • One declaration. The carrier moved from acq_build_handoff()'s argument (#1258, the previous PR) to the engine: acq_set_carrier_freq_hz(), a jm method with a read-back property, drives both the block's alignment and the hand-off's advance. Config, not running state: it is not in the blob, so a resumed engine wants it set again by its holder. The searching receiver sets it from its own carrier_freq_hz; the hand-off flavor's holder sets it on the searcher it seeds from, as the window harness does.

12.13 What was built (2026-09-06) — the pool, §8.2

native/src/async_dsss_pool/ (AsyncDsssPool, Python glue only): one searcher, n_slots hand-off receivers created idle, the assigned table, the event log by attachment, exactly the object of §8.2 — and nothing about the waveform or the population baked in: 28 create parameters, every default the operating point of §6.1, the searcher's and the receivers' own passed through untouched. The carrier is told to the searcher as well as the receivers (§12.11, §12.12), and the table's rows advance by the same dilation while a receiver still refines, so the exclusion zone is keyed on where the emitter IS. The composition serializes as a whole (its own counters and the table, then the searcher's blob and every receiver's).

What test_async_dsss_pool_core pins, on the shipped C stimulus (one capture per emitter, summed; the continuous engine at D = 1 over ±6 kHz; pfa 1e-3; a 0.3 s release interval): one emitter takes exactly one slot however many dwells hit it — the zone drops its own — with the seed at its row and within the searcher's half-chip cell; it tracks with code lock, the live Doppler converged within 100 Hz, the symbols readable by slot; off the air it is lost and released, and back on the air it is a new detection into a free slot; two emitters two rows apart hold one slot each, both locked, with nothing dropped; a one-slot pool holds one of them and counts the other dropped every dwell; every counted transition reaches the log, seeded → tracking → lost → released in order; across threads the assignments, the counts and every receiver's symbols are bit-identical; and a mid-stream split resumes bit for bit in a fresh pool, the envelope and a foreign slot count rejected.

Two things the build found:

  • The searcher's false alarms are part of the lifecycle. At pfa 1e-3 a noise peak seeds a free slot every few hundred milliseconds, the receiver refines to nothing, reports tracking on noise, and is released one interval later: a false alarm costs a slot for lost_confirm_s, which is the release headroom of §8.2's twelve slots for ten emitters. Every expectation is therefore about the emitter's slot, never an exact count of slots.
  • An unlocked carrier loop free-runs, and the table must not key on it (#1261, fixed). With the refine's shortest dwell (2 blocks at 47 dB-Hz and the shipped margin, ±200 Hz) a hand-over past loop 1's pull-in (measured: 1847 Hz for a 1500 Hz emitter) leaves the receiver code-locked — the code loop is non-coherent — with the carrier unlocked on every block (symbol lock down, lock_metric at zero) and loop 1 wandering 800 Hz in a second. That is not loop 2 decoding, as first read: it is a degraded receiver, and degrade is the event it logs. At D = 1 the zone is 4.9 kHz wide and nothing notices; at the pool's D = 154 it is 31.7 Hz, and a row keyed on the wander would let the searcher's next hit on the same emitter look new. Two fixes: the receiver's status().doppler_hz is the whole carrier estimate — loop 1 plus what loop 2 has taken up beyond it, the sum configure_chain_raw() already re-seeds from — and the pool refreshes a row's Doppler only while locked holds and its chip phase only while code_locked does, keeping the last locked value (the seed's row, until the first lock) otherwise. test_async_dsss_pool_core pins it on the reproducing stimulus: the status wanders 843 Hz off with the carrier unlocked on 4858 of 4858 tracking blocks and the row stays the seed's; at pfa 1e-3 the same emitter locks and the row follows within 100 Hz.

Next: the lifecycle soak (§12 step 7) — the shipped synth with the window, the block-coherent searcher at D = 154, several emitters at random Dopplers through the channel, arrivals and departures — is the measurement that certifies the pool. Done, §12.14.

12.14 What was measured (2026-09-06) — step 7, the lifecycle soak

native/validation/async_dsss_pool_soak.c (make validate-c; its --check is in the C suite): per emitter one shipped continuous-DSSS synth with the window (450 of 4950 symbols, Gold-1023 at 5 Mcps, 2700 sym/s PRBS BPSK) through the shipped doppler_channel at its own Doppler drawn within ±20 ppm of 2.5 GHz, no rate, from a random burn-in of up to a frame; visibility a gain of 1 or 0 at the sum; the shipped awgn at 45 and 40 dB-Hz. One AsyncDsssPool at the operating point — code_only_epochs 813 so D = 154 (31.7 Hz rows), 500 Hz/s, ±50 kHz, max_peaks 16, twelve slots, the carrier told, a 2 s release interval, the machine's 20 threads — fed one epoch at a time with the event log attached. Emitter 0 always on; nine more with on-times uniform in 15–30 s and off-times in 4–8 s (the design's 5–15 minutes scaled thirty-fold; the pool's maximum on-air time 35 s, so the always-on emitter is released for its on-time and re-acquired). An emitter's slot is the one whose seed is at its code phase within a chip of the synth's own clock through the channel's documented mapping (k(1+d) − delay, plus the burn-in) and within one native tile of its Doppler — a tile, not a row, for the first reason below. The --check is two emitters for 16 s at 45 dB-Hz: the always-on one released for its on-time and re-acquired, the other leaving, released, returning and re-acquired.

What the soak found before it could run — the zone (fixed). The first run filled all twelve slots in 0.4 s with one emitter on the air. A tracked emitter's data blocks are §12.7's smeared copy — at 45 dB-Hz still over the gate, C/N0 estimated at 30–33 dB-Hz, at the emitter's own code phase within 0.3 chip and hundreds of Hz off in Doppler, a different row every block — and the zone of §7.1, one row by one chip, is the width of one emitter's main lobe, 31.7 Hz at this depth. Every such hit looked new and seeded a fresh receiver onto the same emitter (each pulled in and reported tracking); the true aligned hit, when its window came, found no slot. The searcher's list carries no per-peak concentration to tell the copy from a whole emitter, and a strong emitter's coherent tile sidelobes at its own phase would read concentrated anyway; so the pool's zone is now the code axis alone — a hit within one chip of a live row's code phase is that emitter's own at any Doppler. Pinned in test_async_dsss_pool_core with the cost stated: a second emitter within a chip of a live one is not seen until the first leaves (a pair the surface could not tell apart within a tile in any case, and the searcher's own twin rule already holds a same-phase peak at any tile as suspect). After the fix every seed of the two-minute runs was an emitter's own or a noise false alarm; none a duplicate of a healthy receiver's emitter.

The run, 43 stints per C/N0 (38 scored; the five cut by the run's end before the design's own acquisition bound are not):

45 dB-Hz 40 dB-Hz
stints missed 0 0
arrival → held, min / mean / max 0.00 / 0.04 / 0.17 s 0.00 / 0.24 / 1.24 s
arrival → tracking, mean / max 0.05 / 0.18 s 0.29 / 1.30 s
seed error, worst 1296 Hz, 0.30 chip 4877 Hz, 0.29 chip
held, of on-air blocks after first tracking 0.9965 0.9994
tracking with code lock, of held blocks 0.9908 0.9957
departure → release, min / mean / max 2.02 / 3.07 / 6.81 s (24) 2.01 / 2.71 / 5.44 s (30)
released later than the interval + 0.5 s, or not at all 23 of 38 18 of 38
stints re-locked by their own receiver on return 7 1
false releases (lost while on the air) 1 1
double assignments (two receivers code-locked on one emitter) 1, for 3.0 s 0
seeds matching no emitter 1 11
hits dropped for want of a slot 2 0
most slots assigned 12 12
log: seeded / tracking / degrade / lost / released (lost + on-time) 49 / 49 / 47 / 33 / 33 + 9 57 / 57 / 255 / 43 / 43 + 6
  • Nothing is missed and the assignment is fast. At 45 dB-Hz the emitter's own data-block copy seeds it in the first block or two (0.04 s mean, 0.17 s worst) — the window is not waited for; a receiver seeded from a copy hundreds of Hz off pulls in through the refine's range. At 40 dB-Hz the copy is under the gate more often than not and the window matters: 0.24 s mean, 1.24 s worst, under the frame the design allows. A seed at the tile's edge (4.9 kHz) still pulled in.
  • The release fires late — the open finding. After a departure both flags drop within milliseconds, exactly as §12.3 measured, and then the code flag comes back for a block on noise about once a second (103 restarts of the release clock in the 45 dB-Hz run, 63 at 40, every one code 1 sym 0); adr_release_clock() restarts on either flag, so the interval runs from the last flicker and the release comes at 2.0–6.8 s for a 2 s interval, more than half of them past the interval plus half a second, and seven emitters at 45 dB-Hz came back inside their own receiver's overrun interval and were re-locked by it (the recovered assignment of §12.3, only unintended). §12.3's harness watched 1.5 s after switch-off and could not see it. The mechanism, read and not yet measured on its own: the Dll's lock look is the max over the symbol-scale windows of §3.7, and its threshold is sized for one cell at pfa 1e-3 with two verifies, so the false-lock rate on noise is the max's, not the cell's. The fix is the detector's, proven first in async_dsss_receiver_release.c with a longer watch on noise (#1264). Fixed, §12.15: the looks overlapped — a decision read the same noise n times; the threshold was right.
  • Two receiver failures in 76 stints. At each C/N0 one receiver lost a healthy emitter and was released lost — a false release; at 45 dB-Hz that receiver's carrier loop wandered 1.3 then 2.7 kHz off while its code flag stayed up and its Dll walked 150 chips off the code under the wrong aid, so the emitter's next aligned hit was a new seed and two receivers reported code lock on one emitter for 3 s until the first was released. The second seed is the recovery §10 describes; the first receiver's code flag on a walked-off code is the same false-lock finding as above, seen with the emitter present (#1265; fixed, §12.16 — the hand-over, not the receiver).
  • The pool holds. Never past twelve; at 40 dB-Hz eleven noise seeds (0.09 per second against the configured 0.03 and #1064's 0.06), each refining to nothing, "tracking" with both flags down and released an interval later, as §12.13 said; two hits dropped at 45 dB-Hz while twelve slots held ten emitters and two departed ones inside their overrun intervals — this soak's churn is a departure every few seconds against the design's one a minute, and the headroom is the design's. The two hundred and fifty-five degrade events at 40 dB-Hz are the flags' chatter at the floor, none of it a release.

12.15 What was measured (2026-09-06) — the code flag on noise, #1264

The mechanism was not the one §12.14 read. The Dll's symbol-aided detector (§3.7) keeps Q = ⌈P⌉ timing hypotheses, eight here, each an EMA of its own windows' power, and takes its looks from the best one. The guess was that a threshold sized for one cell could not carry the max of eight. Measured on pure noise with the Dll alone, configured exactly as the receiver configures it (pfa 1e-3, n_looks from det_n_noncoh at the design C/N0), the max costs almost nothing — the mean statistic is 8% above √(2n) — and the tail is what breaks: 1.7e-2 exceedances per decision at 45 dB-Hz and 4.2e-2 at 40 for a configured 1e-3, 0.65 and 0.5 false locks per second, against 1.9e-3 and none for the unaided detector on the same noise. The cause is the looks, not the threshold. On a signal the best hypothesis holds and its windows are a symbol apart; on noise it flips between neighbours whose windows share five of six partials, and a decision's three looks then read the same noise three times — a χ² of two degrees of freedom scaled by three against a gate sized for six, whose tail at the gate is 2.4e-2. The receiver's rule restarts its release clock on either flag (§10), so every such lock cost an interval.

The fix is one comparison in aid_look(): a window that overlaps the last look's is not a look (aid_last_end, running state, blob v10). Nothing changes with a signal present. On noise: 2.1e-3 per decision at 45 dB-Hz, 2.5e-3 at 40, 2.9e-4 at pfa 1e-4, and no lock in twenty seconds at any of them — the unaided detector's own realized rate, which is #1064's factor of two over the configured one. Pinned twice: test_dll_core 6b′ feeds the aided detector forty thousand epochs of noise and asks the exceedance rate within four times pfa and no lock (without the guard: 1.6e-2 and six locks); validate_async_dsss_receiver_release --check now watches eight seconds of noise after the switch-off — four release intervals — and asks that neither flag return (without the guard: 6 and 11 returns in the two trials). §12.3's table gains the row, 30 trials per C/N0:

C/N0 over 240 s of noise after the switch-off code lock returned symbol lock returned
45 dB-Hz 8 s × 30 once (0.004 per s) never
40 dB-Hz 8 s × 30 never never

The healthy-signal rows are unchanged: code lock off in no block of the on-time at either C/N0, drop time 3.5 and 10.8 ms after a switch-off.

The soak, re-run on it (§12.14's stimulus and scoring, ten emitters, 120 s at each C/N0): departure → release 2.02 / 2.03 / 2.04 s at 45 dB-Hz and 2.01 / 2.02 / 2.04 at 40 (was 2.0–6.8 s), no release late, no false release, no emitter re-locked by a receiver that should have let it go, one restart of a release clock in four minutes of noise (was 166), and the check pins the interval plus half a second on every host again. Nothing else moved: nothing missed, arrival → held 0.05 s mean at 45 dB-Hz and 0.30 at 40, tracking with code lock on 99.6% and 99.7% of held blocks. One event is left, and it is #1265's: at 45 dB-Hz one receiver still walks off its emitter mid-stint while its code flag holds, a second is seeded, and both report code lock for 1.6 s — understood and fixed in §12.16.

12.16 What was measured (2026-09-06) — the hand-over that never pulled in, #1265

Interference first, and ruled out. The soak's trace now logs every crossing of two on-air emitters' code phases within two chips, with their Doppler difference and relative chip rate — the one way one emitter's full peak reaches another's prompt correlator. Around the failing stint (emitter 1's third, 55.7–77.5 s at 45 dB-Hz) every crossing was fast, 30 to 127 chips per second and under 0.3 s, with the other emitter at least 6 kHz away, none at the seed's moment; and no slow crossing (under 5 chips per second) happened anywhere in four minutes at either C/N0.

The hand-over. The event log had the number: seeded from a data-block hit +594 Hz off, the receiver reported tracking 12 ms later with its carrier estimate −506 Hz off the truth; symbol lock came and went, the estimate wandered to −1.4 kHz by 61 s and −2.7 kHz by 75 s, while the code loop stayed on the emitter's phase throughout (its chip rate was emitter 1's 37.3 chips per second), and a second receiver was seeded when a code-flag dip left the row's phase stale. §12.10 had measured the refine at the pool's shipped margin — 14 dB at 45 dB-Hz sizes a two-block dwell — at −31 ± 210 Hz: −506 is a 2.4σ draw, and one such draw in 49 hand-overs is the 1.6% it predicts. The dwell is sized by det_n_noncoh for detection at the derated C/N0, which needs fewer blocks the higher the C/N0, while the estimate's noise the tracking chain has to pull in from — a few hundred Hz — does not shrink with it.

Confirmed by the wrong fix. The soak with --refine-margin 22 (18 blocks, 36 Hz): at 45 dB-Hz the double is gone and every stint holds symbol lock (0.9925 of held blocks), tracking 0.14 s later. At 40 dB-Hz the same margin derates to 18 dB-Hz and sizes one to four seconds of dwell, during which the row's phase — advanced on the seed's Doppler error, hundreds of Hz for a data-block seed, a chip a second — drifts out of the zone; the next hit seeds a second receiver and 661 545 blocks read two receivers code-locked on one emitter. The lever is a floor on the dwell in blocks, not a detection margin.

The fix: refine_min_blocks, default 7 (42 ms, §12.10's floor dwell, 77 Hz), applied when the refine chain is built and clamped by the give-up cap; set_refine_min_blocks() on the receiver and on the pool. Pinned in test_async_dsss_receiver_core: the default receiver's dwell at 45 dB-Hz reads 7, 2 with the floor removed (proven red without it), the cap with a floor above it. The pool's #1261 reproduction removes the floor explicitly, because with it the wander it pins never happens.

The soak on it, ten emitters, 120 s at each C/N0:

45 dB-Hz 40 dB-Hz
double assignments 0 (was 1, 1.6 s) 0
false releases 0 0
tracking with symbol lock, of held blocks 0.9968 (was 0.9910) 0.9841
arrival → tracking, mean / max 0.09 / 0.21 s (was 0.06 / 0.18) 0.35 / 1.30 s (unchanged)
departure → release, min / mean / max 2.02 / 2.09 / 3.82 s 2.01 / 2.02 / 2.04 s
stints missed 0 0

The one 3.82 s release is §12.15's residual: the code flag still returns on noise at 0.004 per second, and one return inside the interval restarts the clock once — the rule's own worst case at that rate, two intervals, which is what the soak now bounds (two returns inside one interval is a 1e-4 event per departure). The sweep is green at both C/N0s.


13. What this page does not settle

Of §5.4's open questions, one is still open: the frame epoch (3) — a detecting block locates the window to within D epochs, and the exact boundary is the tracker's to find. Everything else is answered: the window and the cadence (4), the holder (5, the pool of §8.2) and the spread (7, 10 dB — branch one). Of what the receiver lacked, the hand-off-mode constructor, seed(), the lost state, the idle it resets to and the status record are shipped (§4.1); still implementation, not design: the stimulus with the window, the block-coherent searcher, the pool (§12 steps 10–14).

Of the numbers, the budget (§12.1), the floor (§12.2), the release (§12.3) and the knee (§12.6) are measured on the shipped objects at D = 1, and the coherent blocks move three of them: the floor, the knee and the searcher's cost are re-measured by §12 steps 11 and 14 before anything is priced on them. Two things the measurements raised and this page only names: the peak list's same-code-phase rule (§7.1) passes a strong emitter's persistent sidelobes under long non-coherent integration (§12.6, #1191), and the false-release rate of the both-flags-down rule is bounded only over half a minute, not the 15-minute maximum on-time (§6.1) it must hold for — and, from the soak (§12.14, #1264), the code flag's false re-locks on noise made the rule fire one to three intervals late — fixed at the Dll's looks (§12.15), leaving one return per 240 s that restarts the clock once in a hundred departures; and the hand-over that never pulled in (#1265) — fixed at the refine's dwell (§12.16).


14. See also