test(bpsk): pin BPSK's early timing lock (#1438); refute #1435; correct #1437's OTA gain - #1439
Merged
Merged
Conversation
#1437's OTA gain Test-only, plus a ledger correction. #1435 attributed a -2.97 dB median SNR gap to decision-directed estimation. On a fixed span, decisions cost <= 0.6 dB on moderate_f1 even for the worst frames (measured at the symbol boundary). Re-measuring #1435's fixture on the same channel realisation with a fresh engine, changing only where the decoded span starts (4000 vs 4032), reproduced 7 of its 8 paired differences to within 0.02 dB; the eighth was read at a phase-2 AFC correction. The cause is #1438, two defects in find_timing_offset_with_expected: - Its objective peaks before the symbol boundary: demodulate_iq's half-Hann window is not matched to the modulator's full-Hann pulse (computed from the two window definitions: gain 1.190 at d = -9 vs 1.000 at 0). Measured, it locks at d = -8 wherever reachable -- lead 16 locks at 8 though 16 is in range. - It scans only 0..n, so for leads >= n the boundary is never visited: lead 32 locks at 24. At lead 0 neither can show, which is why every buffer-is-the-frame test missed it. At the early lock the SNR estimate is capped: 3.05 / 3.50 / 3.55 / 3.55 dB at a true 10 / 20 / 30 / 40 dB. Through ota_decode_burst on a clean 10 dB channel it read 3.0 dB at best, identically without the union -- it predates #1428. Added to plugins/bpsk/src/demodulate.rs, module snr_decision_discriminator: - the_timing_search_locks_early_when_a_lead_makes_it_reachable, a default-run characterisation pin asserting the measured lock at lead 16 (objective) and lead 32 (range). Expected to fail when either is fixed, naming which; both assertions watched failing under sabotage. - two #[ignore]d measurements: decisions on a fixed span, and a lead-in sweep. Correction to the 2026-09-23 #1428 ledger entry: "+18/96 via ota_decode_burst" is confounded in an unknown direction -- 17 of the 18 union-only frames were decoded from a span one symbol before the frame. "+10/48 via receive_with_fec_mode" (lead 0) stands. Verification-objective: a timing search validated only at lead 0 cannot show a bias that needs a lead to reach; pin the lock where the boundary is and is not reachable Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6
dc0sk
added a commit
that referenced
this pull request
Sep 25, 2026
…lly gets (#1438 PR1) (#1447) * fix(bpsk): estimate SNR on the uncancelled stream with an ISI- and CFO-aware fit (#1438) BPSK's timing search locks a quarter symbol early whenever the frame starts that far into the slice, and the SNR estimator read the crossfade-cancelled stream, correct only on the exact boundary: at the production lock it read a near-constant (slope 0.09 dB/dB), so ClimbOnSnr could not fire on hpx_hf SL2-SL5 on air. estimate_snr_db now reads the uncancelled stream (the arm best sampled at that lock), removes one frame-global residual carrier frequency (the AFC discards sub-2 Hz corrections by design), and fits each 8-symbol window with the two neighbour taps (openpulse_dsp::constellation:: {remove_residual_frequency, isi_aware_snr_db_windowed}); MATCHED_FILTER_LOSS_DB 7.1 -> 4.4. The timing search is unchanged (reachability is PR2). Gates: DSP primitives, the inverted stream pin, forced-phase AWGN tracking over 4 rungs x 5 phases x 0/1/2 Hz, the BPSK250 fade slope, the one-decision-error and flip-heavy frequency pins, and a controller harness with the frame placed off the symbol grid; each watched failing under its own sabotage. The #1439 pin and ledger entry are relabelled; review-1435 gets a scoping banner; additive_snr_db_windowed is recorded DORMANT. Chain and results: docs/dev/project/traceability.md, 2026-09-25. Review: docs/dev/reviews/review-1438-snr-estimator.md. Implements: REQ-FUN-06 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6 * fix(linksim): pass no SNR on a failed frame, as the daemon does (#1438) The daemon's only feed of the OTA rate controller (ota_decode_and_ack_inner) passes None on every failed decode since #1142: the estimate is taken only on a decoded span. The link simulator, whose comment says it mirrors the real software, still passed the whole-buffer reading on a failure, so its FastDownshift fired where the daemon's cannot. #1438's livelier estimate exposed it: psk_ladder_climbs_off_the_entry_rung_on_a_fade ended at avg_level 2.8 / SL2. A decision trace shows the downshifts fired on SL6 (QPSK250-D) failures using the QPSK estimator's readings, and in an SL1<->SL2 loop where every BPSK31 frame after an MFSK16 frame failed (19/19, unexplained, filed). With the failure-path reading removed the test passes and the linksim suite is 19/19. The FastDownshift branch in ota_rate.rs gets a note: it has no on-air consumer. Verification-objective: the link simulator feeds the rate controller what the daemon feeds it Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6 * docs(ledger): #1438 PR1 — the linksim alignment, and BPSK31/63 fade readings measured Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6 --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Change. Test-only, plus this correction.
plugins/bpsk/src/demodulate.rsgains modulesnr_decision_discriminator: two#[ignore]d measurements (decisions on a fixed span; a lead-in sweep)and one default-run characterisation pin,
the_timing_search_locks_early_when_a_lead_makes_it_reachable,which asserts the measured early lock at lead 16 (objective defect) and lead 32 (range defect) and is
expected to fail when either is fixed. Sabotage-verified.
#1435 refuted. On a fixed span, decisions cost ≤ 0.6 dB on
moderate_f1even for the worst frames(taken at the symbol boundary). #1435's −2.97 dB median came from where the decoded span started:
re-measuring on the same channel realisation with a fresh engine, changing only the span start (4000
against 4032), reproduced 7 of its 8 paired differences to within 0.02 dB; the eighth was read at a
phase-2 AFC correction.
The defect (#1438), two in one function.
find_timing_offset_with_expected's objective peaksbefore the symbol boundary (computed from the two window definitions: 1.190 at d = −9 against 1.000 at
0), and measured it locks at d = −8 wherever that is reachable; and it scans only
0..n, so for leads≥ n the boundary is never visited. At lead 0 neither shows. The SNR estimate at the early lock is
capped: 3.05 / 3.50 / 3.55 / 3.55 dB at a true 10 / 20 / 30 / 40 dB. Through
ota_decode_burston aclean 10 dB channel it read 3.0 dB at best, identically in a variant-0-only build, except at j = 20, where only the union decodes
from the earlier 4000 span (−6.0 dB against the variant-0 build's 3.0 dB from 4032) — it predates the union. BPSK's fast SNR climb cannot fire on the OTA path:
hpx_hf'sBPSK ceilings are 6.0–9.0 dB.
Correction to the 2026-09-23 #1428 entry. "+18/96 via
ota_decode_burst" is confounded, in an unknowndirection: 17 of the 18 union-only frames were decoded from a span one symbol before the frame, where
the lock is 8 samples early; whether they decode at the boundary is unmeasured. "+10/48 via
receive_with_fec_mode" (lead 0) stands.Tests → results.
cargo test -p bpsk-plugin --no-default-features --lib snr_decision_discriminator:1 passed, 2 ignored. Both pin assertions were watched failing under sabotage, each naming its defect.
Workspace gate:
GATE: PASS ee96ebba clean, suites=342 tests_passed=2588 tests_failed=0. The two held-out acceptance suites were not run.Refs #1435, #1437, #1438.
Review: docs/dev/reviews/review-1435-timing.md
Verification-objective: a timing search validated only at lead 0 cannot show a bias that needs a lead to reach; pin the lock where the boundary is and is not reachable
🤖 Generated with Claude Code
https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6