From 6e73a787fcba13f1d9cb3b4560350d2a50c20347 Mon Sep 17 00:00:00 2001
From: Catalyst Runner
Date: Tue, 8 Sep 2026 02:14:15 +0000
Subject: [PATCH 1/2] =?UTF-8?q?feat:=20MAN-145=20=E2=80=94=20A=20first-tim?=
=?UTF-8?q?e=20visitor's=20README=20should=20match=20what=20manta=20actual?=
=?UTF-8?q?ly=20does=20today?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
ARCHITECTURE.md | 17 +-
CLAUDE.md | 2 +-
README.md | 226 +++++++++++++--------
ROADMAP.md | 18 +-
crates/manta-cli/tests/docs_consistency.rs | 198 ++++++++++++++++++
wiki/pages/overview.md | 5 +-
6 files changed, 362 insertions(+), 104 deletions(-)
create mode 100644 crates/manta-cli/tests/docs_consistency.rs
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 22d23e24..66e63b39 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -50,25 +50,28 @@ allow).
manta/
├── Cargo.toml # workspace
├── crates/
-│ ├── manta-input # IQ sources: SoapySDR, KiwiSDR client, file, audio
+│ ├── manta-input # IQ sources: SoapySDR, HPSDR/Hermes, KiwiSDR client, file, audio
│ ├── manta-dsp # PFB channelizer, noise-floor estimation, envelope
│ ├── manta-decode # CW keying state machine, timing, Morse decode
│ ├── manta-spot # callsign validation, CQ/DE parse, dedupe, scoring
│ ├── manta-server # telnet cluster server + JSON/WebSocket stream
│ ├── manta-engine # orchestration: track lifecycle, decoder pool
│ ├── manta-testkit # synthetic CW generator, golden-IQ harness
-│ └── manta-cli # `manta` binary: daemon + subcommands
+│ ├── manta-cli # `manta` binary: daemon + subcommands
+│ └── manta-soak-harness # 24h soak measurement harness (ROADMAP M2 gate),
+│ # not shipped in the manta binary
```
Dependency graph (arrows = depends on):
```
manta-cli ──▶ manta-engine ──▶ manta-input ──▶ manta-dsp
- │ ├──▶ manta-dsp ──────▶ coppa-dsp
- │ ├──▶ manta-decode
- │ └──▶ manta-spot ──────▶ manta-decode
- └──▶ manta-server
+ │ │ ├──▶ manta-dsp ──────▶ coppa-dsp
+ │ │ ├──▶ manta-decode
+ │ │ └──▶ manta-spot ──────▶ manta-decode
+ └──────────────────▶ manta-server
manta-testkit ──▶ manta-dsp, manta-decode, coppa-channel
+manta-soak-harness ──▶ manta-dsp, manta-input, manta-engine, manta-testkit
```
M1 added `manta-input → manta-dsp` (the shared Hilbert transformer, used
@@ -318,7 +321,7 @@ validation (MAN-28). Dedupe (step 5) still applies.
own yet (decode-pipeline internals, not the network-facing surface
MAN-59 scoped to), and `manta --status` hitting a local control socket
for live stats is similarly not yet implemented. Prometheus text
- endpoint (feature `metrics`): input overruns, active tracks, evictions,
+ endpoint (compiled in unconditionally, no feature flag): input overruns, active tracks, evictions,
decode rate, spots/min, per-stage queue depths, spot confidence
histogram — also aspirational for several of these fields; the
currently-implemented subset is `manta_spots_total`,
diff --git a/CLAUDE.md b/CLAUDE.md
index a7661231..fe5f8093 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -25,7 +25,7 @@ reachable from this environment.
## Documents (read in this order)
- `README.md` — goals and non-goals
-- `ARCHITECTURE.md` — 8-crate workspace, data flow, channelizer/decoder/
+- `ARCHITECTURE.md` — 9-crate workspace, data flow, channelizer/decoder/
validation/output design
- `docs/SPEC-decode-core.md` — implementation-level algorithm spec: exact
channelizer constants, noise-floor estimator, track state machine, decoder
diff --git a/README.md b/README.md
index 286978ab..c5bcea1c 100644
--- a/README.md
+++ b/README.md
@@ -20,14 +20,22 @@
`manta` is a headless daemon written in Rust. It takes wideband IQ from a
-commodity SDR, a KiwiSDR over the network, or a WAV file, channelizes the
-whole passband with a polyphase filterbank, runs an independent CW decoder on
-every signal it finds, validates the callsigns, and emits spots. Output is the
-standard `DX de` cluster format over telnet plus a JSON Lines stream, so
-existing aggregators (including the Reverse Beacon Network) and modern
-consumers (such as [cqdx](https://cqdx.app)) can ingest it without changes.
-
-No GUI. CLI, a config file, and metrics.
+commodity SDR, an OpenHPSDR/Hermes device, a KiwiSDR over the network, or a
+WAV file, channelizes the whole passband with a polyphase filterbank, runs an
+independent CW decoder on every signal it finds, validates the callsigns, and
+emits spots. Output is the standard `DX de` cluster format over telnet plus a
+JSON Lines / WebSocket stream, so existing aggregators (including the Reverse
+Beacon Network) and modern consumers (such as [cqdx](https://cqdx.app)) can
+ingest it without changes.
+
+No GUI. CLI, a TOML config file, and Prometheus metrics.
+
+```console
+$ telnet manta.example.org 7300
+login: W1XYZ
+de W5AU-# >
+DX de W5AU-#: 14000.7 W1AW CW 7 dB 20 WPM CQ 1533Z
+```
## Why
@@ -40,81 +48,100 @@ with documented, testable algorithms.
## Installation
-No Rust toolchain, no cloning the source. Pick one:
-
-**Download a prebuilt binary** from the [Releases
-page](https://github.com/HagaleTechnologies/manta/releases) — macOS
-(Intel or Apple Silicon), Windows, or Linux (x86_64 or arm64, including
-Raspberry Pi OS 64-bit). Unpack the archive and run the `manta` binary
-inside it. **Linux binaries need `libasound2` installed** (audio input is
-an unconditional dependency, even if you only ever use file, KiwiSDR, or
-HPSDR input) — `sudo apt install libasound2` on Debian/Ubuntu/Raspberry Pi
-OS, or the equivalent ALSA runtime package on other distros; without it
-the binary fails to start.
+```sh
+cargo build --release -p manta-cli # Rust 1.85+
+```
-**Or run the Docker image** (works anywhere Docker does, `linux/amd64` and
-`linux/arm64`):
+No tagged release yet, so there is no prebuilt binary or Docker image to
+pull — build from source for now. Both publish automatically, for every
+platform, from the first tag:
```sh
+# once a release exists:
docker run --rm ghcr.io/hagaletechnologies/manta:latest --help
```
-When running as a long-lived server (not `--help`), stop it with
-`docker stop -t 30 ` — Docker's own default 10-second grace
-period before SIGKILL is shorter than manta's supported drain window for
-a slow client's final write (up to 25s), so the default can cut a
-graceful shutdown off mid-drain.
+**Notes:**
+- Linux binaries need `libasound2` installed (audio input is an
+ unconditional dependency, even if you only ever use file, KiwiSDR, or
+ HPSDR input) — `sudo apt install libasound2` on Debian/Ubuntu/Raspberry
+ Pi OS, or the equivalent ALSA runtime package elsewhere.
+- Stop a long-running container with `docker stop -t 30 ` —
+ Docker's default 10-second grace period is shorter than manta's drain
+ window for a slow client's final write (up to 25 s), so the default
+ can cut a graceful shutdown off mid-drain.
+- The `soapy` feature (RTL-SDR, Airspy, SDRplay, HackRF via SoapySDR)
+ needs the native SoapySDR system library and is not in the container
+ image; build from source with `--features soapy` to use it. `hpsdr`
+ (OpenHPSDR/Hermes) has no native dependency and is on by default in
+ the container image and every release binary.
+- Windows binaries need the [Visual C++
+ Redistributable](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist)
+ installed if it isn't already.
+
+## 60-second demo
+
+No SDR, no radio. Build, generate a synthetic golden vector, decode it:
-Both are built by [`.github/workflows/release-publish.yml`](.github/workflows/release-publish.yml)
-directly from each tagged release's commit — every published binary
-traces to a specific, auditable source revision. (If the image above
-returns an authorization error, the GHCR package needs its one-time
-"make public" step in GitHub's package settings after the first real
-release — see MAN-65. Windows binaries need the [Visual C++
-Redistributable](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist)
-installed if it isn't already.)
-
-**Building from source** (if you're developing manta itself, or need a
-platform/feature combination the release matrix doesn't cover — the
-`soapy` feature below, for instance, isn't in the official release
-binaries since it needs the SoapySDR system library) still works exactly
-as before, and is what the rest of this Quickstart assumes:
-
-## Quickstart
+```sh
+cargo build --release -p manta-cli # Rust 1.85+
+manta gen v1 --out /tmp/v1 # 120 s of synthetic CW, 20 WPM, +20 dB
+manta decode /tmp/v1/v1.wav
+# CQ DE W1AW W1AW K CQ CQ DE W1AW W1AW K CQ CQ DE W1AW W1AW K CQ …
+```
-Requires Rust 1.85 or newer.
+Then point it at a real signal — a public KiwiSDR needs no hardware of
+your own:
```sh
-# Build
-cargo build --release -p manta-cli
+manta listen --kiwi-host kiwi.example.org --kiwi-freq 7030000
+```
-# Decode a synthetic golden vector from a file (deterministic, no hardware)
-manta gen v1 --out /tmp/v1
-manta decode /tmp/v1/v1.wav
+File replay (`listen --source`) currently accepts only 48 kHz mono audio
+and runs faster than realtime, so the hardware-free path above stops at
+`decode`; a paced replay that can drive the servers below is being
+worked on.
-# Copy live CW from a public KiwiSDR on 40 m
-manta listen --kiwi-host kiwi.example.org --kiwi-freq 7030000
+`manta --help` lists every subcommand and flag.
-# Copy from a local SDR via SoapySDR (build with --features soapy)
-manta listen --soapy-driver driver=rtlsdr --soapy-freq 7030000 --soapy-rate 240000
+## Run it as a node
-# Copy from the default audio input (rig audio passband, 48 kHz)
-manta listen
+One flag starts the DX cluster telnet server, the JSON/WebSocket stream,
+and the metrics endpoint alongside the decoder:
-# Any of the above as JSON Lines instead of text
-manta listen --json --kiwi-host kiwi.example.org --kiwi-freq 7030000
+```toml
+# server.toml
+[server]
+station_callsign = "W5AU" # your call; becomes `DX de W5AU-#:` and JSON `deCall`
+bind_addr = "127.0.0.1" # 0.0.0.0 to accept remote clients
+telnet_port = 7300
+json_port = 7301
+metrics_port = 7302
```
-`manta --help` lists every subcommand and flag.
+```sh
+manta listen --kiwi-host kiwi.example.org --kiwi-freq 7030000 --server-config server.toml
+telnet localhost 7300 # DX de … lines
+nc localhost 7301 # one JSON object per spot
+curl -s localhost:7302/metrics # Prometheus text
+```
+
+Forwarding to an upstream RBN-style collector is a `[[rbn_uplink]]`
+block. **Set `dry_run = true` first** — the default is `false`, so an
+uplink block starts transmitting as soon as you add it.
+
+Before exposing any port beyond loopback, read
+[docs/RUNBOOKS/network-exposure.md](docs/RUNBOOKS/network-exposure.md).
## Inputs
| Source | How | Status |
| --- | --- | --- |
-| IQ / audio WAV file | `decode`, `listen --source` | Working |
+| IQ / audio WAV file | `decode`, `listen --source` | Working (`decode` takes IQ; `listen --source` takes 48 kHz mono audio) |
| Sound card (rig audio passband) | `listen --device` | Working, 48 kHz input only |
| KiwiSDR over the network | `listen --kiwi-host` | Working |
-| RTL-SDR, Airspy, SDRplay, HackRF, and anything else SoapySDR drives | `listen --soapy-driver`, feature `soapy` | Working, needs hardware soak |
+| OpenHPSDR / Hermes (Hermes-Lite 2, Red Pitaya, QMTech) | `listen --hpsdr-host`, feature `hpsdr` — in every official binary | Working; protocol verified against reference sources, not yet against hardware |
+| RTL-SDR, Airspy, SDRplay, HackRF, anything SoapySDR drives | `listen --soapy-driver`, feature `soapy` — **not** in official binaries, needs the SoapySDR system library | Working, needs hardware soak |
Targets Linux (x86-64 and ARM, Raspberry Pi 4 class), macOS, and Windows.
The CPU budget is a full 192 kS/s passband inside one Raspberry Pi 4 core,
@@ -122,10 +149,27 @@ enforced by criterion benches.
## Outputs
-- Decoded text or JSON Lines on stdout today.
-- RBN-format `DX de` spots over the DX cluster telnet protocol (port 7300)
- and a JSON Lines / WebSocket stream (port 7301): in progress, see
- [ROADMAP.md](ROADMAP.md) milestone M3.
+All four ship today; `manta listen --server-config ` starts the
+first three together.
+
+- **DX cluster telnet server** (`:7300`) — standard login prompt and
+ RBN-format `DX de` lines, with enough command grammar (`sh/dx`,
+ `set/dx/filter`) for stock clients. This is the RBN/aggregator
+ compatibility surface.
+- **JSON Lines / WebSocket stream** (`:7301`) — one full-fidelity spot
+ object per line; a raw TCP client and a WebSocket client share the
+ port. This is the [cqdx](https://cqdx.app) ingest surface.
+- **Outbound RBN uplink** — forwards validated spots to an upstream
+ collector in the same `DX de` wire format, with reconnect and multiple
+ simultaneous targets. Working against a collector; **not yet validated
+ against RBN's live ingest**.
+- **Prometheus metrics** (`:7302/metrics`) — spot, client, uplink and
+ source-health counters. Some gauges are still placeholders;
+ ARCHITECTURE §8 says which.
+
+`decode` and `listen` also print decoded text or `--json` events on
+stdout. That output is a debugging aid, not a stable interface — the
+servers above are.
The decode path is deterministic: the same file in produces byte-identical
spot logs out. That is a hard requirement, and CI enforces it with golden
@@ -133,21 +177,22 @@ test vectors.
## Status
-Pre-1.0. What exists and what does not:
-
-- **Done:** single-signal decode from files and live audio (M1); the full
- wideband pipeline of polyphase channelizer, detector, track manager, and
- decoder pool, with SoapySDR and KiwiSDR inputs (M2 sub-projects); callsign
- validation, CQ/DE parsing, cty.dat and SCP cross-checks, dedupe, wired into
- the engine (M3, in part).
-- **Open acceptance gates:** the Raspberry Pi 4 CPU-budget measurement and a
- 24 h live-SDR soak both need physical hardware.
-- **Next:** the telnet and JSON spot servers, TOML config, metrics, and an RBN
- parity benchmark on recorded contest IQ.
-- **Known limits:** the classical decoder loses copy under heavy HF fading on
- a few golden vectors (issues #25 and #28). Closing that gap is the M4 ML
- fusion stage, gated on beating the classical baseline under simulated
- fading.
+Pre-1.0, and pre-first-release. What is true today:
+
+- **Shipped:** the full wideband pipeline — polyphase channelizer,
+ noise-floor detector, track manager, decoder pool — with five input
+ sources, callsign validation (cty.dat, SCP, CQ/DE parsing, dedupe), and
+ all four output surfaces above.
+- **Not yet measured:** the RBN parity benchmark (recall vs. RBN on
+ recorded contest IQ), the Raspberry Pi 4 CPU budget, and a 24-hour
+ live-SDR soak. The first needs reference data; the other two need
+ physical hardware.
+- **Known limits:** the classical decoder loses copy under heavy HF
+ fading on several golden vectors, and at low SNR the validator still
+ admits occasional bogus callsigns from noise. Closing the fading gap is
+ classical-DSP work in flight, with ML fusion behind it at M4.
+- **No tagged release yet**, so the container image above is empty until
+ the first tag.
[ROADMAP.md](ROADMAP.md) has the milestone breakdown with acceptance
criteria.
@@ -173,16 +218,21 @@ criteria.
## Documentation
-- [ARCHITECTURE.md](ARCHITECTURE.md): the seven-crate workspace, data flow,
- and the channelizer, decoder, validation, and output design.
-- [docs/SPEC-decode-core.md](docs/SPEC-decode-core.md): the
- implementation-level algorithm spec. Channelizer constants, noise-floor
- estimator, track state machine, decoder equations, confidence formulas,
- determinism rules, golden vectors, and the config-key table.
-- [ROADMAP.md](ROADMAP.md): milestones M0 to M4 with acceptance criteria.
-- [docs/DECISIONS/](docs/DECISIONS/): dated design decisions and
- implementation pins.
-- [wiki/INDEX.md](wiki/INDEX.md): accumulated gotchas and cross-references.
+**Running a node:** [docs/RUNBOOKS/network-exposure.md](docs/RUNBOOKS/network-exposure.md)
+(exposing the servers safely) · the "Run it as a node" section above ·
+`manta --help` for every flag.
+
+**Contributing:** [ARCHITECTURE.md](ARCHITECTURE.md) — the nine-crate
+workspace, data flow, and the channelizer, decoder, validation and output
+design · [docs/DECISIONS/](docs/DECISIONS/) — dated design decisions and
+implementation pins · [wiki/INDEX.md](wiki/INDEX.md) — accumulated
+gotchas.
+
+**Algorithms and research:** [docs/SPEC-decode-core.md](docs/SPEC-decode-core.md) —
+channelizer constants, noise-floor estimator, track state machine,
+decoder equations, confidence formulas, determinism rules, golden
+vectors, config-key table · [ROADMAP.md](ROADMAP.md) — milestones M0 to
+M4 with acceptance criteria.
## Related projects
diff --git a/ROADMAP.md b/ROADMAP.md
index b0c8e3c9..a609048a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -112,11 +112,18 @@ repetition gate, dedupe) is complete as a standalone crate -- see
-decode-core.md §7.1 (V11-V15). It is now wired into `manta-engine`'s
batch (`decode_samples`/`decode_wav`) and streaming (`listen`) pipelines,
both emitting real `Spot`s -- see
-`docs/superpowers/specs/2026-07-26-m3-engine-wiring-design.md`. Remaining
-M3 sub-projects: `manta-server` (telnet + JSON/WebSocket output, TOML
-config, metrics), and the RBN parity benchmark (needs ≥ 2 h of recorded
-contest-weekend IQ with RBN reference spots -- a data dependency not yet
-resolved).
+`docs/superpowers/specs/2026-07-26-m3-engine-wiring-design.md`.
+`manta-server` has also landed: the telnet cluster server, the
+JSON/WebSocket stream, TOML config, the metrics endpoint, and the
+outbound RBN uplink are all shipped and tested. Remaining M3 sub-projects:
+the RBN parity benchmark (needs ≥ 2 h of recorded contest-weekend IQ with
+RBN reference spots -- a data dependency not yet resolved) and the
+7-day unattended soak below.
+
+**RBN admission** is pre-1.0 work, not deferred: the near-term path is a
+Skimmer-Server-compatible handshake so existing aggregators can accept a
+manta node, with direct-uplink admission negotiated from a position of
+having live nodes.
## M4 — ML decoder stage (research-dependent)
@@ -135,6 +142,5 @@ confidence weighting. ONNX/candle inference, feature-gated.
- RTTY/FT4-adjacent modes on the same channelizer.
- Multi-SDR single-daemon orchestration.
-- Upstream conversation with RBN operators about accepting manta nodes.
- Spot quality feedback loop: cqdx-side confirmation (same call spotted by other
nodes) fed back to tune validation thresholds.
diff --git a/crates/manta-cli/tests/docs_consistency.rs b/crates/manta-cli/tests/docs_consistency.rs
new file mode 100644
index 00000000..eb159c02
--- /dev/null
+++ b/crates/manta-cli/tests/docs_consistency.rs
@@ -0,0 +1,198 @@
+//! The public docs must match the shipped binary. MAN-145.
+//!
+//! These assertions exist because README.md, ARCHITECTURE.md, CLAUDE.md and
+//! wiki/pages/overview.md independently drifted to three different crate
+//! counts and a two-milestone-stale description of the output layer. Each
+//! test below pins one claim a first-time visitor reads.
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+fn repo_root() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .canonicalize()
+ .expect("repo root")
+}
+
+fn doc(rel: &str) -> String {
+ let p = repo_root().join(rel);
+ fs::read_to_string(&p).unwrap_or_else(|e| panic!("reading {}: {e}", p.display()))
+}
+
+/// Text between a `## ` and the next `## ` heading.
+fn section<'a>(md: &'a str, heading: &str) -> &'a str {
+ let start = md
+ .find(&format!("\n## {heading}"))
+ .unwrap_or_else(|| panic!("no `## {heading}` section"));
+ let rest = &md[start + 1..];
+ let end = rest[3..].find("\n## ").map(|i| i + 4).unwrap_or(rest.len());
+ &rest[..end]
+}
+
+fn workspace_members() -> Vec {
+ let manifest: toml::Value = toml::from_str(&doc("Cargo.toml")).expect("parse Cargo.toml");
+ manifest["workspace"]["members"]
+ .as_array()
+ .expect("members array")
+ .iter()
+ .map(|m| {
+ m.as_str()
+ .expect("member string")
+ .rsplit('/')
+ .next()
+ .expect("crate name")
+ .to_string()
+ })
+ .collect()
+}
+
+// ---- Scenario 3: the crate count is accurate everywhere ----
+
+/// Every crate in the workspace must appear in ARCHITECTURE.md's tree.
+/// `manta-soak-harness` was missing for the whole of M2/M3.
+#[test]
+fn architecture_lists_every_workspace_crate() {
+ let arch = doc("ARCHITECTURE.md");
+ for krate in workspace_members() {
+ assert!(
+ arch.contains(&krate),
+ "ARCHITECTURE.md never mentions workspace member `{krate}`"
+ );
+ }
+}
+
+/// README, ARCHITECTURE, CLAUDE.md and the wiki overview must all state the
+/// same crate count, and it must be the real one. Guard-listing the stale
+/// spellings keeps the failure message actionable when a crate is added.
+#[test]
+fn every_doc_states_the_real_crate_count() {
+ let n = workspace_members().len();
+ assert_eq!(
+ n, 9,
+ "crate count changed to {n}: update the docs below and this test"
+ );
+
+ let stale = [
+ "seven-crate",
+ "eight-crate",
+ "8-crate",
+ "7-crate",
+ "six-crate",
+ ];
+ let fresh = ["nine-crate", "9-crate"];
+
+ for file in ["README.md", "CLAUDE.md", "wiki/pages/overview.md"] {
+ let text = doc(file);
+ for s in stale {
+ assert!(
+ !text.contains(s),
+ "{file} still says `{s}` (workspace has {n} crates)"
+ );
+ }
+ assert!(
+ fresh.iter().any(|f| text.contains(f)),
+ "{file} states no crate count; expected one of {fresh:?}"
+ );
+ }
+}
+
+// ---- Scenario 2: the Inputs table lists every shipped input ----
+
+/// Every input the CLI can be built with needs a row. HPSDR/Hermes shipped
+/// (crates/manta-input/src/hpsdr.rs, feature `hpsdr`, in every release
+/// binary) and had no row at all.
+#[test]
+fn readme_inputs_table_covers_every_shipped_input() {
+ let readme = doc("README.md");
+ let inputs = section(&readme, "Inputs");
+ for needle in [
+ "WAV file",
+ "--device",
+ "--kiwi-host",
+ "--soapy-driver",
+ "--hpsdr-host",
+ ] {
+ assert!(
+ inputs.contains(needle),
+ "README Inputs table has no row for `{needle}`"
+ );
+ }
+}
+
+// ---- Scenario 1: Outputs and Status describe shipped capability ----
+
+/// The telnet server, JSON/WebSocket stream, RBN uplink and Prometheus
+/// endpoint have all shipped and are covered by manta-server's test suite.
+#[test]
+fn readme_outputs_describes_shipped_servers() {
+ let readme = doc("README.md");
+ let outputs = section(&readme, "Outputs");
+ assert!(
+ !outputs.contains("in progress"),
+ "README Outputs still calls a shipped server \"in progress\""
+ );
+ for needle in ["7300", "7301", "7302", "uplink", "--server-config"] {
+ assert!(
+ outputs.contains(needle),
+ "README Outputs never mentions `{needle}`"
+ );
+ }
+}
+
+/// The Status block must not list shipped work as upcoming.
+#[test]
+fn readme_status_does_not_promise_shipped_work() {
+ let readme = doc("README.md");
+ let status = section(&readme, "Status");
+ for stale in ["the telnet and JSON spot servers", "TOML config, metrics"] {
+ assert!(
+ !status.contains(stale),
+ "README Status still lists `{stale}` as future work"
+ );
+ }
+}
+
+/// Every command in the Quickstart must run on a build the reader was told
+/// to make. `--soapy-driver` needs `--features soapy`, which is in neither
+/// the default build nor any release binary.
+#[test]
+fn readme_quickstart_only_uses_default_build_flags() {
+ let readme = doc("README.md");
+ let quickstart = section(&readme, "60-second demo");
+ for gated in ["--soapy-driver", "--soapy-freq", "--soapy-rate"] {
+ assert!(
+ !quickstart.contains(gated),
+ "60-second demo uses feature-gated flag `{gated}`; it fails with \
+ `error: unexpected argument` on a default or release build"
+ );
+ }
+}
+
+// ---- Scenario 4: ROADMAP's RBN-admission item ----
+
+/// RBN admission is active P0 work (MAN-40; decision D1, 2026-09-06), not a
+/// deferred post-1.0 idea.
+#[test]
+fn roadmap_does_not_defer_rbn_admission() {
+ let roadmap = doc("ROADMAP.md");
+ let post = section(&roadmap, "Post-1.0 candidates");
+ assert!(
+ !post.contains("RBN operators"),
+ "ROADMAP still defers RBN-operator admission to post-1.0"
+ );
+}
+
+/// M3's own prose must not list the shipped manta-server as remaining.
+#[test]
+fn roadmap_m3_does_not_list_shipped_server_as_remaining() {
+ let roadmap = doc("ROADMAP.md");
+ let m3 = section(&roadmap, "M3");
+ let idx = m3
+ .find("Remaining M3 sub-projects")
+ .expect("M3 remaining-work sentence");
+ assert!(
+ !m3[idx..].contains("manta-server"),
+ "ROADMAP M3 still lists `manta-server` as a remaining sub-project"
+ );
+}
diff --git a/wiki/pages/overview.md b/wiki/pages/overview.md
index 27ea1dcd..f27b4e4d 100644
--- a/wiki/pages/overview.md
+++ b/wiki/pages/overview.md
@@ -17,9 +17,9 @@ links:
- decode-chain
- coppa-reuse
---
-manta is an open-source, cross-platform, wideband multi-signal CW skimmer (Rust) that consumes wideband IQ from commodity SDRs, decodes every CW signal in the passband concurrently, validates callsigns, and emits RBN-compatible spots — an open replacement for the single closed-source Windows program the Reverse Beacon Network depends on. It is **design-phase**: ARCHITECTURE, ROADMAP, and `docs/SPEC-decode-core.md` are frozen; no implementation has started. Read the specs first — the design decisions are already made.
+manta is an open-source, cross-platform, wideband multi-signal CW skimmer (Rust) that consumes wideband IQ from commodity SDRs, decodes every CW signal in the passband concurrently, validates callsigns, and emits RBN-compatible spots — an open replacement for the single closed-source Windows program the Reverse Beacon Network depends on. Implementation is well underway; see README.md's Status section for what has shipped. Read the specs first — the design decisions are already made.
-## Where things live (planned 8-crate workspace)
+## Where things live (nine-crate workspace)
The workspace layout and dependency graph are normative in ARCHITECTURE §2 — do not restate the crate table here; the pointers below map crates to wiki pages.
@@ -29,6 +29,7 @@ The workspace layout and dependency graph are normative in ARCHITECTURE §2 —
- `crates/manta-server/` — telnet cluster + JSON/WebSocket. See [[spot-output-contract]].
- `crates/manta-engine/` — track lifecycle, decoder pool orchestration.
- `crates/manta-input/`, `manta-testkit/`, `manta-cli/` — IQ sources, synthetic/golden harness, binary.
+- `crates/manta-soak-harness/` — 24h soak measurement harness (ROADMAP M2 gate), not shipped in the manta binary.
- `docs/SPEC-decode-core.md` — normative constants, equations, config keys, golden vectors. The wiki points here, never restates.
## Start here
From 9cf856f9e27b7be376e9042417ee78d416313bd2 Mon Sep 17 00:00:00 2001
From: Catalyst Runner
Date: Tue, 8 Sep 2026 04:12:32 +0000
Subject: [PATCH 2/2] =?UTF-8?q?feat:=20MAN-145=20=E2=80=94=20A=20first-tim?=
=?UTF-8?q?e=20visitor's=20README=20should=20match=20what=20manta=20actual?=
=?UTF-8?q?ly=20does=20today?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 32 ++++++++++++++--------
crates/manta-cli/tests/docs_consistency.rs | 24 ++++++++++++----
2 files changed, 39 insertions(+), 17 deletions(-)
diff --git a/README.md b/README.md
index c5bcea1c..2edac56c 100644
--- a/README.md
+++ b/README.md
@@ -49,9 +49,15 @@ with documented, testable algorithms.
## Installation
```sh
-cargo build --release -p manta-cli # Rust 1.85+
+cargo install --path crates/manta-cli --features hpsdr # Rust 1.85+
```
+That puts a `manta` binary in Cargo's bin directory (`~/.cargo/bin`
+unless you moved `CARGO_HOME`), which a standard Rust install already has
+on `PATH` — every command below assumes a bare `manta` resolves. To build
+without installing, `cargo build --release -p manta-cli` leaves the
+binary at `target/release/manta`; run that path instead.
+
No tagged release yet, so there is no prebuilt binary or Docker image to
pull — build from source for now. Both publish automatically, for every
platform, from the first tag:
@@ -70,21 +76,23 @@ docker run --rm ghcr.io/hagaletechnologies/manta:latest --help
Docker's default 10-second grace period is shorter than manta's drain
window for a slow client's final write (up to 25 s), so the default
can cut a graceful shutdown off mid-drain.
-- The `soapy` feature (RTL-SDR, Airspy, SDRplay, HackRF via SoapySDR)
- needs the native SoapySDR system library and is not in the container
- image; build from source with `--features soapy` to use it. `hpsdr`
- (OpenHPSDR/Hermes) has no native dependency and is on by default in
- the container image and every release binary.
+- Input backends are cargo features, and a build that did not ask for
+ one has no flags for it — `--hpsdr-host` / `--soapy-driver` fail with
+ `error: unexpected argument` on a build without them. `hpsdr`
+ (OpenHPSDR/Hermes) has no native dependency, which is why the install
+ line above turns it on. `soapy` (RTL-SDR, Airspy, SDRplay, HackRF via
+ SoapySDR) needs the native SoapySDR system library installed first;
+ once you have it, add it: `--features hpsdr,soapy`.
- Windows binaries need the [Visual C++
Redistributable](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist)
installed if it isn't already.
## 60-second demo
-No SDR, no radio. Build, generate a synthetic golden vector, decode it:
+No SDR, no radio. Install, generate a synthetic golden vector, decode it:
```sh
-cargo build --release -p manta-cli # Rust 1.85+
+cargo install --path crates/manta-cli --features hpsdr # puts `manta` on PATH
manta gen v1 --out /tmp/v1 # 120 s of synthetic CW, 20 WPM, +20 dB
manta decode /tmp/v1/v1.wav
# CQ DE W1AW W1AW K CQ CQ DE W1AW W1AW K CQ CQ DE W1AW W1AW K CQ …
@@ -140,8 +148,8 @@ Before exposing any port beyond loopback, read
| IQ / audio WAV file | `decode`, `listen --source` | Working (`decode` takes IQ; `listen --source` takes 48 kHz mono audio) |
| Sound card (rig audio passband) | `listen --device` | Working, 48 kHz input only |
| KiwiSDR over the network | `listen --kiwi-host` | Working |
-| OpenHPSDR / Hermes (Hermes-Lite 2, Red Pitaya, QMTech) | `listen --hpsdr-host`, feature `hpsdr` — in every official binary | Working; protocol verified against reference sources, not yet against hardware |
-| RTL-SDR, Airspy, SDRplay, HackRF, anything SoapySDR drives | `listen --soapy-driver`, feature `soapy` — **not** in official binaries, needs the SoapySDR system library | Working, needs hardware soak |
+| OpenHPSDR / Hermes (Hermes-Lite 2, Red Pitaya, QMTech) | `listen --hpsdr-host`, feature `hpsdr` — on in the install line above, no native dependency | Working; protocol verified against reference sources, not yet against hardware |
+| RTL-SDR, Airspy, SDRplay, HackRF, anything SoapySDR drives | `listen --soapy-driver`, feature `soapy` — **not** in the install line above; needs the SoapySDR system library, then `--features hpsdr,soapy` | Working, needs hardware soak |
Targets Linux (x86-64 and ARM, Raspberry Pi 4 class), macOS, and Windows.
The CPU budget is a full 192 kS/s passband inside one Raspberry Pi 4 core,
@@ -186,7 +194,9 @@ Pre-1.0, and pre-first-release. What is true today:
- **Not yet measured:** the RBN parity benchmark (recall vs. RBN on
recorded contest IQ), the Raspberry Pi 4 CPU budget, and a 24-hour
live-SDR soak. The first needs reference data; the other two need
- physical hardware.
+ physical hardware. The CPU budget's desktop leg *has* been measured and
+ currently reads as a **fail** — ≈0.53x–0.58x realtime against a <0.5x
+ budget, pending a clean rerun on a quiet machine ([ROADMAP.md](ROADMAP.md) M2).
- **Known limits:** the classical decoder loses copy under heavy HF
fading on several golden vectors, and at low SNR the validator still
admits occasional bogus callsigns from noise. Closing the fading gap is
diff --git a/crates/manta-cli/tests/docs_consistency.rs b/crates/manta-cli/tests/docs_consistency.rs
index eb159c02..3a5660c8 100644
--- a/crates/manta-cli/tests/docs_consistency.rs
+++ b/crates/manta-cli/tests/docs_consistency.rs
@@ -20,6 +20,12 @@ fn doc(rel: &str) -> String {
fs::read_to_string(&p).unwrap_or_else(|e| panic!("reading {}: {e}", p.display()))
}
+/// Every run of whitespace collapsed to a single space, so a phrase that a
+/// Markdown reflow split across two lines still matches.
+fn squash_whitespace(s: &str) -> String {
+ s.split_whitespace().collect::>().join(" ")
+}
+
/// Text between a `## ` and the next `## ` heading.
fn section<'a>(md: &'a str, heading: &str) -> &'a str {
let start = md
@@ -62,9 +68,12 @@ fn architecture_lists_every_workspace_crate() {
}
}
-/// README, ARCHITECTURE, CLAUDE.md and the wiki overview must all state the
-/// same crate count, and it must be the real one. Guard-listing the stale
-/// spellings keeps the failure message actionable when a crate is added.
+/// README, CLAUDE.md and the wiki overview must all state the same crate
+/// count, and it must be the real one. ARCHITECTURE.md is deliberately not
+/// in the list: it states no count in words, and is covered for crate
+/// *names* by `architecture_lists_every_workspace_crate` above. Guard-
+/// listing the stale spellings keeps the failure message actionable when a
+/// crate is added.
#[test]
fn every_doc_states_the_real_crate_count() {
let n = workspace_members().len();
@@ -100,8 +109,8 @@ fn every_doc_states_the_real_crate_count() {
// ---- Scenario 2: the Inputs table lists every shipped input ----
/// Every input the CLI can be built with needs a row. HPSDR/Hermes shipped
-/// (crates/manta-input/src/hpsdr.rs, feature `hpsdr`, in every release
-/// binary) and had no row at all.
+/// (crates/manta-input/src/hpsdr.rs, feature `hpsdr`, which the README's
+/// install line turns on) and had no row at all.
#[test]
fn readme_inputs_table_covers_every_shipped_input() {
let readme = doc("README.md");
@@ -187,7 +196,10 @@ fn roadmap_does_not_defer_rbn_admission() {
#[test]
fn roadmap_m3_does_not_list_shipped_server_as_remaining() {
let roadmap = doc("ROADMAP.md");
- let m3 = section(&roadmap, "M3");
+ // Collapse runs of whitespace so the search survives a paragraph reflow:
+ // the phrase is line-wrapped in the source Markdown, and matching the raw
+ // text would panic on `.expect()` instead of reporting a real drift.
+ let m3 = squash_whitespace(section(&roadmap, "M3"));
let idx = m3
.find("Remaining M3 sub-projects")
.expect("M3 remaining-work sentence");