Skip to content

BurstCapture: acquisition's output, turned into bursts

Status: shipped, as doppler.acquire.BurstCapture and its file-backed view PersistentBurstCapture; DsssBurstReceiver composes it. §1–§6 are the rationale written before it was built, and describe the receiver as it was then: where they say "today's receiver", read "the receiver before BurstCapture". The certification is its validation report. Scope: a C object — BurstCapture — that sits between a detector and whatever consumes a burst. It searches, refines, retains, and emits the burst's SAMPLES. It stops there. Decision record: dsss-burst-receiver.md §11 — why this is being built, including the count it was first declined on.

Tracked as gh-1166.


1. Why — a shipped composition that dead-ends

doppler.dsss.orchestrator.Acquirer is a second composition over BurstAcquisition: K coarse-Doppler channels, each DDC → BurstAcquisition, fanned across a thread pool, with get_state/set_state per channel so the bank ships to other pods. It feeds acquisition continuously, which is the right way to drive it. Its output is Detection records — a Doppler and a code phase — and there it stops.

A user of that bank who wants the burst has two routes, and both are bad.

  • Continue feeding continuously. Anchors are stream-absolute and there is no sweep — and §3.1 bites in full: acq_result_t::code_phase measures burst_start mod code_bins exactly, so it fixes the alignment WITHIN a code period and never says WHICH repetition. A burst has a frame that begins in one specific repetition. Resolving that is a refine stage, a history ring to reach back into, a retention rule, and a claim rule — and §3.2 makes getting it wrong a cliff: a window one period out decodes at half the payload in error, which is noise, not degradation.
  • Sweep acquisition in reset()-per-dwell dwells. That makes pos + code_phase a real position, so refine is not needed — paid for with PRE_LEN/ACQ_HOP overlapping FFT dwells over the whole stream, two window constants nothing derives, a hand-rolled cluster-and-keep-strongest rule, a configure_search_raw pin without which the auto-sizer makes a decision impossible, and the entire capture held in RAM to slice from. That last one means it does not stream.

So the ring, the refine and the claim rule exist exactly once, inside DsssBurstReceiver — an object that also demodulates. The capability has no home a caller can reach.

1.1 The criterion

A caller holding a detector and a sample stream can get the burst's samples, aligned and never late, without writing epoch arithmetic.

"Never late" is the load-bearing half, and it is §3.2's: the hand-off's obligation is not to be exact, it is to never be late and to say how early it is. A capture that emits a window starting after the preamble has destroyed the burst.


2. Use cases

caller supplies wants back
DsssBurstReceiver a sample stream aligned windows to hand to BurstDemod, with no copy
an Acquirer channel a down-mixed sub-band the bursts that channel found, in that channel's own timebase
a recorder / corpus builder a stream and a burst length every burst, to disk, with the event that names where it was
a second consumer the same stream windows to fan out to a despreader as well as a demodulator

The first row is the demanding one: it is an existing certified object whose behaviour must not change, so the capture has to be reachable without a second copy of every burst.


3. What it owns, and what it does not

owns does not own
the acquisition engine (§4) the frame — what the symbols after the sync mean
the history ring, its span, and the retention rule demodulation, despreading, or any decision
refine: the period-resolving correlation a CRC, an outer code, a randomiser
CLAIM: which detections name one burst what a burst is FOR
the suppression window that follows an emitted burst telemetry about a consumer's estimates

burst_len is a construction parameter, and that is the point of the split. dp_acq_create_burst() takes search parameters only and has no notion of how long a burst is; teaching acquisition frame geometry to support refine would be wrong. For a capture the burst length IS the parameter, because it is what gets captured — and it is what sizes the retention.


4. It owns the engine — a correctness choice, not a convenience

gh-1166 drew this object as push(samples, acq_results): the caller brings their own detector. That face is rejected on one invariant.

acq_result_t::samples_consumed is stream-absolute only for an engine fed continuously and never reset(), in the caller's own sample coordinates.

An object taking foreign results has to require that and cannot check it. The failure is not a slightly wrong window: refine searches the wrong repetition and §3.2's cliff returns noise. push() defining the coordinate system makes the invariant internal and unbreakable.

What that costs is a caller with a foreign detector, and the loss is smaller than it looks — but the first version of this paragraph overstated it, and the correction is worth keeping. It said "the engine stays reachable as a child for read-backs and configure_search_raw()". It is not reachable: there is no child accessor and there is not going to be one. What is true instead:

  • BurstAcquisition is public. A caller who wants the search's own face constructs one with the same parameters and gets all 22 of its read-backs. Nothing was taken away by this object existing, which is what makes the swallowing survivable.
  • The search this capture will do is visible FROM the capture — doppler_bins, n_noncoh, code_bins, doppler_span_hz, both detection gates, straddle_loss, pd_predicted and underpowered (§9.5). Forwarded, not re-derived.
  • A consumer receiving detections over a transport needs the WINDOW as well, which is what §1.1's sufficiency criterion already requires the event to carry.

