…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
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_f18 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 (trait3.0.0→3.1.0).BPSK returns cancelled then uncancelled from ONE timing search and ONE
demodulate_iq;-RRCreturns one arm, since it does not crossfade.
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.
decode_through_arms/decode_variants.stage_demodulate_payloadhadeleven 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_variantsis split from the demodulation becausereceive_from_samples_with_fec_innerruns
update_afc_estimatebetween them; folding them would demodulate arm 1 at a different centrefrequency than arm 0.
Measured, each paired against a variant-0-only build on identical channels. Every fixture is
BPSK250 +
Rson synthetic channels.Rsframes atmoderate_f1@ 8 dB:+10/48 via
receive_with_fec_modeand +18/96 viaota_decode_burst. On 29 B frames (whichfree_rs_strengtheningupgrades to t = 32) the union matched arm 0 in 7 of 8moderate_f1cellsand 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_f1gain is untested.ota_decode_burstseeds, 288 AWGNpairs, 768 fading pairs. On the single-shot
receive_with_fec_modepath this is structural ratherthan measured: arm 0 is tried first on the same buffer.
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.
−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_dbrebuilds 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, viaOtaRateDecision.commits no correction; in the onset scan it commits the fine estimate at
afc_step = 0.1, so thecorrection 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_f1and a 0.01 Hz fade chosen because it measurably swings burst toburst (9/84 consecutive drops > 6 dB, against 0/84 for
moderate_f1), 768 pairs: zero lost. Oneunion-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_acquisitionsplit: 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) andunion_second_arm_wiring(new; thesecond arm is reached and wins on a fade, and is never credited on a clean channel).
gpu_cpu_equivalencenow guards the ceiling side of the cliff, not only the floor.alternate_arm_decodesreworded as wiring evidence: it read 26 where only 18 frames needed arm 1.engine_cancellation_abrelabelled: 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.mdsaid2.0.0for seven weeks after the constant became3.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