Skip to content

feat(bpsk,modem): decode both crossfade arms and let the FEC choose (#1428) - #1437

Merged
dc0sk merged 1 commit into
mainfrom
feat/1428-union-seam
Sep 23, 2026
Merged

dc0sk merged 1 commit into
mainfrom
feat/1428-union-seam

Conversation

@dc0sk

@dc0sk dc0sk commented Sep 23, 2026

Copy link
Copy Markdown
Owner

Requirement/change. #1363 / #1428: BPSK's crossfade-ISI cancellation wins AWGN decisively and
loses on moderate_f1 (harm localised to delayed-dominant dips, 2026-09-22 entry). In #1428 step 1
(PR #1432 — soft-uncancelled against hard-cancelled, union computed from the discordant pairs) that
was 96/96 against 12/96 at −2 dB AWGN and 38/96 against 49/96 at moderate_f1 8 dB, union 52/96.
Neither arm dominates. The union demodulates both from one acquisition and keeps the first that RS,
the 4-byte length prefix and CRC-16 accept — no predicate, where the per-symbol gate it replaces
needed a fitted threshold.

Design, reviewed before the code it covered; the reviews are recorded in
docs/dev/reviews/review-1428-union.md:

  • ModulationPlugin::demodulate_variants, additive with a default body (trait 3.0.0 → 3.1.0).
    BPSK returns cancelled then uncancelled from ONE timing search and ONE demodulate_iq; -RRC
    returns one arm, since it does not crossfade.
  • BPSK's override carries its own GPU branch. The trait default would have returned ONE variant on
    the GPU daemon while CPU tests saw two — GPU BPSK demod never applies cancel_crossfade_isi — the shipped daemon takes the arm that decodes 12/96 at −2 dB #1433's shape, one method over.
  • One hard-decode seam, decode_through_arms / decode_variants. stage_demodulate_payload had
    eleven callers; the six FEC-protected chains now go through the seam, and the five call sites that
    remain carry four stated reasons at the function. The decode closure takes &[u8], not
    &mut Self, so a losing arm cannot move AFC, HARQ retention, the rate controller or the SNR record.
  • decode_variants is split from the demodulation because receive_from_samples_with_fec_inner
    runs update_afc_estimate between them; folding them would demodulate arm 1 at a different centre
    frequency than arm 0.

Measured, each paired against a variant-0-only build on identical channels. Every fixture is
BPSK250 + Rs on synthetic channels.

  • Gain, and where it was and was not shown. On 200 B plain-Rs frames at moderate_f1 @ 8 dB:
    +10/48 via receive_with_fec_mode and +18/96 via ota_decode_burst. On 29 B frames (which
    free_rs_strengthening upgrades to t = 32) the union matched arm 0 in 7 of 8 moderate_f1 cells
    and was +1 in the eighth, and gained +17/384 on a 0.01 Hz fade. The 29 B sweep also set noise from
    the unfaded frame's RMS in pure AWGN, where the 200 B runs embedded the frame in recorded idle —
    so the two are not one comparison, and which difference removed the moderate_f1 gain is untested.
  • Frames lost to the union: zero in every paired run — 96 ota_decode_burst seeds, 288 AWGN
    pairs, 768 fading pairs. On the single-shot receive_with_fec_mode path this is structural rather
    than measured: arm 0 is tried first on the same buffer.
  • Cost: per-burst ratio 1.017, 95 % CI [0.93, 1.10], nine within-round pairs. Arm 1 ran on ~126
    attempts per burst — this fixture's onset-scan geometry, not a property of the union. By structure
    the second arm is a few per cent of an attempt; the interval is consistent with that and cannot
    resolve it.
  • SNR on frames credited to arm 1 reads low: paired on the 8 seeds both builds decode, median
    −2.97 dB (−0.70 to −6.88), negative on all 8; one frame only the union decodes read −19.3 dB.
    Probable cause, unmeasured (the discriminating test is in the follow-up issue): estimate_snr_db
    rebuilds symbols from the CANCELLED arm's decisions, and a wrong decision leaves the window holding
    it largely booked as noise. Not new, but exercised more often. On the ladder, measured: a decoded
    frame is never answered with a demotion (a_decoded_frame_is_never_answered_with_a_demotion, fed
    −20 dB). By code read: the evidence climb does not read the SNR, and the hard arm records no SNR,
    so last_rx_snr_db() (QSY scan, ADIF) never sees it. It does reach operators, via
    OtaRateDecision.
  • AFC: at 50 Hz the uncancelled arm can decode a burst before the settle runs. At offset 0 that
    commits no correction; in the onset scan it commits the fine estimate at afc_step = 0.1, so the
    correction converges ~10 % per burst instead of in one settle (measured 5.0 → 23.5 Hz over six
    transmissions). AWGN, −4 to +12 dB, 288 pairs: zero lost; on the first burst the skip occurs only
    from +8 dB. Fading, moderate_f1 and a 0.01 Hz fade chosen because it measurably swings burst to
    burst (9/84 consecutive drops > 6 dB, against 0/84 for moderate_f1), 768 pairs: zero lost. One
    union-specific excursion: an arm-1 win moved a correct 51.0 Hz to 63.4 Hz on a transmission the
    variant-0-only build failed to decode.

Gates.

  • daemon_frequency_acquisition split: 50 Hz asserts the decode only (REQ-PHY-03 at its bound);
    100 Hz — where neither arm decodes unaided — asserts the decode AND that the acquisition pass ran.
    Sabotage: destroying the settle's estimate fails the 100 Hz test and leaves the 50 Hz one green.
  • hard_variant_conformance (new, 67 modes, 9 plugins) and union_second_arm_wiring (new; the
    second arm is reached and wins on a fade, and is never credited on a clean channel).
  • gpu_cpu_equivalence now guards the ceiling side of the cliff, not only the floor.
  • alternate_arm_decodes reworded as wiring evidence: it read 26 where only 18 frames needed arm 1.
  • engine_cancellation_ab relabelled: its second column is now the union, not the cancelled arm.

Corrections recorded in this change. "Four months" was 71 days — two sites in the tree, plus
the #1433 body and PR #1434's description (the merged commit message gives dates, no duration). The
acquisition test's header claimed acquisition was needed "only past ~200 Hz", which is false.
plugin-trait-versioning.md said 2.0.0 for seven weeks after the constant became 3.0.0.

Follow-ups filed: #1435 (the SNR-estimator mechanism) and #1436 (the pre-settle AFC behaviour).

Tests → results. Workspace gate: GATE: PASS ae9e9088 clean, suites=342 tests_passed=2587 tests_failed=0. The two held-out acceptance suites (notch_rescues_interferer, ota_channel_adaptation) were not run.

Refs #1428, #1363, #1433.

Review: docs/dev/reviews/review-1428-union.md

Implements: REQ-FUN-02, REQ-FUN-06
Verification-objective: an agreement sweep reaches the regime where agreement can fail, and a gate asserts only what its fixture can distinguish

🤖 Generated with Claude Code

https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6

…1428)

BPSK's crossfade-ISI cancellation wins AWGN decisively and loses on moderate_f1. Neither
arm dominates. The union demodulates both from ONE acquisition and keeps the first that
RS, the 4-byte length prefix and CRC-16 accept: no predicate, where the per-symbol gate it
replaces needed a fitted threshold.

Design (reviewed before the code; docs/dev/reviews/review-1428-union.md):
- ModulationPlugin::demodulate_variants, additive with a default body; trait 3.0.0 ->
  3.1.0. BPSK returns cancelled then uncancelled from one timing search and one
  demodulate_iq; -RRC returns one arm, since it does not crossfade.
- BPSK's override carries its own GPU branch: the default would have returned ONE variant
  on the GPU daemon while CPU tests saw two -- #1433's shape, one method over.
- One hard-decode seam: the six FEC-protected chains go through decode_through_arms /
  decode_variants; five single-arm call sites remain, for four stated reasons. The decode
  closure takes &[u8], not &mut Self, so a losing arm cannot move engine state.
- Demodulation split from decode where update_afc_estimate sits between them, so every
  arm demodulates at one centre frequency.

Measured, paired against a variant-0-only build on identical channels (BPSK250 + Rs,
synthetic channels):
- Gain on 200 B plain-Rs frames at moderate_f1 8 dB: +10/48 via receive_with_fec_mode,
  +18/96 via ota_decode_burst. On 29 B frames (free RsStrong) it matched arm 0 in 7 of 8
  moderate_f1 cells and gained +17/384 on a 0.01 Hz fade.
- Frames lost to the union: zero in every paired run (96, 288 and 768 pairs).
- Cost: per-burst ratio 1.017, 95% CI [0.93, 1.10].
- SNR on frames credited to arm 1 reads low (paired median -2.97 dB); cannot move the
  ladder, reaches operators. Filed separately.
- AFC: a 50 Hz station decoded before the settle converges ~10% per burst instead of in
  one step. Zero frames lost across AWGN and fading. Filed separately.

Gates:
- daemon_frequency_acquisition split: 50 Hz asserts the decode (REQ-PHY-03 at its bound);
  100 Hz, where neither arm decodes unaided, asserts the decode AND the acquisition pass.
  Sabotage: destroying the settle's estimate fails 100 Hz and leaves 50 Hz green.
- hard_variant_conformance (new; 67 modes, 9 plugins) and union_second_arm_wiring (new).
- gpu_cpu_equivalence guards the ceiling side of the cliff, not only the floor.
- alternate_arm_decodes documented as wiring evidence (it read 26; 18 needed arm 1).
- engine_cancellation_ab relabelled: its second column is now the union.

Corrections: "four months" was 71 days; the acquisition test's header claimed acquisition
was needed "only past ~200 Hz" (false); plugin-trait-versioning.md said 2.0.0 for seven
weeks after the constant became 3.0.0.

Implements: REQ-FUN-02, REQ-FUN-06
Verification-objective: an agreement sweep reaches the regime where agreement can fail,
and a gate asserts only what its fixture can distinguish

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6
@dc0sk

dc0sk commented Sep 24, 2026

Copy link
Copy Markdown
Owner Author

Correction: "+18/96 via ota_decode_burst" is confounded, in an unknown direction

This PR's description, the squash commit and the traceability ledger quote the union's gain on
moderate_f1 @ 8 dB as +10/48 via receive_with_fec_mode and +18/96 via ota_decode_burst,
as though the second confirmed the first through the production entry. It does not.

  • 17 of the 18 frames only the union decodes came from a span starting at 4000, one symbol before
    the frame at 4032. There, BPSK's timing search locks 8 samples early (BPSK's timing search locks 8 samples early whenever a lead makes that reachable — the SNR estimate is capped near 3.5 dB and BPSK's fast climb can never fire on the OTA path #1438), and the uncancelled
    arm sees different ISI than it does in the lead-0 fixture behind +10/48.
  • That is not evidence the early lock caused arm 0's failures on them. The scan tries 4000 before
    4032 and keeps the first success, so every frame arm 1 can decode lands at 4000 whether or not it
    would decode at 4032; and the variant-0 build tried those 18 at 4032 too and failed.
  • Whether arm 1 would decode them at the boundary is unmeasured, and it could go either way: by the
    gain table, arm 1 sees less crossfade ISI at d = −8 (0.09 of the next symbol) than at d = 0 (0.32).

So the OTA figure is not comparable to +10/48 until the lock is fixed and it is re-measured.
(+18/96 is 18.8 % against 20.8 % — a one-frame difference, not evidence of direction.) +10/48 stands: its fixture applies the
channel to the frame itself (lead 0), so the lock is on the boundary.

Separately, 8 of that run's 96 frames failed the cancelled arm at the early lock and decoded at the
boundary; with the union they are delivered from the early lock instead. They are not among the 18.

The merged commit message cannot be amended; the ledger correction is in PR #1439.

dc0sk added a commit that referenced this pull request Sep 24, 2026
#1437's OTA gain (#1439)

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


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