The version that shipped first forwarded ONE of the engine's 27 read-backs, had no noise_mode at all, and let the under-powered warning vanish. An object that swallows another and republishes a twenty-seventh of its face is a split in the wrong place — and the fix was not to move the split but to forward what a capture caller actually needs.

4.1 Any repeated preamble, and one replica of it

dp_burst_capture_create() takes the preamble as its samples: one period of any repeated complex sequence (a chirp, Zadoff-Chu, shaped PSK), at fs (#1470). A PN code is one such preamble -- its chips by bin_to_nrz(), each held spc samples, at fs = chip_rate · spc -- and DsssBurstReceiver builds it that way from the code it is given. The engine is dp_acq_create_burst(). Nothing downstream of it reads chips: the ring, CLAIM, refine's Doppler cells and the window all work in periods and samples.

Refine correlates each candidate position against the engine's own reference row, the preamble at unit RMS. It used to expand PN chips a second time, by its own rule. That copy could drift from the one acquisition searches with, and it could not describe anything but a code. So the engine is built first, and the capture reads its reference.

A preamble repeated every P samples is sampled once a period in slow time, so its Doppler is defined only modulo 1/P: +span and -span are one frequency. A burst near the band edge can be reported at the other edge. The tests read it that way. Refine cannot: it mixes within each period too, where the two edges are a whole span apart, so it wraps every Doppler cell into the native span first. Unwrapped, every even depth centred its cells on the wrong alias (doppler#1502).


5. The surface, and the two faces of one mechanism

dp_burst_capture_state_t *dp_burst_capture_create (
    const float _Complex *preamble, size_t preamble_len, size_t burst_len,
    size_t reps, double fs, double cn0_dbhz, double doppler_uncertainty,
    double pfa, double pd, int noise_mode, double doppler_rate,
    size_t rate_hypotheses);

size_t dp_burst_capture_push (dp_burst_capture_state_t *, const float complex *x,
                           size_t x_len, float complex *out, size_t max_out);
size_t dp_burst_capture_events (dp_burst_capture_state_t *, size_t n,
                             burst_capture_event_t *out, size_t max_out);

/* The C consumer's face: borrow, do not copy. */
size_t dp_burst_capture_ready  (const dp_burst_capture_state_t *);
const float complex *dp_burst_capture_window (const dp_burst_capture_state_t *,
                                           size_t i);

push() returns windows concatenated — burst i at i*burst_len — with events() giving each its own record. Same shape, and the same reasoning, as DsssBurstReceiver's §8.2/§8.3: returning a list means a caller cannot fail to drain, and two different output lengths need two calls because the binding generator emits one variable-length output per method.

5.1 A window is COPIED out of the ring, not borrowed from it

The receiver hands burst_demod a pointer straight into the double-mapped ring — no copy, no seam. The obvious move is for the capture to keep doing that and let its consumer borrow.

It does not, and the reason is lifetime. A borrow would have to stay valid until the caller was done with it, so the retention rule would have to hold every window emitted by the current call — and one push can complete several bursts, while the ring's capacity is derived as 2 · (refine_span + burst_len). Retaining an unbounded number of complete bursts is not something that capacity promises.

So each emitted window is copied once into a grow-on-demand scratch, and both faces read from there: push() copies scratch → the caller's buffer, and dp_burst_capture_window() borrows the scratch. A composing object therefore pays one memcpy per burst and no more.

That is a real cost and it is named here rather than buried: it is one copy per BURST, not per sample, which is a different order of magnitude from the whole-stream copy §6.1 weighs. Whether it is visible at all is §8's first question.

5.2 A partial window is never returned

push() truncates to a whole number of bursts. A caller handed 3.5 bursts cannot tell where the truncation fell, and half a burst is not a burst.


6. The one behaviour that changes

DsssBurstReceiver arms its suppression window — the span after a burst in which detections are the payload firing against the acquisition code, not new bursts — when dp_burst_demod_demod() returns a non-zero bit count. §10.3 settled that: what arms it is the burst having demodulated, which is a physical fact that object owns.

A capture does not demodulate. It arms the window when it emits — refine resolved a start here and the whole span was handed out.

These differ in exactly one case: a full window on which the consumer's demodulation returns nothing. In burst_demod that is reachable two ways — an allocation failure, or fewer despread symbols than the frame needs — and the second cannot happen for a window burst_len long by construction. So the divergence is confined to allocation failure, where today's receiver leaves the span unsuppressed and re-detects inside a burst it failed to demodulate.

It is written down here because it is a behaviour change to a certified object and must be pinned by a test, not argued about. Which behaviour is better is not obvious — re-detecting inside a failed burst produces a second attempt whose refined start lands inside the payload, which is not a recovery — and the honest answer is that no measurement has been asked for one.


7. What DsssBurstReceiver keeps

The receiver is not being thinned for its own sake; it keeps everything that is about a decision rather than about finding a burst.

moves to BurstCapture stays in DsssBurstReceiver
the ring, its sizing, chunk_max driving BurstDemod, re-seeded per burst
refine and its scratch frame_syms and the payload rows
the retention/trim rule llrs() and the soft bits
CLAIM and the detection queue the demodulator's own estimates
the suppression window events() rows built from both halves

Its public API does not change. Its state blob does: the capture's blob nests, and DSSS_BURST_RECEIVER_STATE_VERSION bumps. The format is unreleased, so a layout change is free.

The gate on the refactor is bit-exactness, not a green suite: the receiver's existing tests and its certification must produce the same numbers, because every line moved is a line whose measurements were taken against the old arrangement.


8. What characterization has to settle

Written down first, so a later sweep measures them rather than confirming a decision already made.

  • Does a dwell that runs into the data cost the capture Pd? — MEASURED, and the engine now sizes on the burst. A burst engine's dwells of D repetitions are aligned to the stream, not the burst, so a preamble of R repetitions spans about R/D of them, whole or partial. One aligned dwell's Pd (pd_predicted) was wrong in both directions: pessimistic at small D, where a burst offers several dwells, and optimistic past D = (R + 1)/2, where the detecting dwell straddles noise or data. Measured on Zadoff-Chu 127 × R = 8 then QPSK data, each depth at the C/N0 where one dwell predicts 0.6, delivered Pd fell from 0.900 at D = 1 to 0.263 at D = 8.

    pd_burst averages over the alignment, credits every dwell the preamble spans, and shares one Doppler and code delay across them (drawing them per dwell over-credited small D by up to 0.05). The sizer meets pd with it, and underpowered reads it (#1498). With each depth at pd_burst = 0.6, the engine delivers 0.64–0.70 against it (native/validation/capture_dwell_pd.c): conservative within acq's bounds at every D. D = 1 was 0.045 optimistic until the model priced the CFAR reference, 127 cells there and inflated by the burst itself (#1501). The capture then loses a further 0.00–0.02 after the engine detects, which the model's margin covers but does not price (#1502). The delay must be continuous: a first run put every burst on a whole sample and read up to 0.23 high.

  • Does the per-burst copy cost anything? — MEASURED at the test geometry, and no. §5.1 chose a copy over a lifetime contract. bench_burst_capture_core, 64k blocks, minimum of 30 rounds, ACQ_SF=31, REPS=4, burst_len=2448:

    row min rate
    push[quiet] 2.242 ms 29.2 MSa/s
    push[4 bursts] 2.276 ms 28.8 MSa/s
    push[4 bursts, file] 2.268 ms 28.9 MSa/s

    Four bursts — four refines and four window copies — cost 34 µs against a 2.24 ms search floor, so the capture layer is 1.5% on a block that is almost all acquisition. What is NOT settled is the same question at a real link geometry, where a window is 2.5 MB rather than 20 kB: the copy scales with burst_len and the search floor does not. If it becomes visible there, the answer is a bounded borrow window, not a bigger ring.

  • How much dead air must a caller leave? — SETTLED, and the object derives it (2026-09-01). This section first recorded the answer as measured, 256 samples, against a documented max(0, refine_span - burst_len) of 32. Both numbers were wrong, in different ways, and the pair is worth keeping:

    • The formula omitted the detection lag. A detection's anchor is the code epoch of whichever frame detected, and acquisition's framing is not aligned to the preamble, so the last frame that can detect sits reps · P past the true start — the term §7.1's own table already names. CLAIM merges anchors closer than refine_span, so the first burst detected LATE and the second EARLY close by that much:

        min_gap = max(0, refine_span + reps·P - burst_len)
      
    • The measurement was under-powered. 256 came from 12 trials reading 100%; at 60 trials the same spacing reads 88.8% and the curve reaches 100% at 528 — which is what the derivation gives. A floor measured with too few alignments is not a floor, and this one reached a certified report before the sample size was questioned.

    Checked on four geometries, each predicting a different bound (reps=6 → 1520, a 63-chip code → 3088, a 16-chip data code → 0), each reaching 100% at its own value and short of it below. min_gap is a read-back on both the capture and the receiver, so a caller applies no rule at all (doppler#1172).

    Zero is a real answer — a burst longer than refine_span + reps·P needs no gap for the claim rule's sake — and it is not advice: at the geometry where min_gap reads 0, a touching pair measures 88%, because a zero gap is a continuous stream rather than a burst link (§5.1).

  • Does the queue depth still hold at a capture-only geometry? q_cap is derived from burst_len/refine_span (2·(3 + burst_len/refine_span + 1), at least 8), and the receiver only ever exercised it where a burst was also demodulated. A recorder with a very long burst_len and a short code is a shape nothing has run.

  • What is the retention floor for a caller who never consumes? pending is the read-back that says "a burst is held". A recorder that stops pushing has no way to flush; whether it needs one — a drain() that emits what it can — is unsettled, and the honest default is not to build it until a caller asks.

  • Is burst_len the right parameter, or is it two? Retention is refine_span + burst_len, and a caller who wants a window WIDER than the burst (guard samples either side, for an offline analyst) currently has to inflate burst_len and get the suppression window inflated with it. Whether a separate capture span earns its knob is a question for a real recorder, not for this document.


9. Persistence — the ring in a file

§8's last question was whether a deployment could hold the look-back in RAM and in the blob. PersistentBurstCapture is the answer for one that cannot: the same object, with the history ring's pages backed by a file instead of anonymous memory.

9.1 There is no write path

buffer.h builds its ring by mapping one fd twice at adjacent addresses, which is what makes a window spanning the wrap contiguous. The mirror does not care where the fd came from, so a persistent ring is that same double mapping over an open()ed path — MAP_SHARED, so the ring's samples ARE the file's contents.

That is the whole mechanism, and it is worth being explicit about what it is not: there is no mirror buffer, no background flusher, no second copy of the stream, and no way for the memory and the file to hold different bytes. Writes land in the page cache and the kernel writes them back; get_state() calls msync so a checkpoint and the history it names are consistent even across a crash.

The primitive lives in buffer.h beside the anonymous one (dp_f32_create_backed, dp_f32_sync), not in this object — a second private ring is exactly the drift this repo forbids, and every DECLARE_DP_BUFFER type gets the capability for free.

9.2 What it buys: the blob stops carrying the history

§8's measurement was that the look-back IS the blob. Backed, it is not. Measured 2026-10-10 at ACQ_SF=511, REPS=5, DATA_SF=63, spc=4:

frame retain_span in-RAM blob backed blob ring file
61 sym 80 780 2.18 MB 82.5 kB 2.10 MB
1029 sym 324 716 8.47 MB 83.1 kB 8.39 MB
8029 sym 2 088 716 33.64 MB 87.7 kB 33.55 MB

A checkpoint at the long geometry drops from 33.64 MB to 88 kB — 383× — and what is left is the acquisition child plus the detection queue. The in-RAM blob is the ring's capacity in bytes plus exactly that (here also the file's size, which rounds up to a page), because it carries the WHOLE ring, not retain_span of it: a live capture can hold history past retain_span for a burst release() handed back, and a resume that dropped it would not be one (§11.4). The ring is twice the retained span rounded up to a power of two; the file is written once and reused, not accumulated. (Re-measured 2026-10-10 for #2015, on main after #2042's framer-snapshot acq blob. The 2026-09-01 table read 0.61 / 2.57 / 16.68 MB in RAM, when the blob carried only retain_span; retain_span itself and the backed blob have moved with other changes since.)

The second thing it buys is that the history outlives the process. Point a new capture at the same path and the samples are already there; restore the blob and it reaches back across the restart into a burst that began before it.

And it costs nothing measurable, which is the claim the mechanism has to earn: push[4 bursts] at 2.276 ms against push[4 bursts, file-backed] at 2.268 ms — the two rows are within each other's spread, because MAP_SHARED means there is no second write to be slower than. bench_burst_capture keeps both rows side by side, so the day that stops being true is the day the pair separates.

9.3 Two refusals, both deliberate

  • A backed blob does not restore into an in-RAM capture, or the reverse. state_bytes() differs, so jm's length check rejects it. They are different configurations, and silently accepting one for the other would resume a capture whose history was somewhere else entirely.
  • A blob claiming retained history the file cannot hold is refused. The file holds the span a blob names when create() adopted a ring of exactly this geometry with history in it, or when this capture wrote it itself — its ring head is its own stream position — and in either case only while the span is still inside the ring. Without that check the positions would be perfectly valid and the samples would be zeros or another stream's, so the capture would simply never find another burst — indistinguishable from a quiet stream, which is the failure this object exists to prevent. What is not refused: a live capture restoring a checkpoint it took itself, after any number of pushes (#1190) — set_state → push → get_state per call is a supported service shape on both flavours.

9.4 Why a view rather than an argument

The two constructors differ and nothing else does, so PersistentBurstCapture is a [[burst_capture.views]] entry over the same core — the mechanism MatchedDDC established: a difference in CONSTRUCTOR is a flavour, a difference in METHOD SIGNATURE would be a separate type. The methods are shared verbatim, so there is one algorithm rather than two.

An optional path=None on the base constructor was the alternative and is worse twice over: a non-required string init-param renders its own "create with defaults" doctest as BurstCapture(path=0), which fails the stub-doctest gate, and a backing file with a default is a capture quietly persisting somewhere nobody named.


9.5 Minimal where it is REQUIRED, rich everywhere else

Two different axes, and pulling on the wrong one makes the object worse.

Required configuration is one parameter: preamble (one period, as complex samples). Everything else — the geometry, the search targets, the burst length, the CFAR mode — carries a default, so the smallest thing that constructs is BurstCapture(preamble). That is the axis to minimise, because every required parameter is a decision a caller has to make before they can begin.

Read-backs, optional configuration and diagnostics are rich, deliberately. Twenty-two read-backs is not bloat when each answers a question the object alone can answer:

group what it answers
refine_span, retain_span where may the next burst go, and how much trailing context
pending, dropped, n_bursts am I losing bursts, and is one still in flight
the events() row where was THIS burst, how strong, how well resolved
the search group will this configuration find my bursts at all

The one that earns its place most is underpowered, because its failure mode is silence: a search that cannot meet the requested pd still builds a best-effort grid and then captures fewer bursts than arrived, which is indistinguishable from a stream with nothing in it. It is a declared warning, gated on a bool field. The sibling BurstAcquisition declares the same warning the same way: dp_burst_acq_state_t carries its own underpowered field for it, because jm's condition must be a bare identifier on the struct.

threshold is not forwarded, though the engine carries it. eta and eta_nc are the gates, and n_noncoh says which is in force. A burst engine's n_noncoh is always 1, so eta is the gate and eta_nc reads 0.0. (The engine zeroes threshold on the non-coherent path, where the continuous engine runs; a burst capture never takes it.)


11. The sizing contract, and whose verdict a span is (2026-09-01)

Two things the bank composition (#1174) and the review it asked for (#1181) settled about this object. Both are gates now, not prose.

11.1 One look, and a design C/N0 that is optional

Refine reaches k_lo = 3·reps + 2 code periods back from an anchor. The anchor is acquisition's: samples_consumed − frame_n + code_phase, the epoch of the frame a hit was stamped on. frame_n is the engine's whole coherent dwell natively and one code period in tiled Doppler mode, where backing off n, W periods, anchored every hit (W − 1) periods early (#2090). On the non-coherent path that frame is the last of n_noncoh accumulated, so a burst — whose preamble sits in one frame — gets an anchor up to n_noncoh · coherent_bins periods late. Sized at the old default of 50 dB-Hz the burst sizer chose n_noncoh = 6 at the test geometry, and the same 34 dB scene came back 9 and 3 periods late — while the refine_margin of the day read 0.68, better than a correct 0.81, which is one of the reasons that read-back was later removed (doppler#1312): it was most confident exactly where it was most wrong.

The extra looks could not have helped either: the sizer's Pd model assumes signal in every look, which is the continuous engine's situation, not a burst's. So the contract is:

  • A burst engine never buys non-coherent looks. When the coherent ceiling falls short of pd it is underpowered, honestly, rather than escalated (acq_auto_config_burst).
  • cn0_dbhz is a design (minimum) C/N0, and optional. NaN (ACQ_CN0_NONE, the default) means none given: the whole preamble is integrated in one look (capped by doppler_rate when one is given) and the threshold is pfa's alone; pd is a target only with a design point, and without one pd_predicted is NaN and underpowered never asserts. Every finite value is a design point, negative included: it used to be 0, which left normalized units (fs = 1, where C/N0 is the per-sample SNR) no way to state one (#1484). The continuous engine keeps a required, finite C/N0 — looks are its only lever.
  • The capture refuses a pinned grid past its reach: configure_search_raw returns DP_ERR_INVALID when n_noncoh · doppler_bins > k_lo.

Pinned in C at the default sizing and at a design point the ceiling cannot meet, and in Python at reps ∈ {3, 6, 10} — reps is a link variable, never 4.

11.2 Hold, and release

An emitted window owns its whole span: detections inside it are the payload firing against the acquisition code. That rule was armed on emission, and the receiver's design says only a decoded burst may own a span (§10.3, #1004). Under the two-look grid the timing hid the difference; under one look a decoy whose window ends just after a real burst begins swallowed that burst's first detections (receiver §2.10, lead 2100).

The capture cannot reach the verdict — it stops at samples; error detection, in whatever form a frame carries it, is the consumer's. So:

  • detections inside an owned span are held (shadowed), not dropped;
  • dp_burst_capture_release(i) gives window i's span back and the held detections are searched again on the next push();
  • unreleased, they are dropped when the next push() begins — exactly what a consumer with no verdict always got. pending never counts them;
  • they never hold the history ring. Its tail is kept for the oldest entry that can still be emitted, and a held detection whose history has been trimmed goes early, since no release could refine it again. Only a push long enough to trim past one gets there (#1527).
  • they never stall the queue: emit takes the first entry that is not held. A complete window behind a held head used to wait, pin the history tail, and with bursts longer than refine_span make one long push drop a chunk and a burst (#1534).

DsssBurstReceiver checks its own trailer in C (frame_valid, scalar and per event row) and releases every window that fails. Nothing assumes that trailer beyond this receiver: a link may code, interleave, carry a midamble or detect errors another way, and the capture's contract is the same.

STATE_VERSION 1 → 2: the pending entry grew a flag. Proven by five sabotages: the sizer escalating again, the reach refusal removed, a shadowed hit dropped on arrival, dropped at emission, and the receiver never releasing — each red at its own pin.

11.3 Stream order

What a push returns follows the stream, not the caller's block size. A push is processed in chunks of up to chunk_max, and each detection is claimed only after every pending burst whose window had arrived when that detection was made has been emitted: burst_capture_have() reads the history up to a horizon, the detection's samples_consumed, while it is claimed.

Before that, a chunk's detections were all claimed and then drained once. A burst a small-block caller had already been handed was still pending when the next burst's detections arrived, and inside refine_span they merged. Four bursts at a quarter of min_gap came back as one wrong window when pushed whole, and as four exact ones in 1000-sample blocks (#1527). Fixed, 120 of 120 runs agree across block sizes, gaps from min_gap / 8 up, and lead offsets; §2.4's pair sweep reaches 100% at 194 samples of dead air, down from 388. min_gap stays sufficient and now looks conservative (#1530).

Proven by three sabotages. Removing the drain before a claim turns the below-min_gap block-size test red, and the chunk-invariance test with it. Ignoring the horizon turns five refine tests red, because bursts were emitted before their later phases were scored. Holding history for held detections stops the receiver's saturation test: it used to refuse a chunk, and since push() never refuses input (#2015) the held entries pin the ring until it has no room after trim, and push() aborts rather than spin.

11.4 Input is never refused (#2015, #2028)

push() writes what the history ring has room for and loops, so the ring's head, what acquisition has absorbed and samples_fed are one stream position, and every epoch is stream-absolute. An all-or-nothing write refused whole chunks instead: acquisition never saw them, and every later epoch came out early by exactly what was refused. At a constant block size the first refusal was also permanent. emit() swapped the next entry to the queue head, release() then gave back older ones behind a newer, unarrived head, and a complete burst pinned the ring: 73728 samples and 11 of 15 bursts at 8192-sample blocks.

The queue now stays in anchor order, and a detection is judged dead by its REACH -- refined, its window's first sample; not yet, the lowest position refine will read across its phases. One whose reach is behind the ring's tail can never be emitted, and is swept at every trim, wherever it sits; dropped counts the part of its span that is gone if it was a burst, and nothing for a shadowed one. On a live stream no burst dies this way, so dropped stays 0: a moved anchor is a later one, and a re-armed refine reads no further back than the tail an unarrived entry at or ahead of it kept. A resume with less look-back, or a forged blob, is what the sweep is for. Sweep, drain, trim, in that order, is what bounds the ring: the head is never more than max(burst_len, reps·P) + (2·k_lo + k_hi + 2)·P past the tail, below 2·retain_span ≤ capacity, so a write always has room. It is not within retain_span + P: an entry refined at its earliest candidate behind one with the same anchor refined at its latest pins max(burst_len, reps·P) − burst_len + (k_lo + 1 − reps)·P beyond it, which is (k_lo + 1 − reps)·P when burst_len ≥ reps·P and (k_lo + 1)·P − burst_len otherwise.

A checkpoint carries everything the ring holds -- up to its capacity, a pure function of configuration -- so a resume emits what the live capture would have; carrying only retain_span lost a burst release() had handed back. A blob is checked whole before any of the capture is written, so a refused one leaves its next get_state() byte-identical. The checks are: a stream position below 2^63 and a suppression span that ends by it; the queue's phases within their array, its readings finite, its anchors in order and none past the stream position, and a refined start within the range refine chooses from; a look-back no longer than the stream; and an acquisition child that fits its region and stands at the capture's stream position. The child is restored to check that, and handed its own blob back on a mismatch, which resets acq's unserialized dwells and surface_at. Each check stands between a forged blob and a push() that aborts, reads out of bounds, shifts every later epoch, or silently stops capturing.

11.5 A sweeping carrier: cap the depth, or compensate the rate

doppler_rate alone is a CAP. A carrier that sweeps at a Hz/s drifts a·D·T over a block of D repetitions (T = P/fs), and the slow-time transform stays coherent only while that is under half of its row, 1/(2·D·T): D ≤ f_epoch/√(2a). A faster sweep does not break the capture, it shrinks the block, and the integration gain with it.

rate_hypotheses = n > 1 compensates instead. doppler_rate becomes the SPAN [-R, +R], and the coherent block is searched at n rates evenly spaced across it. For each, the slow-time axis is multiplied by exp(-jπ·a_h·t²), t the epoch's time from the block's middle, before the transform. The cap then applies to the RESIDUAL, the farthest a true rate can sit from a hypothesis, R/(n - 1): the depth grows by up to √(n - 1) at a fixed span (the bound reaches 15 at n = 9; the sizer picks the smallest depth that meets pd, which at the design point below is 10). One mechanism, both grids: the narrowband engine multiplies each column before its slow-time FFT, and the tiled one (a doppler_uncertainty past the native span, PR #2210) multiplies inside every tile's block.

rate_hypotheses = 0 chooses n. For each n from 1 the sizer is run twice in effect: D*(n) is the depth it picks with the drift bound OFF, priced with n looks per cell, and D_cap(n) is the depth the residual R/(n - 1) allows (n = 1: R itself). The first n with D_cap(n) >= D*(n) is the first at which the hypotheses stop costing depth, so it is the smallest that does not trade sensitivity for work; if none does by 64, it is 64. At the design point below it picks n = 3 at D = 8, where n = 9 is D = 9: a capture Pd of 0.927 (95% 0.909 – 0.942) against n = 9's 0.932 (0.915 – 0.946) at 1000 trials, the same within the intervals, with a third of the hypotheses a dwell (the table below). A strong design point that a capped depth already serves picks n = 1. The choice reads back as rate_hypotheses, burst engines only, and like any n > 1 it needs a doppler_rate to span.

A block of one or two epochs cannot see a rate: at D = 1 the de-chirp is t = 0, at D = 2 it is t = ±T/2, and either way every hypothesis multiplies the block by one constant phase, so the n surfaces are the same surface. An engine sized there (a strong design point makes the sizer pick the smallest depth) searches ONE look: the gate of an engine without hypotheses, n = 1's cost, and a reported rate of 0. Without that, a strict > kept hypothesis 0 and reported -R whatever the carrier did.

The reported rate (doppler_rate_hz_s) is a hypothesis, not a fit, and a reliable one only from a block WHOLLY inside the preamble and well above the design point. Adjacent hypotheses differ by at most π/4 of phase at the block's edges at the depth the residual bound allows, so at the C/N0 the engine was sized for noise picks among neighbours, and a dwell straddling the preamble's edge integrates part payload and often names ±R. Measured by validate_acq_rate_pd's reported-rate rows (n = 9, D = 9, design −12.75 dB-Hz, 200 seeded bursts a row):

signal true rate all hits naming the nearest full-block hits naming it
−12.75 dB 0 15.0% 18.3% (of 142)
−12.75 dB +R 52.5% 60.3% (of 151)
+10.25 dB 0 39.8% 100% (of 158)
+10.25 dB +R 56.1% 100% (of 165)

About half the hits at the design point, and seven in ten well above it, come from straddling dwells. The 23-dB-over rows are gated (95% of the full-block hits, with at least 40 of them); the others are printed and not claimed. Detection is unaffected, since the surface that wins is a good one whichever neighbour it is; treat the value as a seed. With max_peaks > 1 one hypothesis's surface serves the whole dwell's list, so every hit of a dwell wears the winner's rate.

What it costs, and where the cost is paid.

  • Work is about n times per input sample: each hypothesis is a full slow-time pass, CFAR-tested against its own surface (a reference taken over a max of hypotheses is biased up, and the gate is derived for one). A dwell costs more than n times, because the block also gets deeper with n. Measured, one thread, ZC 127 × 16 at fs = 1: 43 µs a dwell uncompensated (D = 5), 418 µs at n = 5 (D = 9), 772 µs at n = 9 (D = 10), about 85 µs a hypothesis. Refine's own rate grid is paid once a burst, not a dwell: searched with one acquisition hypothesis and a declared rate (nine rows instead of one), it adds about 0.42 ms to a 40,000-sample push carrying one burst, 2.52 ms against 2.93 ms (#2219).
  • False alarms are priced, not hoped away: the per-cell Pfa is split over n times the cells a dwell's maximum ranges over (every searched row of the interpolated surface, times code_bins), so the gate rises with n. The hypotheses are strongly correlated, so that is conservative: measured per dwell against a target of 1e-3 (200,000 dwells each), n = 1, 3, 5, 9 read 0.95e-3, 0.60e-3, 0.52e-3 and 0.22e-3 (#2287). CI holds them, each not significantly above the target, in validate_acq_rate_pd_pfa (#2324).
  • Refine follows. Its coherent sum runs the whole preamble, so a sweep that the engine's short dwell tolerates collapses it: the capture named the wrong repetition for about half the bursts at high SNR. Refine tries the same rates (on a grid denser than the engine's whenever the engine's is too coarse for reps periods, the drift rule at that depth), whenever a rate is DECLARED, so with one acquisition hypothesis too (#2219), and widens its Doppler cells by the shift a rate puts between the detecting dwell's middle and the preamble's. Each of those two is worth about 0.02 of Pd on the validator's trials.

What it buys, measured (native/validation/acq_rate_pd.c; ZC 127 × 16, fs = 1, rate span ±9e-7 cycles/sample², so a carrier at the span's rate sweeps 0.0018 of fs across the preamble; C/N0 −12.75 dB, where n = 9 predicts a burst Pd of 0.9; 1000 trials a row; Wilson 95%; the swept carrier is the library's own rotation, dp_swept_rotate, not a private phase ramp):

n D rate drawn pd_burst engine capture capture 95%
1 5 uniform 0.848 0.958 0.905 0.885 – 0.922
3 8 uniform 0.903 0.981 0.919 0.900 – 0.934
3 8 midpoint 0.903 0.984 0.935 0.918 – 0.949
5 9 uniform 0.911 0.982 0.926 0.908 – 0.941
5 9 midpoint 0.911 0.983 0.931 0.914 – 0.945
9 9 uniform 0.900 0.978 0.932 0.915 – 0.946
9 9 midpoint 0.900 0.984 0.913 0.894 – 0.929
3 8 uniform, auto 0.903 0.979 0.927 0.909 – 0.942

midpoint puts every trial's rate exactly half a spacing from the nearest hypothesis, the worst case the depth bound is derived against; uniform draws it over the span. pd_burst prices a rate ON a hypothesis, and the table is the evidence that the half-spacing residual costs nothing beyond what the bound already allows: the worst case reads the same as the average. Uncompensated, one hypothesis with the rate declared, the capture delivers 0.905, above its own pd_burst. It used to deliver 0.555: the engine saw the burst (0.946 then) and refine lost it, because refine searched rates only when the engine had several hypotheses. Keyed on the declared rate, refine keeps what the engine finds (#2219). What hypotheses still buy is the engine's depth, and that shows where the capped depth is underpowered, 3 dB under the design point: engine Pd 0.542 capped against 0.747 for n = 5 (what auto picks at that C/N0; at the design point it picks n = 3) and 0.685 for n = 9, outside the intervals. A capture still places a few percent fewer bursts than its engine detects, compensated or not (0.905–0.935 against 0.958–0.984 above), which is refine's own cost, open in #2341.

Not a property of the Zadoff-Chu sequence. The validator repeats the claims on two more preamble families of the same 127 × 16 geometry, each from the shipped waveform generator at its own design point: a repeated LFM chirp (−fs/4 to +fs/4 a period) and a PN m-sequence on dp_mls_poly(7). Capped (n = 1) against n = 5, 1000 trials a row:

preamble C/N0 capped capture (pd_burst) n = 5 capture (pd_burst) 3 dB under, engine: capped vs n = 5
LFM −14.00 dB 0.924, 0.906 – 0.939 (0.842) 0.915, 0.896 – 0.931 (0.917) 0.471 (0.440 – 0.502) vs 0.760 (0.733 – 0.785)
PN −12.75 dB 0.879, 0.857 – 0.898 (0.847) 0.925, 0.907 – 0.940 (0.911) 0.554 (0.523 – 0.585) vs 0.742 (0.714 – 0.768)

Both captures deliver their pd_burst within the interval. Compensation's depth beats the capped depth 3 dB under the design point, outside the intervals, at the engine and at the capture (LFM 0.362 against 0.603, PN 0.465 against 0.624). Each family's Pfa is held too.

Two things this does not say. The uncompensated row's Pfa was a separate finding, since fixed: the engine read 1.97e-3 a dwell against a target of 1e-3, because its gate was priced over the native cells while its maximum runs over the Doppler-interpolated surface (#2218, the mechanism in DSSS acquisition §9.1). Priced over the surface, it reads 0.95e-3. And the tiled grid is covered by the C tests (rate found, either sign, in a tile off the centre) but not by the Monte-Carlo above.

Hits carry the winning hypothesis as doppler_rate_hz_s (BurstAcquisition's last field): the detecting dwell's, so reliable only from a block wholly inside the preamble. A capture's events carry refine's instead (#2219): the hypothesis whose de-chirp made the whole preamble's coherent sum largest at the refined start, on refine's own grid, which is at least as dense as the engine's. It is the better estimate by construction, from the whole preamble rather than one dwell, and it measures so (validate_acq_rate_pd's event-rate rows, beside its rows for the hits). At 23 dB over the design point every captured event names the grid point nearest the truth: 100% at rate 0, at the span's edge, and off the grid, 0.4 of a step from a hypothesis. Under the same conditions the engine's hits name it 40% and 56% of the time (at 0 and the edge), and only the full-block ones, wholly inside the preamble, reach 100%: the event gets there whichever dwell anchored the burst. Nearer the design point the off-grid case is the one to read. There the event is the nearest in 82% and 89% of events on the grid (all the hits manage 15% and 52%, the full-block hits 18% and 60%), but in only 51% off it: a rate 0.4 of a step from one hypothesis is 0.6 from the next, and at that signal level noise picks the neighbour often. 3 dB over, 94% and 96% on the grid and 65% off it. The queue, the blob (version 6) and the event record carry it together, and a blob of the earlier layout (version 5) is refused.

10. See also