Skip to content

test(bpsk): pin BPSK's early timing lock (#1438); refute #1435; correct #1437's OTA gain - #1439

Merged
dc0sk merged 1 commit into
mainfrom
investigate/1435-snr-decisions
Sep 24, 2026
Merged

dc0sk merged 1 commit into
mainfrom
investigate/1435-snr-decisions

Conversation

@dc0sk

@dc0sk dc0sk commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Change. Test-only, plus this correction. plugins/bpsk/src/demodulate.rs gains module
snr_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_f1 even 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 peaks
before 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_burst on a
clean 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's
BPSK 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 unknown
direction: 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

#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
dc0sk merged commit ad7d768 into main Sep 24, 2026
11 checks passed
@dc0sk
dc0sk deleted the investigate/1435-snr-decisions branch September 24, 2026 18:53
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>
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.

1 participant