Skip to content
Open
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
21 changes: 17 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,11 @@ transmission may never produce again).
carry this flag.
2. **Callsign plausibility**: structural grammar (prefix-digit-suffix, portable
designators `/P /QRP /3`), then prefix lookup against **cty.dat** (bundled,
refreshable) — a call with an unallocated prefix is rejected.
refreshable) — a call with an unallocated prefix is rejected. `cty.dat` is
also joined, on that same primary-prefix field, against a small vendored
ADIF DXCC entity-number table (`data/dxcc.tsv`, MAN-136) — refreshed
together, see `crates/manta-spot/data/SOURCES.md` — which is what lets
`manta-server`'s JSON stream populate `dxDxcc`/`deDxcc` (§7).
3. **SCP cross-check** (optional, default on if file present): membership in
`master.scp` (contest super-check-partial list) *raises* confidence; absence
only lowers it (new/rare calls must still spot, not just well-known ones).
Expand Down Expand Up @@ -286,7 +290,12 @@ validation (MAN-28). Dedupe (step 5) still applies.
- **JSON Lines stream** (TCP and WebSocket, :7301): full-fidelity spot objects
(adds confidence, track id, decoder text context). This is the cqdx ingest
surface; schema published in `dispensa` as a JSON Schema contract alongside the
existing ecosystem contracts.
existing ecosystem contracts. Every spot carries a non-null, real `dxDxcc`/
`deDxcc` (an ADIF DXCC entity number, MAN-136) whenever the callsign
resolves against `cty.dat`; when it doesn't, `dxDxcc`/`dxContinent`/
`dxCqZone` (and their `de*` counterparts) carry named, out-of-domain
`UNKNOWN_*` sentinels rather than `null` or a fabricated-looking value —
see `docs/DECISIONS/2026-09-07-man136-dxcc-and-unknown-geography-sentinels.md`.
- Both servers are thin fan-out consumers of one broadcast channel; slow clients
are disconnected, never back-pressure the pipeline.
- **Exposure policy (normative, not just observed behavior):** both servers are
Expand Down Expand Up @@ -324,8 +333,12 @@ validation (MAN-28). Dedupe (step 5) still applies.
currently-implemented subset is `manta_spots_total`,
`manta_spots_dropped_lagged_total`,
`manta_spots_suppressed_by_filter_total`,
`manta_spots_dropped_write_failed_total`, per-protocol client-connected
gauges, `manta_source_health`, and the uplink counters
`manta_spots_dropped_write_failed_total`,
`manta_spots_unresolved_geography_total` (MAN-136/MAN-45 — a spot that went
out carrying an `UNKNOWN_*` sentinel on either side, i.e. its dx or de
callsign didn't resolve against `cty.dat`, *or* it resolved but its entity
has no row in the vendored `dxcc.tsv`), per-protocol
client-connected gauges, `manta_source_health`, and the uplink counters
(`crates/manta-server/src/metrics.rs`) — not input-layer overruns or
per-stage queue depths, which MAN-56 tracks as a separate gap.
**`manta_active_tracks` is served but not populated** (corrected
Expand Down
95 changes: 94 additions & 1 deletion crates/manta-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,36 @@ struct SpotServer {
/// `SHUTDOWN_DRAIN_DEADLINE`) instead of guessing a fixed sleep
/// duration -- see `shutdown_runtime_after_drain`.
tasks: manta_server::tasks::ClientTasks,
/// MAN-136/MAN-45: the same `cty::Table` handed to `JsonStreamConfig`,
/// kept here too so the publish callback can check resolvability once
/// per spot for `manta_spots_unresolved_geography_total` -- checking
/// inside `SpotMessage::from_spot` would scale with connected client
/// count instead of spot count.
cty: std::sync::Arc<manta_spot::cty::Table>,
/// Whether the operator's OWN station callsign (config, not decoder
/// output -- and not required to be cty-resolvable) already forces the
/// de-side `UNKNOWN_*` sentinels. Resolved ONCE at `start_spot_server`
/// time rather than per spot: `station_callsign` cannot change for the
/// life of the process, so re-running the same binary search on every
/// spot only re-derives a constant.
station_geography_unresolved: bool,
}

/// True when `SpotMessage::from_spot` would emit the `UNKNOWN_DXCC` /
/// `UNKNOWN_CONTINENT` / `UNKNOWN_CQ_ZONE` sentinels for `callsign`, i.e.
/// exactly the condition `manta_spots_unresolved_geography_total` counts.
///
/// Deliberately keyed on the RESOLVED ADIF entity number, not merely on
/// whether `lookup` returned an entry: `from_spot` emits `UNKNOWN_DXCC` on
/// `dx.and_then(|e| e.dxcc).is_none()`, which is also true when `cty.dat`
/// resolves the call but the vendored `dxcc.tsv` has no row for its primary
/// prefix -- the drift state that arises when `cty.dat` is hand-refreshed
/// (data/SOURCES.md) without regenerating the TSV. Counting `lookup`
/// alone would let those spots go out carrying `dxDxcc: -1` with the
/// counter still at zero, silently withholding the one signal this metric
/// exists to give (round-1 validate code-review finding 1).
fn geography_is_unresolved(cty: &manta_spot::cty::Table, callsign: &str) -> bool {
cty.lookup(callsign).and_then(|e| e.dxcc).is_none()
}

/// Starts the telnet/JSON-Lines-and-WebSocket/metrics servers on their own
Expand Down Expand Up @@ -864,7 +894,7 @@ fn start_spot_server(
manta_server::json_stream::JsonStreamConfig {
bus: bus.clone(),
metrics: metrics.clone(),
cty,
cty: cty.clone(),
station_call: cfg.station_callsign.clone(),
decoder_version,
// .clone(): MAN-32/MAN-42's uplink::serve spawns below also
Expand Down Expand Up @@ -928,6 +958,8 @@ fn start_spot_server(
metrics,
shutdown_tx,
tasks,
station_geography_unresolved: geography_is_unresolved(&cty, &cfg.station_callsign),
cty,
},
))
}
Expand Down Expand Up @@ -1203,6 +1235,20 @@ fn main() -> Result<()> {
if let Some(server) = &spot_server {
server.bus.publish(spot.clone());
server.metrics.record_spot();
// MAN-136/MAN-45: counted ONCE per spot here, NOT
// inside `SpotMessage::from_spot` -- that runs once
// per connected JSON/WS client (json_stream.rs:126),
// so counting there would scale with client count
// instead of spot count. Checks BOTH sides: the
// operator's own station_callsign is config, not
// decoder output, and isn't required to resolve --
// but it also never changes, so its side is
// resolved once at start_spot_server time.
if geography_is_unresolved(&server.cty, &spot.callsign)
|| server.station_geography_unresolved
{
server.metrics.record_unresolved_geography();
}
}
if json {
println!("{}", serde_json::json!({ "spot": spot }));
Expand Down Expand Up @@ -1731,4 +1777,51 @@ mod tests {
assert!(accepted1, "first configured target must be connected to");
assert!(accepted2, "second configured target must be connected to");
}

// MAN-136 round-1 validate code-review finding 1: the increment
// condition for `manta_spots_unresolved_geography_total` must match the
// condition under which `SpotMessage::from_spot` emits the `UNKNOWN_*`
// sentinels -- the RESOLVED ADIF entity number, not merely whether
// `cty.lookup` returned an entry.

const GEOGRAPHY_CTY_FIXTURE: &str = "\
United States: 5: 8: NA: 40.0: 75.0: 5.0: K:
K,W,N;
";
/// One `dxcc.tsv` row for the fixture above, in the vendored file's
/// `<primary-prefix>\t<adif-number>\t<name>` shape.
const GEOGRAPHY_DXCC_FIXTURE: &str = "K\t291\tUnited States\n";

#[test]
fn a_callsign_with_a_resolved_entity_number_is_not_counted_as_unresolved() {
let cty =
manta_spot::cty::Table::parse_with_dxcc(GEOGRAPHY_CTY_FIXTURE, GEOGRAPHY_DXCC_FIXTURE);
assert_eq!(cty.lookup("W1AW").and_then(|e| e.dxcc), Some(291));
assert!(!geography_is_unresolved(&cty, "W1AW"));
}

#[test]
fn an_unresolvable_callsign_is_counted_as_unresolved() {
let cty =
manta_spot::cty::Table::parse_with_dxcc(GEOGRAPHY_CTY_FIXTURE, GEOGRAPHY_DXCC_FIXTURE);
assert!(cty.lookup("QQ1AAA").is_none(), "test premise");
assert!(geography_is_unresolved(&cty, "QQ1AAA"));
}

#[test]
fn a_cty_resolvable_callsign_with_no_dxcc_row_is_still_counted_as_unresolved() {
// The cty.dat/dxcc.tsv drift state: `cty.dat` was hand-refreshed
// (data/SOURCES.md has no refresh automation) without regenerating
// the TSV, so geography resolves -- non-null dxLat/dxLon -- while
// the entity number does not, and the spot goes out with
// `dxDxcc: -1`. Counting `lookup().is_none()` missed exactly this.
let cty = manta_spot::cty::Table::parse_with_dxcc(GEOGRAPHY_CTY_FIXTURE, "");
let entry = cty.lookup("W1AW").expect("geography still resolves");
assert_eq!(entry.dxcc, None, "test premise: only the number is missing");
assert_eq!(entry.continent, "NA");
assert!(
geography_is_unresolved(&cty, "W1AW"),
"a spot emitted with UNKNOWN_DXCC must be counted, even though cty.dat resolved it"
);
}
}
47 changes: 47 additions & 0 deletions crates/manta-server/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ pub struct Metrics {
spots_dropped_lagged_total: AtomicU64,
spots_suppressed_by_filter_total: AtomicU64,
spots_dropped_write_failed_total: AtomicU64,
/// MAN-136/MAN-45: a spot's dx or de callsign couldn't be resolved
/// against `cty.dat`, so it was emitted with the `UNKNOWN_DXCC`/
/// `UNKNOWN_CONTINENT`/`UNKNOWN_CQ_ZONE` sentinels instead of real
/// geography. ARCHITECTURE §8: "every dropped/evicted/suppressed item is
/// counted" -- an operator otherwise has no way to notice this is
/// happening. Incremented once per spot at publish time (`main.rs`), not
/// inside `SpotMessage::from_spot` (which runs once per connected
/// client).
spots_unresolved_geography_total: AtomicU64,
telnet_clients: AtomicI64,
json_clients: AtomicI64,
ws_clients: AtomicI64,
Expand Down Expand Up @@ -83,6 +92,15 @@ impl Metrics {
.fetch_add(n, Ordering::Relaxed);
}

/// MAN-136/MAN-45: call once per spot (not per connected client) when
/// either the dx or de callsign didn't resolve against `cty.dat`, so the
/// wire message carries the `UNKNOWN_*` sentinels instead of real
/// geography.
pub fn record_unresolved_geography(&self) {
self.spots_unresolved_geography_total
.fetch_add(1, Ordering::Relaxed);
}

pub fn inc_telnet_clients(&self) {
self.telnet_clients.fetch_add(1, Ordering::Relaxed);
}
Expand Down Expand Up @@ -257,6 +275,16 @@ impl Metrics {
.load(Ordering::Relaxed)
));

out.push_str(
"# HELP manta_spots_unresolved_geography_total Spots emitted with an UNKNOWN_DXCC/UNKNOWN_CONTINENT/UNKNOWN_CQ_ZONE sentinel on the dx or de side, because the callsign did not resolve against cty.dat OR its entity carries no row in the vendored dxcc.tsv.\n",
);
out.push_str("# TYPE manta_spots_unresolved_geography_total counter\n");
out.push_str(&format!(
"manta_spots_unresolved_geography_total {}\n",
self.spots_unresolved_geography_total
.load(Ordering::Relaxed)
));

out.push_str("# HELP manta_telnet_clients_connected Currently connected telnet clients.\n");
out.push_str("# TYPE manta_telnet_clients_connected gauge\n");
out.push_str(&format!(
Expand Down Expand Up @@ -432,6 +460,25 @@ mod tests {
assert!(text.contains("manta_spots_dropped_write_failed_total 7"));
}

#[test]
fn unresolved_geography_is_counted_and_exposed() {
let m = Metrics::new();
m.record_unresolved_geography();
m.record_unresolved_geography();
assert!(m
.render_prometheus_text()
.contains("manta_spots_unresolved_geography_total 2"));
}

#[test]
fn unresolved_geography_counter_is_present_at_zero_before_any_spot() {
// A counter that only appears once it fires is invisible to an operator
// building a dashboard -- the other spot counters all render at 0.
assert!(Metrics::new()
.render_prometheus_text()
.contains("manta_spots_unresolved_geography_total 0"));
}

#[test]
fn renders_per_source_health_as_labeled_gauge() {
let m = Metrics::new();
Expand Down
Loading
Loading