Skip to content

fix(modem): the FSK4-ACK scan reached 2 s of a 9 s window, and restarted every tick (#1247) - #1317

Merged
dc0sk merged 1 commit into
mainfrom
fix/1247-ack-scan-cap
Sep 8, 2026
Merged

fix(modem): the FSK4-ACK scan reached 2 s of a 9 s window, and restarted every tick (#1247)#1317
dc0sk merged 1 commit into
mainfrom
fix/1247-ack-scan-cap

Conversation

@dc0sk

@dc0sk dc0sk commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Closes #1247.

What was wrong

decode_fsk4_ack_in_stream capped its trial-decode onset scan at FSK4_ACK_SEARCH_SAMPLES = 16_0002 s at 8 kHz — inside a listen window the same function sizes at 4 s, or 9 s when the profile carries the MFSK16 sub-floor rung, which hpx_hf does.

Nothing else could rescue a later ACK: the whole-buffer decode cannot carry a noisy lead (that is why the scan exists), a normal-rung ACK is transmitted as FSK4 rather than K=3, and a retry opens a fresh buffer instead of accumulating.

Measuring first inverted the fix

The issue proposes deriving the cap from the listen window. That is the wrong fix, and the measurement is why: the caller re-invokes the scan on the whole accumulated buffer once per frame of growth (next_fsk4_at), so the cap bounds a redundancy, not a single pass. Raising it multiplies an already-quadratic cost on the thread that must keep draining the capture stream.

Scanning incrementally — resuming past offsets already tried — covers the entire window for less total work, and the constant disappears rather than being re-tuned. There is no cap left to justify.

An outcome-level defect the issue does not mention

The scan returns the first CRC-valid offset. When that ACK failed the caller's session check, the old scan restarted from zero on the next tick and found the same foreign ACK again — every tick, deterministically, for the whole window. A real ACK behind it was never reached.

That is a live lock, not a probabilistic miss. It is reachable only in the session-hash mode; with an E7 MAC key a foreign ACK fails the MAC inside the trial decode and never returns at all.

Why resuming is equivalent, and why that is pinned

A resumed scan matches a rescan only if a trial decode gives the same answer later in the listen. fsk4_ack_at takes &self and mutates nothing — but its demod config carries afc_correction_hz, and the per-chunk decode_fsk4_ack feeds update_afc_estimate on every chunk. The correction stays put only because Fsk4Plugin does not override estimate_afc_hz and inherits the trait's None.

That is an inherited property, not a designed one. Give FSK4-ACK an AFC estimator and resumed and from-scratch scans diverge silently. So it is pinned by a test, following #1177's precedent of pinning the whitening property its gateless scan depends on.

Two defects sabotage found in this work that the passing tests did not

The retry loop hung rather than failed. Sabotaging the resume made the listen spin forever: the inner loop assumed the resume offset always advances. It does, but the loop now enforces it instead of assuming it.

The first fixture was vacuous. With the scan fully sabotaged, all three tests still passed. A FSK4 ACK is almost exactly one chunk long, so chunk-aligned ACKs are decoded by the per-chunk whole-buffer path and the scan never runs. Every offset is now deliberately off the chunk grid, and the reason is written into the fixture.

A third pass tightened the starvation fixture to sit inside the old cap, so it isolates the resume defect. Before that it failed under both sabotages and the two mechanisms were indistinguishable.

Tests

  • ack_scan_tests — the offset planner is a pure function, so never-repeat and never-skip are gated directly rather than through an instrument, over uneven growth including sub-step growth and off-grid lengths. (ScanPlanner already does this for the data path; prior art I had missed.) Plus the AFC pin.
  • fsk4_ack_scan_reaches_the_whole_window.rs — cap removal, an early-ACK control, and the foreign-ACK starvation, all through the production entry with the capture delivered in chunks via push_frame. The unpaced loopback returns a whole capture in one read, which is why every pre-existing ACK test could not see either defect.

Sabotage-verified with clean attribution: restarting from zero fails only the starvation gate; reinstating the cap fails only the cap gate.

Cost — not the justification, and not claimed as one

One pass is ~0.1 s; today's rescan measured 0.5–1.2 s over a chunked 9 s listen, with a 2× spread I cannot attribute. On hpx_hf the K=3 MFSK16 arm dominates the listen at ~2.5 s regardless, so "cheaper than today" is true of the FSK4 arm in isolation and false of the listen. The only cost claim being made is that raising the cap would have made an already-quadratic path worse, which needs no absolute number.

Consumer

server.rs:1912 is the only production caller of receive_ota_ack_within. My design artifact also claimed the CLI ARQ path reaches it; it does not — that goes through transmit_arqreceive_ack_with_short_fec, which is #1315.

Prior art

#1177 removed this scan's energy gate and pins the whitening property that makes a gateless scan safe — the same pinning idiom used here for the AFC property. ScanPlanner (engine.rs:77) is the never-retry-a-start-position precedent. The K=3 throttle comment already reasoned about O(n²)-over-the-window for the sibling path; the FSK4 side got a constant instead.

Twins

decode_mfsk16_k3_ack has no cap — it walks the whole buffer — so it has no unreachable region and this is not wider on that axis. The real twin is #1315: receive_ack_with_short_fec (ARDOP production, and the CLI's transmit_arq) has no in-stream acquisition at all, which is #1177's shape unfixed, compounded by stage_capture_input reopening the stream per read. Different function, different frame type, own callers — filed rather than folded in.

Implements: REQ-FUN-09

🤖 Generated with Claude Code

https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6

…ted every tick (#1247)

`decode_fsk4_ack_in_stream` capped its trial-decode onset scan at 16 000 samples
— 2 s at 8 kHz — inside a listen window the same function sizes at 4 s, or 9 s
with the MFSK16 sub-floor rung that `hpx_hf` carries. Nothing else could rescue a
later ACK: the whole-buffer decode cannot carry a noisy lead, a normal-rung ACK
is sent as FSK4 rather than K=3, and a retry opens a fresh buffer.

Measuring first inverted the fix. The issue proposes deriving the cap from the
window; the caller re-invokes the scan on the WHOLE accumulated buffer once per
frame of growth, so the cap bounds a redundancy rather than a single pass, and
raising it multiplies an already-quadratic cost on the thread draining the
capture. The scan is resumable now, so it covers the whole window for less total
work and the constant is gone rather than re-tuned.

That also fixes an outcome-level defect the issue does not mention: the scan
returns the first CRC-valid offset, and when that ACK failed the caller's session
check the old scan restarted from zero next tick and found the SAME foreign ACK
again — deterministically, for the whole window. A live lock, not a miss.

Resuming is equivalent only because a trial decode gives the same answer later,
which holds because FSK4-ACK inherits the trait's `None` for `estimate_afc_hz`
and so the AFC correction cannot move mid-listen. That is inherited, not
designed, so it is pinned by a test rather than assumed — following #1177, which
pins the whitening property its gateless scan depends on.

The offset bookkeeping is a pure function, so never-repeat and never-skip are
gated directly instead of through an instrument. `ScanPlanner` already does this
for the data path.

Sabotaging found two defects in this work that the passing tests did not: the
retry loop hung rather than failed when the resume did not advance, and the first
fixture was vacuous because a chunk-aligned ACK is decoded by the per-chunk path
without the scan running at all.

The performance argument is not the justification and is not claimed as one; see
the ledger for the measured spread.

Implements: REQ-FUN-09

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6
@dc0sk
dc0sk merged commit 0a81099 into main Sep 8, 2026
8 of 9 checks passed
@dc0sk
dc0sk deleted the fix/1247-ack-scan-cap branch September 8, 2026 20:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FSK4-ACK onset scan covers 2 s of a 9 s listen window — an ACK past the cap is unreachable by every decoder in the path

1 participant