Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,9 @@ Each requirement below is done when the linked test passes. Add new links as tes
| Winlink header fields are capped, and a realistic multi-recipient message still decodes | `cargo test -p openpulse-b2f --no-default-features -- header_decode_caps header_decode_allows_a_realistic` |
| B2F driver survives a hostile peer — line cap, per-operation read deadlines, framing edges | `cargo test -p openpulse-b2f-driver --no-default-features --test cmd_hardening` + `--test timeout_hardening` + `--test data_framing` |
| B2F driver reports a refused or fully-rejected ISS transfer instead of silent success | `cargo test -p openpulse-b2f-driver --no-default-features --test iss_failure_paths` |
| The daemon decodes a station **±50 Hz** off frequency (REQ-PHY-03 at its bound), and the acquisition pass recovers one **beyond native reach** (100 Hz, where neither decision arm decodes unaided). Split in #1428: the union's uncancelled arm decodes this fixture at 50 Hz with no acquisition, so a settle guard at 50 Hz measured fixture luck. Sabotage-verified: breaking the settle's estimate fails the 100 Hz test and leaves the 50 Hz one green — which is why the 50 Hz test alone could not guard the pass. Native decode at a given offset is frame-dependent (a different frame failed both arms at 25 Hz); the pass is what REQ-PHY-03 rests on | `cargo test -p openpulse-modem --no-default-features --test daemon_frequency_acquisition` |
| Every hard-decode chain tries **every decision arm a mode offers** and keeps the first its FEC accepts (#1428 union), every plugin's `demodulate_variants` obeys the contract (variant 0 is `demodulate`; declared arms differ at some rung of a fixed 0 … −24 dB ladder; a single-arm plugin's soft path agrees with its hard path under noise), and the second arm is **reached and wins** on a fade but is never credited on a clean channel. The wiring test proves the arm is reached, NOT the gain — the gain (+18/96 via `ota_decode_burst`, zero frames lost) needs a variant-0-only build and is recorded in the ledger | `cargo test -p openpulse-modem --no-default-features --test hard_variant_conformance --test union_second_arm_wiring` + `cargo test -p bpsk-plugin --no-default-features --lib -- variant second_arm symbol_stream_feeds` |
| The GPU BPSK demodulator agrees with the CPU one **where the crossfade cancellation decides the frame** (#1433), with a guard on BOTH sides of the cliff so the sweep cannot drift into a saturated cell. **Manual tier — nothing runs this automatically**: it needs `--features gpu` and a working adapter, the gate is `--no-default-features`, and CI's `gpu` job was removed in #1380. That absence is why #1433 shipped for 71 days | `cargo test -p bpsk-plugin --features gpu --test gpu_cpu_equivalence` (on a host with an adapter) |
| CI gates are defined and correct (Linux core/full/pi5 + `macos-build`; the `gpu` job was removed in #1380, subsumed by `gate.sh`'s `--all-features` pass) — **but the `CI` workflow is `disabled_manually` by the maintainer, so they do NOT run on a PR; the gates above are run locally before every merge** | `.github/workflows/ci.yml` `on: pull_request` (definition only; check state with `gh api repos/dc0sk/OpenPulseHF/actions/workflows`) |

For any new Phase 1 feature: write the test first, confirm it fails, implement until it passes. Do not mark a task done if its test does not exist.
Expand Down
29 changes: 28 additions & 1 deletion crates/openpulse-core/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use crate::error::{ModemError, PluginError};
/// would corroborate settles on pure noise — the exact defect the check exists to prevent, and one
/// that produces no error, only a receiver that stops acquiring. A single method makes publishing a
/// template without its own measured constants unrepresentable.
pub const PLUGIN_TRAIT_VERSION: &str = "3.0.0";
pub const PLUGIN_TRAIT_VERSION: &str = "3.1.0";

// ── Plugin metadata ───────────────────────────────────────────────────────────

Expand Down Expand Up @@ -309,6 +309,33 @@ pub trait ModulationPlugin: Send + Sync {
Ok(llrs)
}

/// Every hard-decision wire this mode can produce from ONE acquisition, best-first.
///
/// The engine tries each in order and keeps the first whose FEC + frame decode succeeds, so a
/// plugin whose demodulator has two defensible decision rules can offer both and let RS, the
/// length prefix and CRC-16 adjudicate — rather than the engine guessing with a predicate.
///
/// **`variants[0]` MUST equal [`demodulate`](Self::demodulate) byte-for-byte.** The rest of the
/// trait contract hangs off `demodulate`, and the default body below preserves that by
/// construction. `every_plugin_obeys_the_hard_variant_contract` (`openpulse-modem/tests/hard_variant_conformance.rs`)
/// sweeps the registry for it.
///
/// Return ONE variant unless a second is a genuinely different decode. A duplicate costs a
/// wasted FEC trial per onset in the scanning receive and can be miscounted as a second arm
/// contributing. Note the arms of a given mode may be identical on a clean channel and differ
/// only under noise — that is expected, and it is the noisy case the sweep checks.
///
/// Motivating case (#1428): BPSK's crossfade-ISI cancellation wins AWGN decisively and loses on
/// `moderate_f1`, measured end-to-end with real RS. Neither arm dominates. Their union was never
/// below the better arm in any measured cell, and above both on the two `moderate_f1` cells.
fn demodulate_variants(
&self,
samples: &[f32],
config: &ModulationConfig,
) -> Result<Vec<Vec<u8>>, ModemError> {
Ok(vec![self.demodulate(samples, config)?])
}

/// Frame geometry for `config.mode`, used by the receive engine to size
/// its scan step, energy-gate window, and demodulation slices.
///
Expand Down
Loading
Loading