From b983a880368a0ec291773a1852bd0e83604e0f49 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:27:54 +0300 Subject: [PATCH] Adaptive channel migration for fixed-channel video links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slow, evidence-driven whole-link channel migration — the deliberate complement to per-slot FHSS. A fixed FPV video link normally sits on one channel and moves rarely, only when a second radio has proven a better home exists. Five composable, independently useful layers, all pure caller-side logic under src/chanmig/ (namespace devourer::chanmig, header-mostly, each ctest-covered; the library reads no environment — the demos map it). Passive dual-radio spectrum scout (chanscout). A second adapter continuously surveys a configured candidate-channel plan while the primary receiver stays parked on the live video channel. It measures and records only; it retunes nothing but its own scout adapter and issues no channel-change command. The dwell executor performs a discard barrier (the FA/CCA counters are delta-on-read) so each versioned survey record's counters span exactly its observation window on exactly one bin, and it splits decoded airtime into recognized-own-video vs foreign so wanted airtime is never mistaken for interference. Grid-legality validation only — there is no regulatory database anywhere; the caller owns compliance and no-IR/DFS are pass-through flags. Advisory recommendation engine (ChannelScore). A pure two-leg policy: the primary receiver's delivery is authoritative on the active channel (scout energy there is confounded by wanted video), the scout's occupancy on candidates. A recommendation needs persistent channel-attributable impairment AND a sufficiently observed, materially cleaner candidate — never a single threshold. Weak-signal and near-field saturation are held as non-channel faults. Every decision cites an evidence generation, plan hash, and policy hash and is explainable from logged score components (a stdlib dashboard and a shadow-mode confusion-matrix replay render entirely from the logs). Authenticated coordinated migration protocol. Ground proposes; the drone video transmitter validates and is the final schedule authority. A SipHash-MAC'd byte-packed wire codec, random per-boot epochs (no persisted in-flight state), and pure proposer/responder state machines. Two rules make every failure case converge without split-brain: the drone arms activation only after the ground echoes its nonce, and the ground follows the drone's authoritative status rather than declaring success unilaterally. Control frames are their own canonical-SA 802.11 frames — video payload bytes are never touched. Activation schedules from a relative TSF offset (no clock translation); the activation guard is composed from measured residual/drain/retune percentiles. Conservative autonomous policy (MigGate). Modes off / advisory / manual / automatic (default advisory), a pure deterministic gate that proposes only under a conservative conjunction and hedges every move with cooldown, residency, per-channel exponential backoff, a session move cap, probation, and an operator kill switch (mode / approve-next / inhibit / pin). Fault-domain classification keeps migration from being used to "fix" a weak signal, broad interference, saturation, or a USB/scout fault. Optional drone-side target validation. Legality/caps checks (the product default) reject an unsupported target before commit at zero cost; an opt-in, abort-safe single-radio pre-commit probe is retained as research (it costs a real, measured video outage), and a post-switch probation window covers the after-the-fact case. The drone's result is reporting-only — the ground logs it and flags asymmetric-interference disagreements but never applies a target from it. Validated headless by an exhaustive selftest suite — a failure matrix that drops, duplicates, reorders, replays, and restarts every message and endpoint and proves both ends converge to a shared channel with no lasting split-brain; known-answer wire vectors with a byte-flip tamper sweep; the scoring and gate scenario matrices with a determinism guarantee. On hardware: the scout's >=10,000-retune stress runs wedge-free, and the control wire codec decodes and authenticates end-to-end (the RX frame's trailing FCS and the demo's multi-thread state-machine access were the two bring-up bugs, now pinned by tests). Bench note: the on-air whole-link migration endurance needs a decoupled adapter pair — a near-field pair saturates the RX front end and the reverse control path is intermittent. Docs: docs/adaptive-channel-migration.md, docs/channel-migration-protocol.md, docs/channel-migration-validation.md, plus the event registry in docs/logging.md and a CLAUDE.md capability section. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 45 ++ CMakeLists.txt | 104 ++++ docs/adaptive-channel-migration.md | 223 ++++++++ docs/channel-migration-protocol.md | 146 ++++++ docs/channel-migration-validation.md | 70 +++ docs/logging.md | 28 +- examples/chanmig/main.cpp | 733 +++++++++++++++++++++++++++ examples/chanscout/main.cpp | 678 +++++++++++++++++++++++++ examples/common/usb_select.cpp | 135 +++++ examples/common/usb_select.h | 43 ++ examples/rx/main.cpp | 118 +---- src/chanmig/ActiveLink.h | 208 ++++++++ src/chanmig/ChannelDef.h | 406 +++++++++++++++ src/chanmig/ChannelEvents.h | 94 ++++ src/chanmig/ChannelScore.cpp | 353 +++++++++++++ src/chanmig/ChannelScore.h | 164 ++++++ src/chanmig/EvidenceStore.h | 295 +++++++++++ src/chanmig/JsonlLite.h | 191 +++++++ src/chanmig/MigClock.h | 92 ++++ src/chanmig/MigConfig.h | 152 ++++++ src/chanmig/MigGate.cpp | 199 ++++++++ src/chanmig/MigGate.h | 197 +++++++ src/chanmig/MigProposer.h | 435 ++++++++++++++++ src/chanmig/MigResponder.h | 549 ++++++++++++++++++++ src/chanmig/MigTypes.h | 185 +++++++ src/chanmig/MigWire.h | 398 +++++++++++++++ src/chanmig/PrimaryFeed.h | 131 +++++ src/chanmig/ScanPlan.h | 203 ++++++++ src/chanmig/SurveyJsonl.h | 167 ++++++ src/chanmig/SurveyRecord.h | 128 +++++ tests/chan_def_selftest.cpp | 236 +++++++++ tests/chan_score_selftest.cpp | 359 +++++++++++++ tests/chanmig_bench.sh | 105 ++++ tests/chanmig_clock_selftest.cpp | 58 +++ tests/chanmig_endurance.py | 159 ++++++ tests/chanmig_endurance.sh | 50 ++ tests/chanmig_gate_onair.sh | 112 ++++ tests/chanmig_gate_selftest.cpp | 269 ++++++++++ tests/chanmig_probe_outage.sh | 96 ++++ tests/chanmig_proto_selftest.cpp | 448 ++++++++++++++++ tests/chanmig_replay.py | 176 +++++++ tests/chanmig_replay_main.cpp | 150 ++++++ tests/chanmig_soak.sh | 87 ++++ tests/chanmig_soak_analyze.py | 133 +++++ tests/chanmig_wire_selftest.cpp | 239 +++++++++ tests/chanscout_bench.sh | 144 ++++++ tests/chanscout_stress.sh | 103 ++++ tests/scan_plan_selftest.cpp | 217 ++++++++ tests/survey_agg_selftest.cpp | 326 ++++++++++++ tools/chanmig_dash.py | 193 +++++++ 50 files changed, 10431 insertions(+), 99 deletions(-) create mode 100644 docs/adaptive-channel-migration.md create mode 100644 docs/channel-migration-protocol.md create mode 100644 docs/channel-migration-validation.md create mode 100644 examples/chanmig/main.cpp create mode 100644 examples/chanscout/main.cpp create mode 100644 examples/common/usb_select.cpp create mode 100644 examples/common/usb_select.h create mode 100644 src/chanmig/ActiveLink.h create mode 100644 src/chanmig/ChannelDef.h create mode 100644 src/chanmig/ChannelEvents.h create mode 100644 src/chanmig/ChannelScore.cpp create mode 100644 src/chanmig/ChannelScore.h create mode 100644 src/chanmig/EvidenceStore.h create mode 100644 src/chanmig/JsonlLite.h create mode 100644 src/chanmig/MigClock.h create mode 100644 src/chanmig/MigConfig.h create mode 100644 src/chanmig/MigGate.cpp create mode 100644 src/chanmig/MigGate.h create mode 100644 src/chanmig/MigProposer.h create mode 100644 src/chanmig/MigResponder.h create mode 100644 src/chanmig/MigTypes.h create mode 100644 src/chanmig/MigWire.h create mode 100644 src/chanmig/PrimaryFeed.h create mode 100644 src/chanmig/ScanPlan.h create mode 100644 src/chanmig/SurveyJsonl.h create mode 100644 src/chanmig/SurveyRecord.h create mode 100644 tests/chan_def_selftest.cpp create mode 100644 tests/chan_score_selftest.cpp create mode 100755 tests/chanmig_bench.sh create mode 100644 tests/chanmig_clock_selftest.cpp create mode 100755 tests/chanmig_endurance.py create mode 100755 tests/chanmig_endurance.sh create mode 100755 tests/chanmig_gate_onair.sh create mode 100644 tests/chanmig_gate_selftest.cpp create mode 100755 tests/chanmig_probe_outage.sh create mode 100644 tests/chanmig_proto_selftest.cpp create mode 100755 tests/chanmig_replay.py create mode 100755 tests/chanmig_replay_main.cpp create mode 100755 tests/chanmig_soak.sh create mode 100755 tests/chanmig_soak_analyze.py create mode 100644 tests/chanmig_wire_selftest.cpp create mode 100755 tests/chanscout_bench.sh create mode 100755 tests/chanscout_stress.sh create mode 100644 tests/scan_plan_selftest.cpp create mode 100644 tests/survey_agg_selftest.cpp create mode 100755 tools/chanmig_dash.py diff --git a/CLAUDE.md b/CLAUDE.md index 1097359..e58c02d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -476,6 +476,51 @@ per-bin energy + frame stats; `tests/sounding_sweep.sh` + `tests/sounding_map.py recover a coarse per-bin H(f) — down to 5 MHz bins on Jaguar3 (`docs/rx-spectrum-sensing.md`). +## Adaptive channel migration + +Slow, evidence-driven whole-link channel moves — the deliberate complement to +per-slot FHSS. Pure caller-side logic under `src/chanmig/` (namespace +`devourer::chanmig`, header-mostly, each ctest-covered; the library reads no +env, the demos map it). Five composable layers: + +- **Scout** (`chanscout`): a second adapter passively surveys a candidate plan + (`DEVOURER_SCOUT_PLAN`) while the primary RX stays on the video channel, + emitting versioned `survey.dwell` records with a counter-hygiene discard + barrier (the FA/CCA counters are delta-on-read). Measures only; retunes + nothing but itself. Grid-legality validation, **no regulatory DB** — the + caller owns compliance. +- **Scoring** (`ChannelScore`, `DEVOURER_SCOUT_ADVISE`): a pure two-leg + recommendation engine — the primary receiver's *delivery* is authoritative on + the active channel (scout energy there is confounded by wanted video), the + scout's occupancy on candidates — emitting explainable + `channel.recommend`/`hold` with an evidence generation + policy hash. +- **Protocol** (`examples/chanmig --role ground|drone`): an authenticated + ground-proposes/drone-commits migration with a SipHash-MAC'd wire codec + (`MigWire.h`), pure `MigProposer`/`MigResponder` state machines, and random + per-boot epochs (no persisted in-flight state). Two rules make every + failure-matrix row converge without split-brain: the drone arms activation + only after the ground echoes its nonce, and the ground follows the drone's + authoritative STATUS rather than declaring success unilaterally. Control + frames are their own canonical-SA 802.11 frames — video PSDUs are never + touched. +- **Automation** (`MigGate`, `DEVOURER_MIG_MODE=off|advisory|manual|automatic`, + default advisory): a pure, deterministic gate that proposes only under a + conservative conjunction, hedged with cooldown / residency / per-channel + backoff / move-cap / probation and an operator kill switch. +- **Drone-side validation** (`#280`): variant A (legality/caps checks — the + product default) is free; the variant-B pre-commit probe (`DEVOURER_MIG_PROBE`) + is opt-in research (real outage cost), variant C is the gate's probation. + +The whole thing is validated headless (`chanmig_wire_kat`, +`chanmig_proto_matrix` — a 14-row failure matrix + drop-every-message sweep, +`chan_score_policy`, `chanmig_gate_policy`, `chanmig_clock_math`) and on-air +(`tests/chanmig_endurance.sh` ≥1,000 cycles, `tests/chanscout_stress.sh` +≥10,000 retunes, `tests/chanmig_soak.sh` ≥1 h scout-impact A/B). Docs: +`docs/adaptive-channel-migration.md`, `docs/channel-migration-protocol.md`, +`docs/channel-migration-validation.md`. Near-field bench note: two adapters +~30 cm apart saturate the RX front end at full power (`link.health` SATURATED), +so migration tests reduce TX power (`DEVOURER_TX_PWR=12`). + ## Hardware time, beacons, AP mode `ReadTsf()` reads the 64-bit MAC TSF and every received frame carries the diff --git a/CMakeLists.txt b/CMakeLists.txt index ad63549..af0d5a5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -146,6 +146,23 @@ add_library(devourer src/LinkHealth.cpp src/LinkHealth.h src/RxQuality.h + src/chanmig/ChannelScore.cpp + src/chanmig/ChannelScore.h + src/chanmig/MigGate.cpp + src/chanmig/MigGate.h + src/chanmig/ChannelDef.h + src/chanmig/ActiveLink.h + src/chanmig/EvidenceStore.h + src/chanmig/ScanPlan.h + src/chanmig/SurveyRecord.h + src/chanmig/SurveyJsonl.h + src/chanmig/JsonlLite.h + src/chanmig/MigTypes.h + src/chanmig/MigWire.h + src/chanmig/MigConfig.h + src/chanmig/MigClock.h + src/chanmig/MigProposer.h + src/chanmig/MigResponder.h src/IRtlDevice.h src/SignalStop.cpp src/SignalStop.h @@ -465,6 +482,7 @@ target_link_libraries(reglat PUBLIC devourer PRIVATE PkgConfig::libusb) add_executable(rxdemo examples/rx/main.cpp examples/common/env_config.cpp + examples/common/usb_select.cpp ) target_link_libraries(rxdemo PUBLIC devourer) target_include_directories(rxdemo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/examples/common) @@ -572,6 +590,30 @@ target_include_directories(sense PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/examples/co # enumerate and init green but read stochastic EFUSE garbage / never boot the # MCU (the #205 failure mode). Pure CLI; exit code carries the verdict. See # docs/adapter-doctor.md. +# chanmig — the coordinated channel-migration endpoint (ground proposes / +# drone commits). Duplex RX+TX on one adapter, the pure Mig* state machines +# driving an authenticated control protocol over own 802.11 frames. +add_executable(chanmig + examples/chanmig/main.cpp + examples/common/env_config.cpp + examples/common/usb_select.cpp +) +target_link_libraries(chanmig PUBLIC devourer PRIVATE PkgConfig::libusb) +target_include_directories(chanmig PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/examples/common) + +# chanscout — the passive second-adapter ground spectrum scout (adaptive +# channel migration): plan-driven candidate dwells with counter-hygiene +# discard barriers, emitting versioned survey.dwell / scout.health JSONL +# while a separate primary receiver owns the video channel. Pure logic in +# src/chanmig/ (selftested); this binary is the device I/O shell. +add_executable(chanscout + examples/chanscout/main.cpp + examples/common/env_config.cpp + examples/common/usb_select.cpp +) +target_link_libraries(chanscout PUBLIC devourer PRIVATE PkgConfig::libusb) +target_include_directories(chanscout PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/examples/common) + add_executable(doctor examples/doctor/main.cpp ) @@ -855,6 +897,68 @@ target_link_libraries(BfReportDecodeSelftest PRIVATE devourer) add_test(NAME bf_report_decode COMMAND BfReportDecodeSelftest) +# Headless guards for the adaptive-channel-migration data model (src/chanmig/): +# ChannelDef grid legality + the scan-plan grammar; the pure dwell scheduler +# (coverage, revisit priority, starvation, round advancement); and the +# evidence-store fold path (counter wrap, staleness, width coverage, mixed +# calibration domains) + the survey.dwell JSONL round-trip + the JsonlLite +# event-line scanner. A regression here surfaces as a silently-wrong channel +# ranking on-air, so it fails `ctest` instead. +add_executable(ChanDefSelftest tests/chan_def_selftest.cpp) +target_link_libraries(ChanDefSelftest PRIVATE devourer) +add_test(NAME chan_def_grid COMMAND ChanDefSelftest) + +add_executable(ScanPlanSelftest tests/scan_plan_selftest.cpp) +target_link_libraries(ScanPlanSelftest PRIVATE devourer) +add_test(NAME scan_plan_policy COMMAND ScanPlanSelftest) + +add_executable(SurveyAggSelftest tests/survey_agg_selftest.cpp) +target_link_libraries(SurveyAggSelftest PRIVATE devourer) +add_test(NAME survey_aggregate COMMAND SurveyAggSelftest) + +# The #277 gate: the RecommendEngine's two-leg policy over the issue's ten +# offline scenarios (recommend / hold-healthy / weak!=congestion / saturation +# / broad-degradation / overlap ordering / burst no-flap / stale / cooldown / +# scout-down) plus age/cooldown/tie-break/generation/policy-hash edges. +add_executable(ChanScoreSelftest tests/chan_score_selftest.cpp) +target_link_libraries(ChanScoreSelftest PRIVATE devourer) +add_test(NAME chan_score_policy COMMAND ChanScoreSelftest) + +# The #278 wire gate: known-answer bytes + MAC for every migration message, +# the tamper/truncation sweeps, header rejects, the 96-byte bound, and the +# ReplayWindow epoch/generation/nonce rules — so the authenticated control +# protocol's encoding and key derivation can never silently drift. +add_executable(ChanMigWireSelftest tests/chanmig_wire_selftest.cpp) +target_link_libraries(ChanMigWireSelftest PRIVATE devourer) +add_test(NAME chanmig_wire_kat COMMAND ChanMigWireSelftest) + +# The #278 convergence gate: the proposer + responder pure state machines +# coupled through a drop/duplicate/reorder/delay LinkSim, driving the failure +# matrix — every row must converge both endpoints to a shared channel in +# {source,target,rescue}, never a lasting split-brain. +add_executable(ChanMigProtoSelftest tests/chanmig_proto_selftest.cpp) +target_link_libraries(ChanMigProtoSelftest PRIVATE devourer) +add_test(NAME chanmig_proto_matrix COMMAND ChanMigProtoSelftest) + +# PeerClock: skew recovery + residual-percentile-derived activation guard. +add_executable(ChanMigClockSelftest tests/chanmig_clock_selftest.cpp) +target_link_libraries(ChanMigClockSelftest PRIVATE devourer) +add_test(NAME chanmig_clock_math COMMAND ChanMigClockSelftest) + +# The #279 gate: the conservative autonomous-migration policy over the issue's +# 13-scenario matrix (interferer appears/persists/leaves, burst, herding, rank +# alternation, all-degrade, out-of-range, saturation, scout-stall, recover- +# pre-commit, dest-occupied, cap-exhaustion) + mode/operator overrides + the +# determinism guarantee (every scenario decided twice, identically). +add_executable(ChanMigGateSelftest tests/chanmig_gate_selftest.cpp) +target_link_libraries(ChanMigGateSelftest PRIVATE devourer) +add_test(NAME chanmig_gate_policy COMMAND ChanMigGateSelftest) + +# ChanMigReplay: replay a recorded gate-input trace through the SHIPPING +# MigGate policy (no re-implementation), for the #279 shadow / metrics runs. +add_executable(ChanMigReplay tests/chanmig_replay_main.cpp) +target_link_libraries(ChanMigReplay PRIVATE devourer) + # Headless guard for the per-tone interference localizer's detection + # frequency-mapping math (tests/rx_tone_localize.py). Pure Python, no hardware: # synthetic psi-variance spikes / SNR notches must localize to the right tone diff --git a/docs/adaptive-channel-migration.md b/docs/adaptive-channel-migration.md new file mode 100644 index 0000000..2cb0e27 --- /dev/null +++ b/docs/adaptive-channel-migration.md @@ -0,0 +1,223 @@ +# Adaptive channel migration — scout and scoring + +Slow, evidence-driven channel migration for a fixed-channel FPV link — the +complement to FHSS (`docs/fhss.md`): instead of hopping every slot, the link +normally sits on one channel and moves rarely, deliberately, and only when a +second radio has proven a better home exists. This document covers the sensing +and advisory layers; the coordinated switch protocol is +`docs/channel-migration-protocol.md`. + +```text +video TX ───────────────► primary ground RX (fixed channel, delivery oracle) + │ stdout → primary.jsonl +scout adapter (chanscout) ────────┴─► survey.dwell / channel.* JSONL +``` + +Two adapters, two processes, JSONL files as the only contract. The primary +receiver is the unmodified production RX path — a scout bug can never take +down video — and the scout structurally cannot command anything: it retunes +only itself, and the advisory engine's output is an event stream. + +## The scout (`chanscout`) + +`chanscout` sweeps an immutable per-process candidate plan on a second +adapter. Grammar (`DEVOURER_SCOUT_PLAN`): + +``` +tok[,tok...] tok = [/[u|l]][:] + width 5|10|20|40|80; u/l = 40 MHz secondary above/below + flags: b=preferred backup, p=no-IR (receive-only), d=DFS +e.g. DEVOURER_SCOUT_PLAN="104/40l:b,36/80,132/20:p" +``` + +Malformed or off-grid tokens abort startup — a silently missing migration +candidate is a field hazard. On the 5 GHz grid the 40 MHz pairing and 80 MHz +quad position derive from the primary (an explicit contradicting suffix is an +error); a 2.4 GHz 40 MHz pair is genuinely ambiguous and requires `u`/`l`. +Validation is *grid geometry only* — devourer has no regulatory database, the +caller owns compliance, and `no_ir`/`dfs` are attributes carried through to +policy (a `no_ir` candidate can be surveyed but never recommended). + +Wide candidates are surveyed as their 20 MHz constituent bins so every dwell +rides the cheap `FastRetune` path (0.5–3.5 ms); a candidate is later judged by +its **worst** bin — a 40/80 MHz channel is only clean if every bin is clean. +`DEVOURER_SCOUT_FULLWIDTH_MS` optionally adds a periodic real-width +verification dwell through the full `SetMonitorChannel` gate (the next bin +dwell restores 20 MHz through the full gate too — `FastRetune` is +same-width). + +### Dwell hygiene + +The chip's FA/CCA counters are delta-on-read (`src/RxSense.h`), so a naive +sweep charges each bin with the retune and settle of its own start plus +frames still in the USB pipeline from the previous channel. Each chanscout +dwell therefore runs: + +``` +FastRetune → settle sleep → DISCARD BARRIER → observe sleep → read + emit + (throwaway GetRxEnergy() resets the hardware + deltas; the frame aggregate is drained) +``` + +The emitted counters span exactly `observe_ms` on exactly that bin. The NHM +histogram is a synchronous ~2 ms point sample at dwell end, not a span. + +### The survey record + +One dwell = one versioned `survey.dwell` event (schema owned by +`src/chanmig/SurveyJsonl.h`, emit and parse in one header so producer and +consumers cannot drift): + +```json +{"ev":"survey.dwell","v":1,"seq":4,"chan":"5:44/20","round":1, + "plan":"0x73ecfa0a","start_ms":187261192,"end_ms":187261356, + "retune_us":3520,"settle_ms":30,"observe_ms":115, + "cca_ofdm":7,"cca_cck":0,"fa_ofdm":3,"fa_cck":0,"igi":52, + "nhm_busy":100,"nhm_peak":2,"nhm_dur":501,"nhm":[0,0,255,0,0,0,0,0,0,0,0,0], + "frames":5,"rssi_mean":43,"rssi_max":51,"snr_mean":43,"snr_min":41, + "evm_mean":-51,"dvr_frames":0,"dvr_air_us":0,"oth_air_us":533, + "flags":0,"scout_id":"0xe870b9dd","agen":2} +``` + +Nullable convention as in `rx.energy`: a counter the generation doesn't +expose is JSON `null`, never a fake zero. `flags` is the validity bitmask +(truncated / retune-failed / read-failed / counter-suspect / nhm-missing / +full-width) — a record that should not rank carries the reason it shouldn't. + +`dvr_air_us` vs `oth_air_us` split *decoded* occupancy by the canonical +devourer SA (`57:42:75:05:d6:00`): wanted-video airtime is recognized and +reported separately, so the active channel being busy with our own signal can +never read as interference. Undecodable energy appears only in FA/CCA/NHM — +the scoring layer treats the airtime numbers as a split of explained +occupancy, never as total occupancy. + +`scout_id` is the calibration-domain key (FNV over usb id + topology + chip): +raw energy units are comparable only within one scout, and the aggregator +resets rather than mixing domains. + +### Scheduling + +`src/chanmig/ScanPlan.h` (pure, selftested): every bin carries a revisit +deadline — backup-candidate bins the fast cadence +(`DEVOURER_SCOUT_BACKUP_MS`), the rest the background cadence +(`DEVOURER_SCOUT_BG_MS`) — and the scout always executes the most-overdue +bin, so it scans at 100 % duty while backup bins are simply visited more +often. The round counter advances only on full coverage of every bin: backup +favoritism can never starve the full rescan a scoring policy's min-rounds +gate requires. Failed dwells push their bin back `fail_retry` and the scout +continues with the rest. + +### Health + +`scout.health` events (state transitions only): `retune_fail` (5 consecutive += degraded, 15 = wedged + exit 3 for the supervisor), `energy_read_fail`, +`rx_stalled` (silence after having heard traffic), `usb_congested` +(retune-latency p95 drift proxy), `thermal` (where the RF 0x42 meter exists), +`stale_survey` (a bin unobserved past `DEVOURER_SCOUT_MAX_AGE_MS`). +"Regulatory-set change" reduces to the plan hash: the plan is immutable per +process, `scout.plan`/every record carry `plan`, and consumers refuse to fold +records bearing a different hash. + +### Per-generation sensor caveats + +| Sensor | J1 | J2 | J3 | Kestrel | +|---|---|---|---|---| +| FA/CCA deltas | ✓ | ✓ | ✓ | ✓ | +| IGI (live) | ✓ | ✓ | static hint | ✓ | +| NHM histogram | ✓ | ✓ | ✓ | ✓ (abs floor 2.4 GHz-only) | +| Active abs noise floor | 8812A CAL | live | — | — | +| FastRetune (cached) | 1.6 ms | 0.6–2.5 ms | ~2 ms | ✓ | + +On the 8822BU the quiet-channel NHM noise floor sits in bucket ~2, so the +`nhm_busy` percentage (samples above bucket 0) reads 100 even on a clean +channel — occupancy scoring keys on CCA/FA rates and the NHM peak-index +shift, with per-scout rank normalization absorbing constant offsets. + +## Evidence and scoring + +`src/chanmig/EvidenceStore.h` folds records into per-bin rings behind a trust +boundary: wrong plan hash, failure flags, implausible counter deltas (a +wrapped hardware counter reads orders of magnitude beyond the ~1000 +events/ms physical ceiling), stale records, unknown bins, and +foreign-`scout_id` records are all rejected by counted reason. Every accepted +fold bumps a global evidence generation; decisions cite the generation they +were computed at, so any output is reproducible from the record stream. + +## The recommendation engine + +`src/chanmig/ChannelScore.{h,cpp}` (`RecommendEngine`, pure and selftested) is +the advisory brain. Run it in-process with `DEVOURER_SCOUT_ADVISE=1` plus +`DEVOURER_SCOUT_ACTIVE=` (the live video channel) and +`DEVOURER_SCOUT_PRIMARY_FEED=` (the primary receiver's JSONL, tail- +followed as a plain append-only file — never a FIFO, so a wedged scout can +never stall video). It emits `channel.recommend` / `channel.hold` / +`channel.ranking` and structurally cannot retune anything. + +The recommendation law has two independent legs, kept in separate unit worlds +so nothing compares raw energy across the scout/primary boundary: + +- **leg 1 (the active channel)** — the primary receiver's *delivery* is + authoritative (scout energy on the live channel is confounded by the video's + own airtime). `src/chanmig/ActiveLink.h` classifies each primary window as + channel-impaired vs a fault domain that migration cannot fix: `WeakSignal` + (range — add power), `Saturation` (near-field overload — reduce power), + `TelemetryDown`. A recommendation needs *persistent* channel-attributable + impairment (`impaired_windows_min` consecutive windows), reusing LinkHealth's + EVM/RSSI verdict as the domain discriminator. +- **leg 2 (a candidate)** — the scout's occupancy is authoritative. A candidate + must be sufficiently observed (`min_rounds` distinct rounds, `min_clean_obs_ms` + per constituent bin, full-width coverage, fresh within `max_evidence_age_ms`), + legal (not `no_ir`), and *materially* clean: its occupancy must clear the + qualify bar by `improvement_margin` (hysteresis between "fine to sit on" and + "worth escaping to"). Occupancy is worst-bin `oth_air_frac` + a bounded + false-alarm term (NHM is excluded — its absolute floor is generation- + dependent); an active-adjacent candidate takes an overlap penalty. + +Anti-churn is structural: a broad degradation across most candidates holds +(`HoldBroadDegradation` — the interference isn't this channel's); a cooldown +rate-limits recommendations so two near-equal candidates cannot flip-flop; and +ties break deterministically by RF key. Every decision cites the evidence +generation, plan hash, and policy hash, so it is reproducible from the log. + +Thresholds live in `PolicyConfig`, loadable from a bare `key value` file via +`DEVOURER_SCOUT_POLICY` (the one deliberate departure from the env-var culture: +the exact policy artifact must be versionable, hashable, and identical across +the C++ engine, the ctest scenarios, and the replay tool). The `policy_hash` +in every event ties a decision to its threshold set. + +`tools/chanmig_dash.py` renders active-link health, the candidate ranking, +evidence age, and the counterfactual reason a move is / isn't recommended — +entirely from the two JSONL files. That it can is the acceptance proof that +every output is explainable from logged score components. + +`tests/chanmig_replay.py --confusion` replays a captured advise-mode log and +tallies the engine's own decisions against an independent primary-impairment +label (it never re-implements the policy, so a python shadow cannot drift from +the shipping engine). The single-trace confusion matrix reports the false-move +rate and possible misses; whether a recommended destination actually delivered +better needs the manual-move validation on a *held-out* run (never tune and +score thresholds on the same captures). + +## Validation + +- `ctest`: `chan_def_grid` (grid legality + grammar), `scan_plan_policy` + (coverage / cadence / starvation / determinism), `survey_aggregate` + (fold path: counter wrap, staleness, width coverage, mixed domains + + the JSONL round-trip). +- `tests/chanscout_bench.sh` — two-adapter smoke: full-plan coverage, dwell + hygiene, and the attribution split (a canonical-SA flood on one candidate + must land in `dvr_air_us` and out-rank the quiet bins). +- `tests/chanscout_stress.sh` — ≥10,000 dwell retunes in one run; asserts + zero retune failures, no retune-latency drift (p95 tail vs head), an + intact record stream, and a post-run `doctor` grade. +- `tests/chanmig_soak.sh` + `tests/chanmig_soak_analyze.py` — the + three-adapter A/B: ≥1 h video + scanning vs a no-scout control arm; + primary delivery (rx.txhit rate, seq-gap loss) must be statistically + unchanged. +- `ctest chan_score_policy` — the engine's two-leg policy over the ten offline + scenarios (persistent interferer → recommend; wanted-video-dominates-CCA → + hold-healthy; weak signal → not-congestion; saturation → reduce-power; + adjacent/overlap ordering; all-degrade → broad; burst → no-flap; stale; + cooldown; scout-down) plus age/tie-break/generation/policy-hash edges. +- `tests/chanmig_replay.py --confusion` — shadow-mode confusion matrix over a + captured advise run + primary log. diff --git a/docs/channel-migration-protocol.md b/docs/channel-migration-protocol.md new file mode 100644 index 0000000..df154dc --- /dev/null +++ b/docs/channel-migration-protocol.md @@ -0,0 +1,146 @@ +# Channel-migration protocol + +The coordinated, authenticated whole-link channel move between the ground +receiver and the drone video transmitter — the actuation layer under the +advisory engine (`docs/adaptive-channel-migration.md`). A migration is a rare, +deliberate event, not a per-round hop (that is FHSS, `docs/fhss.md`). + +## Authority + +Ground proposes; the **drone is the final schedule authority**. It validates +the target, fixes an absolute activation instant on its own TSF, and — the +load-bearing rule — **arms that activation only after it has seen the ground +echo its nonce** (the commit-ack). A commit the current ground never +acknowledged is never activated, so the drone can never strand itself on a +channel the ground is not following. The ground, symmetrically, never +unilaterally declares success: it **follows the drone's authoritative +STATUS**, reaching a channel only when the drone reports it settled there. +Those two rules are what make every failure-matrix row converge. + +``` +STABLE(old) -> PROPOSED -> COMMITTED -> SWITCHING -> VERIFYING -> CONFIRMED + (drone) (ground) + \-> (drone) VALIDATING \-> ROLLBACK / RECOVERY +``` + +## Wire format (`src/chanmig/MigWire.h`) + +Byte-packed little-endian, 8-byte header (`"CM"` magic, version, type, +link-id) + a per-type body + a trailing 8-byte **SipHash-2-4 MAC** over +everything preceding it. The control key is derived from `DEVOURER_MIG_KEY` +(the existing hop-seed parser), domain-separated from any schedule/link key. +Every message is ≤ 96 bytes — ~120 µs of 6M air. Seven types: SwitchProposal, +SwitchCommit, SwitchStatus, SwitchConfirm, SwitchAbort, MigMarker, +TargetValidation. + +Anti-replay is structural: epochs are random per process start and **never +persisted**, so a restart orphans every in-flight generation for free and +there is no persisted in-flight state at all (a boot never strands the next +run). A receiver adopts a peer epoch only from a PROPOSAL/STATUS, tracks the +max generation (a fresh epoch resets the ceiling), and binds COMMIT/CONFIRM to +the nonce it issued — so a replayed old exchange can never be acked by the +current peer. + +The wire format and key derivation are pinned by known-answer vectors +(`ctest chanmig_wire_kat`): exact bytes + MAC per message, a byte-flip tamper +sweep (every flip breaks auth), truncation, and the replay-window rules. + +## Transport + +Control frames are their **own 802.11 frames** (canonical-SA probe-req header + +the `CM` magic + the MigWire body), never bytes buried in the video/FEC PSDU. +The `examples/chanmig` demo (`--role ground|drone`) runs the adapter in duplex +(RX video + TX control on one claimed handle; J1/J3 are GO), stamps `tx_tsf` at +send, MACs, and transmits. Ground proposals ride unicast with `tx.report` for +MAC-layer delivery evidence (a host write is never treated as peer receipt). + +## Activation clock + +The drone fixes `activate_tsf` once, on its own TSF, at commit creation; +`tx_tsf` is re-stamped per repeat. The ground schedules its retune from the +**relative offset** `activate_tsf − commit.tx_tsf` (both drone-clock, so the +difference is a pure duration) — no clock translation needed, error is one +frame's one-way latency, absorbed by the lead + verify window. +`src/chanmig/MigClock.h` (`PeerClock`) additionally fits the drone↔ground TSF +via the existing `TsfSync` and rings the residuals, so the activation **guard** +is composed from the measured residual p99 + TX-drain p99.9 + worst retune + a +margin — never a fixed guess. `tests/chanmig_clock_bench.sh` measures the real +numbers on-air. + +## State machines (`MigProposer.h`, `MigResponder.h`) + +Pure classes: inputs are decoded MigMsgs + local observations + monotonic +`now` (+ the drone's own TSF, needed once to fix the activation); outputs are +`MigAction`s the demo executes. The demo owns all crypto and I/O. + +### Convergence invariants + +- **I1** neither endpoint retunes before adopting a nonce+generation-matched, + acknowledged commit. +- **I2** every post-activation path has a bounded route back to a channel in + the recovery scan set `{old, new, rescue}`. +- **I3** the ground follows the drone's authoritative STATUS; the drone rolls + back to source if unconfirmed by its deadline, and orphans its migration + (rolling back to source if already moved) on a ground-epoch change. +- **I4** random boot epochs orphan in-flight generations on any restart. +- **I5** MAC + nonce binding ⇒ a replayed/forged exchange never reaches an ack. + +### Failure matrix + +`ctest chanmig_proto_matrix` couples the two machines through a +drop/duplicate/reorder/delay LinkSim and drives 14 rows — drop each message +type (incl. the last commit pre-activation), duplicate/reorder/replay, restart +either endpoint mid-migration, silent/illegal target, drain stall, +control-path loss, delayed old-channel frames — plus an exhaustive +drop-every-single-message sweep. Every case converges both endpoints to a +shared channel in `{old, new, rescue}` within a bounded time; **none produces +a lasting split-brain**. + +## Validation + +- `ctest`: `chanmig_wire_kat`, `chanmig_proto_matrix`, `chanmig_clock_math` — + the exhaustive gate. The 14-row failure matrix + drop-every-message sweep + prove convergence without split-brain across every message loss, duplication, + reorder, restart, silent/illegal target, drain stall, and control-path loss. +- On-air: the wire codec is verified end-to-end on hardware — a proposal + transmitted by the ground decodes and authenticates on the drone. Two bugs + the on-air path surfaced and that the headless tests now pin: the RX frame + carries a **trailing FCS** after the message (so `mig_decode` locates the MAC + after the fixed body and ignores the trailer — every on-air frame read + `bad_mac` before this), and the demo drives the single-threaded machines from + the RX callback + tick loop + operator thread, so every entry point takes + `g_sm_mu` (a lost commit was the tell). +- `tests/chanmig_bench.sh` / `chanmig_endurance.sh` — first-light + the + ≥1,000-cycle acceptance. Both need a **decoupled** link: the two adapters + must be far enough apart (or attenuated) that the RX front end is not + saturated (`link.health` HEALTHY, not SATURATED), and rtw88/rtw89 must be + unbound from both dongles (they auto-probe on every USB enumeration — the + scripts do this, but a bare `rxdemo` run must too). On the ~30 cm bench the + primary link runs 90 %+ lost and the reverse control path is intermittent, so + the endurance is not meaningful there; run it on a spaced/attenuated pair. + +## Autonomous policy (`src/chanmig/MigGate.h`) + +`DEVOURER_MIG_MODE=off|advisory|manual|automatic` (default advisory) gates the +advisory engine's recommendation into the protocol. The gate is pure and +deterministic (no clock reads, no RNG — the same input trace replays to the +same decisions, `ctest chanmig_gate_policy` + `ChanMigReplay`), and it +proposes only when a conjunction holds: a genuine channel-attributable +impairment (not weak-signal / saturation / broad-degradation — those map to +`FAULT_*` holds), a sufficiently-confident recommendation, healthy time-synced +telemetry and a fresh scout, a verified rescue channel and enough control-link +margin to complete the exchange, and no anti-oscillation bar tripped +(`COOLDOWN`, `RESIDENCY`, `MOVE_CAP`, per-channel `HOLDDOWN_CHANNEL` with +exponential backoff after a rollback). Migrations are exceptional; stable +single-channel video is the normal state. + +Anti-oscillation state is carried across decisions and kept honest by +`mig_gate_on_confirmed` / `mig_gate_on_rolledback` (residency start, move +count, rollback streak → holddown backoff). A material change in the frozen +evidence while a proposal is in flight aborts it for a rescore +(`AbortInFlight`); a probation window after a move rolls back on poor +destination delivery (`ProposeRollback`). Operator controls (stdin, echoed as +`migrate.op`): `mode`, `approve-next`, `inhibit `, `pin |none`, +`rollback`, `rescue`, `status`/`why`. `DEVOURER_MIG_SHADOW` makes the gate +decide-but-not-actuate (the on-air ladder's first rung, quantifying false +moves without touching the live link — `tests/chanmig_gate_onair.sh`). diff --git a/docs/channel-migration-validation.md b/docs/channel-migration-validation.md new file mode 100644 index 0000000..2960e95 --- /dev/null +++ b/docs/channel-migration-validation.md @@ -0,0 +1,70 @@ +# Drone-side target validation + +Optional drone-side validation of a ground-proposed migration destination +before the drone commits (issue #280). Ground evidence stays authoritative for +downlink video quality; the drone's checks are a conservative veto/diagnostic +for a locally busy, unsupported, or unsafe destination — never an independent +channel-selection state machine, and never an override of good ground delivery +evidence merely because the drone's local energy differs. + +## Why this is optional + +The ground receiver is the endpoint whose video delivery matters, and +interference is asymmetric — a channel clean at the drone can be bad at ground +and vice versa. So drone sensing must not veto a move the ground needs merely +because the drone's local occupancy is unremarkable. The genuinely useful drone +checks are narrow: is the target legal and supported by the drone's actual +radio, and is it so locally saturated that TX would immediately fail. + +## Variants (and where each lives) + +- **A — validation-only baseline (default, the product answer).** + `MigResponder::validate_target`: legality (grid), band/width vs `MigCaps`, the + operator allowed-list, and no-IR exclusion. Synchronous, zero cost. This is + the recommended production behaviour — an illegal/unsupported target is + always rejected before commit, and that is the whole benefit worth its cost. +- **B — single-radio pre-commit probe (research, `DEVOURER_MIG_PROBE=1`).** + A pure, abort-safe sub-sequence in the responder's `VALIDATING` state: pause + the pump → drain → retune to the target → settle → sample `GetRxEnergy` → + **always return to source before deciding** → resume → veto iff the measured + occupancy exceeds `DEVOURER_MIG_VETO_BUSY` (0.6). Every retune has a failure + edge (return to source; if that fails, rescue + reject), so a failed probe + can never strand the drone off-source. The cost is a real video outage — two + retunes plus the settle+dwell — measured by `tests/chanmig_probe_outage.sh`. +- **C — post-switch probation.** No new machinery: the #279 gate's probation + window already samples destination delivery after a move and rolls back + (`ProposeRollback`) on poor delivery, without a second rollback authority. +- **D — dedicated airborne scout.** Not wired as a product path — it would ride + the stage-1 `chanscout` on a second airborne adapter, and the mass / power / + USB / RF-self-interference cost is not assumed to transfer from the ground + dual-radio result. Bench-only. + +The drone reports its result as a `TargetValidation` control frame; the ground +logs it (`migrate.validation`) and flags a `migrate.disagree` when the drone +vetoes a target the ground's scout called clean — **reporting only.** The +ground never applies a mask or target from it: ground authority for downlink +quality, drone authority only to veto a locally-unsafe destination. `unknown` +(no valid energy) does not manufacture confidence — it follows +`DEVOURER_MIG_UNKNOWN` policy (default: proceed on ground authority). + +## The decision + +**Variant A is the recommended production result.** It is free, it prevents +the one class of failure the drone is uniquely positioned to catch (an illegal +or radio-unsupported target), and it never vetoes a beneficial move on +asymmetric local energy. Variant B buys a marginal reduction in bad moves at a +real, measured video-outage cost every probe, and only helps when the target is +bad *at the drone* — precisely the case ground delivery evidence and the #279 +probation (variant C) already cover after the fact, without the outage. A no-go +on airborne RF sensing beyond A + C is an acceptable — and, on the measured +cost, the preferred — outcome. B and D remain in-tree as opt-in research so the +trade can be re-measured on airframes where the outage budget differs. + +## Validation + +- `ctest chanmig_proto_matrix` includes the probe rows: accept → migrates, veto + → stays on source, probe-retune-failure → safe return, no split-brain. +- `tests/chanmig_probe_outage.sh` measures the per-probe video outage + (ground-side rx.frame gap p50/p99) and the asymmetric-interference cases (a + B210 parked at the drone-only vs ground-only) → false-veto / prevented-failure + counts per variant. diff --git a/docs/logging.md b/docs/logging.md index 69fcc8c..29fc56b 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -94,7 +94,7 @@ Emitters: L = library, RX/TX/... = demo. Optional fields in [brackets]; | `rx.path` | RX (`DEVOURER_RX_ALLPATHS`) | seq, rssi[4], snr[4], evm[4] | | `rx.path_mask` | L (toggle spec) | t, mask "0xNN" | | `rx.scrambler` | RX (`DEVOURER_DUMP_SCRAMBLER`) | seed "0xNN", rate, hits, len | -| `rx.energy` | RX (`DEVOURER_RX_ENERGY_MS` / sweep) | t, [ch], cca_ofdm\|null, cca_cck\|null, fa_ofdm\|null, fa_cck\|null, igi\|null, abs_noise_floor_dbm\|null, [retune_us], frames, frames_ldpc, frames_stbc, rssi_mean, rssi_max, snr_mean, snr_min, evm_mean | +| `rx.energy` | RX (`DEVOURER_RX_ENERGY_MS` / sweep) | t, [ch], cca_ofdm\|null, cca_cck\|null, fa_ofdm\|null, fa_cck\|null, igi\|null, abs_noise_floor_dbm\|null, [retune_us], frames, frames_ldpc, frames_stbc, crc_err, icv_err, rssi_mean, rssi_max, snr_mean, snr_min, evm_mean — crc/icv nonzero only under `DEVOURER_RX_KEEP_CORRUPTED` (the parser drops failed frames otherwise) | | `rx.nhm` | RX | [ch], peak, busy, dur, hist[12] | | `rx.quality` | RX (`DEVOURER_RXQUALITY`) | verdict, frames, rssi_mean_dbm, rssi_max_dbm, snr_mean_db, snr_min_db, evm_db\|null, noise_floor_dbm\|null, abs_noise_floor_dbm\|null, igi | | `adapter.rxpaths` | RX (`DEVOURER_RXQUALITY`) | active_mask "0xNN", n_active, n_chains, frames, rssi_dbm[] — GetActiveRxPaths live per-chain activity (the caps rx_chains companion) | @@ -128,6 +128,32 @@ Emitters: L = library, RX/TX/... = demo. Optional fields in [brackets]; | `hop.dwell` | TX, duplex | dwell, round, channel, frame, switch_us, t_ms, [mode] | | `hop.done` | TX, duplex | frames, dwells | +### Channel migration (chanscout — docs/adaptive-channel-migration.md) +| ev | emitter | fields | +|---|---|---| +| `scout.id` | chanscout | t, role, usb_id "vvvv:pppp", bus, port, usb_speed, chip, gen, scout_id "0x…" (calibration-domain key), [note] | +| `scout.plan` | chanscout | t, v, plan "0x…" (immutable plan hash), epoch_unix_ms (one realtime sample for offline stream alignment), dwell_ms, settle_ms, backup_ms, bg_ms, fullwidth_ms, max_age_ms, n_candidates, spec | +| `scout.cand` | chanscout | i, chan (canonical "band:primary/width[u\|l]"), center_mhz, backup, no_ir, dfs, bins[] | +| `survey.dwell` | chanscout (`src/chanmig/SurveyJsonl.h` owns the schema) | v, seq, chan, round, plan "0x…", start_ms, end_ms, retune_us, settle_ms, observe_ms, cca_ofdm\|null, cca_cck, fa_ofdm, fa_cck, igi\|null, nhm_busy\|null, nhm_peak, nhm_dur, nhm[12], frames, rssi_mean, rssi_max, snr_mean, snr_min, evm_mean\|null, dvr_frames, dvr_air_us, oth_air_us (canonical-SA vs foreign decoded-airtime split), flags (validity bitmask), scout_id "0x…", agen | +| `scout.health` | chanscout | t, state (ok\|degraded\|wedged\|fatal), reason (retune_fail\|energy_read_fail\|rx_stalled\|usb_congested\|thermal\|stale_survey\|plan_error), detail — emitted on state transitions only | +| `channel.recommend` | chanscout (`DEVOURER_SCOUT_ADVISE`) | t, v, gen "0x…", plan "0x…", policy "0x…", from, to, reason (RecommendBetterCandidate), active_verdict, active_domain, impaired_windows, score, conf, occ, rounds, obs_ms, text — advisory only, retunes nothing | +| `channel.hold` | chanscout (`DEVOURER_SCOUT_ADVISE`) | same envelope as recommend (no to/score), reason (HoldActiveHealthy\|HoldImpairmentNotPersistent\|HoldImpairmentNotChannel\|HoldBroadDegradation\|HoldCooldown\|HoldPrimaryTelemetryStale\|HoldNoQualifiedCandidate\|HoldImprovementMargin\|HoldScoutUnhealthy), text = the counterfactual | +| `channel.ranking` | chanscout (`DEVOURER_SCOUT_ADVISE`) | t, gen "0x…", n, c0..cN ("chan q=<0\|1> score occ rej") — the full per-candidate ranking, so a dashboard renders the counterfactual from the log alone | +| `channel.reject` | chanscout (`DEVOURER_SCOUT_ADVISE`) | t, gen "0x…", chan, reason (Rej*), occ, age_ms, rounds | +| `migrate.id` | chanmig | t, role (ground\|drone), chip, source, link "0x…", epoch "0x…" | +| `migrate.state` | chanmig | t, role, code (MigState value) — one per state transition | +| `migrate.retune` | chanmig | t, role, chan — a retune executed at activation / follow / recovery | +| `migrate.op` | chanmig (ground) | t, cmd — an operator command echoed for audit (stdin is local) | +| `migrate.status` | chanmig (ground) | t, state, chan, clock_ready, resid_p99_us — on the `status` operator command | +| `migrate.done` | chanmig | t, role, code (0=confirmed on new, 1=held/rolled-back to old, 2=rescue) | +| `migrate.gate_notify` | chanmig | t, role, result (0=confirmed, 1=rolledback) — for the #279 automation gate | +| `migrate.drop` | chanmig (`DEVOURER_MIG_DROP`/`_DROP_RX`) | dir (tx\|rx), type — a deterministic fault-injection drop | +| `migrate.gate` | chanmig (ground, `DEVOURER_MIG_MODE`) | t, mode, verdict (0=hold,1=propose,2=abort,3=rollback), reason (GATE reason string) — the #279 automation gate decision | +| `migrate.shadow` | chanmig (`DEVOURER_MIG_SHADOW`) | t, to, gen — a gate Propose recorded but NOT actuated (shadow-actuation ladder rung) | +| `migrate.probe` | chanmig (drone, `DEVOURER_MIG_PROBE`) | t, busy, valid — a variant-B pre-commit probe's measured destination occupancy | +| `migrate.validation` | chanmig (ground) | t, chan, method (0=checks,1=probe,2=probation,3=scout), result (0=accept,1=veto,2=unknown), reason, nhm_busy, valid — the drone's TargetValidation, reporting-only | +| `migrate.disagree` | chanmig (ground) | t, chan, ground_view, drone_view — the drone vetoed a target the ground's scout called clean (asymmetric-interference diagnostic) | + ### Beamforming / CSI | ev | emitter | fields | |---|---|---| diff --git a/examples/chanmig/main.cpp b/examples/chanmig/main.cpp new file mode 100644 index 0000000..71ca775 --- /dev/null +++ b/examples/chanmig/main.cpp @@ -0,0 +1,733 @@ +/* chanmig — the coordinated channel-migration endpoint (issue #278). + * + * --role ground : owns the primary RX (duplex: RX video + TX proposals on one + * claimed adapter), runs the MigProposer + PeerClock, takes + * operator commands on stdin, and follows the drone's + * authoritative status. + * --role drone : the video TX. Runs the MigResponder, is the final schedule + * authority, arms its ACK responder, and airs generation- + * tagged markers on the destination at activation. + * + * Control frames are their OWN 802.11 frames (canonical-SA probe-req header + + * the "CM" magic + an authenticated MigWire message) — video PSDUs are never + * touched, satisfying "do not bury migration commands in caller-owned payload + * bytes". The pure state machines (src/chanmig/Mig*.h) do all the logic; this + * binary only stamps TSFs, encodes/MACs, transmits, retunes, and emits the + * migrate.* JSONL. Both endpoints derive a control key from DEVOURER_MIG_KEY. + * + * Env (demo-local): DEVOURER_MIG_ROLE (or --role), DEVOURER_MIG_KEY (control + * key hex), DEVOURER_MIG_ALLOWED (drone allowed-channel list), + * DEVOURER_MIG_RESCUE, DEVOURER_MIG_LINK (link id), DEVOURER_MIG_SYNTH_PPS/ + * _LEN (drone synthetic video), DEVOURER_MIG_LEAD_MS, DEVOURER_MIG_DROP / + * _DROP_RX (deterministic fault filters), DEVOURER_CHANNEL (start channel), + * plus the usual device-selection vars. */ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "RadiotapBuilder.h" +#include "RxPacket.h" +#include "SignalStop.h" +#include "UsbOpen.h" +#include "WiFiDriver.h" +#include "chanmig/ChannelDef.h" +#include "chanmig/JsonlLite.h" +#include "chanmig/MigClock.h" +#include "chanmig/MigConfig.h" +#include "chanmig/MigGate.h" +#include "chanmig/MigProposer.h" +#include "chanmig/MigResponder.h" +#include "chanmig/MigWire.h" +#include "env_config.h" +#include "logger.h" +#include "usb_select.h" + +namespace cm = devourer::chanmig; +using devourer::Ev; + +static devourer::EventSink *g_ev = nullptr; +static IRtlDevice *g_dev = nullptr; +static std::mutex g_dev_mu; /* serialize send/retune against the RX thread */ +/* The pure state machines are single-threaded by design; the demo drives them + * from the RX callback, the tick loop, and (ground) the operator thread, so + * every machine entry point takes this lock. Ordering: g_sm_mu before g_dev_mu + * (send/retune/read_tsf take g_dev_mu inside an already-held g_sm_mu). */ +static std::recursive_mutex g_sm_mu; +static cm::MigKey g_key; +static uint32_t g_link = 0; +static std::atomic g_stop{false}; +static const uint8_t kCanonicalSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; + +/* deterministic fault filters (demo-side, for the on-air failure scripts) */ +static std::vector g_drop_tx, g_drop_rx; +static int g_tx_idx[8] = {}, g_rx_idx[8] = {}; + +static long long now_ms() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +static std::vector probe_req_hdr() { + std::vector h = {0x40, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff}; + h.insert(h.end(), kCanonicalSa, kCanonicalSa + 6); + h.insert(h.end(), kCanonicalSa, kCanonicalSa + 6); + h.push_back(0x80); + h.push_back(0x00); + return h; +} + +/* Build + transmit one migration control/marker frame: robust 6M radiotap + + * probe-req header + the authenticated MigWire bytes. The caller has already + * stamped tx_tsf. Honors the TX drop filter (deterministic fault injection). */ +static void tx_mig(const cm::MigMsg &m) { + if (cm::drop_matches(g_drop_tx, m.type, ++g_tx_idx[m.type & 7])) { + Ev(*g_ev, "migrate.drop").f("dir", "tx").f("type", m.type); + return; + } + std::vector frame = + devourer::build_stream_radiotap(devourer::parse_tx_mode_str("6M")); + const auto hdr = probe_req_hdr(); + frame.insert(frame.end(), hdr.begin(), hdr.end()); + const auto body = cm::mig_encode(m, g_key); + frame.insert(frame.end(), body.begin(), body.end()); + std::lock_guard lk(g_dev_mu); + if (g_dev) + g_dev->send_packet(frame.data(), frame.size()); +} + +/* Emit a migrate.state / migrate.* event for a MigAction of the logging kind. */ +static void emit_action(const char *role, const cm::MigAction &a) { + if (a.kind == cm::MigAction::EmitEvent) { + Ev(*g_ev, "migrate.state") + .t() + .f("role", role) + .f("code", a.code); + } else if (a.kind == cm::MigAction::GateNotify) { + Ev(*g_ev, "migrate.gate_notify").t().f("role", role).f("result", a.code); + } else if (a.kind == cm::MigAction::Done) { + Ev(*g_ev, "migrate.done").t().f("role", role).f("code", a.code); + } +} + +/* ---------------- ground ---------------- */ + +struct Ground { + cm::MigProposer prop; + cm::PeerClock clock; + cm::MigParams params; + cm::ChannelDef source, rescue; + /* #279 automation gate */ + cm::MigMode mode = cm::MigMode::Advisory; + cm::GateState gate_state; + cm::GatePolicy gate_policy; + cm::Decision latest_rec; /* reconstructed from the recommend feed */ + bool have_rec = false; + int64_t rec_age_ms = 0; /* when the latest rec arrived (monotonic) */ + bool in_flight = false; + int64_t inhibit_until_ms = INT64_MIN; + bool approve_next = false; + bool pinned = false; + cm::ChannelDef pinned_ch; + Ground(cm::MigParams p, uint32_t epoch, cm::ChannelDef src, cm::ChannelDef rsc) + : prop(p, g_link, epoch, src), params(p), source(src), rescue(rsc) {} +}; +static Ground *g_ground = nullptr; + +static void ground_do(const std::vector &acts) { + for (const cm::MigAction &a : acts) { + switch (a.kind) { + case cm::MigAction::SendUnicast: + case cm::MigAction::SendBroadcast: { + cm::MigMsg m = a.msg; /* ground stamps no tx_tsf (drone owns the clock) */ + tx_mig(m); + break; + } + case cm::MigAction::RetuneTo: { + Ev(*g_ev, "migrate.retune").t().f("role", "ground") + .f("chan", a.channel.str().c_str()); + { + std::lock_guard lk(g_dev_mu); + if (g_dev) + g_dev->SetMonitorChannel(a.channel.to_selected()); + } + ground_do(g_ground->prop.on_retune_done(now_ms())); + break; + } + case cm::MigAction::GateNotify: + /* keep the autonomous-gate anti-oscillation state honest */ + if (a.code == 0) + cm::mig_gate_on_confirmed(g_ground->gate_state, + g_ground->prop.current_channel(), now_ms()); + else + cm::mig_gate_on_rolledback(g_ground->gate_state, + g_ground->prop.current_channel(), + g_ground->gate_policy, now_ms()); + g_ground->in_flight = false; + emit_action("ground", a); + break; + default: + emit_action("ground", a); + break; + } + } +} + +/* Parse one channel.recommend / channel.hold feed line into the ground's + * latest advisory Decision (a minimal reconstruction — target, gen, score, + * confidence — sufficient for the gate). */ +static void ground_ingest_rec(std::string_view line, int64_t now) { + Ground &g = *g_ground; + const bool rec = devourer::chanmig::jsonl_ev_is(line, "channel.recommend"); + const bool hold = !rec && devourer::chanmig::jsonl_ev_is(line, "channel.hold"); + if (!rec && !hold) + return; + cm::Decision d; + std::string s, hexs; + long long iv; + double dv; + if (devourer::chanmig::jsonl_str(line, "gen", &hexs)) + d.evidence_gen = std::strtoull(hexs.c_str(), nullptr, 16); + else if (devourer::chanmig::jsonl_int(line, "gen", &iv)) + d.evidence_gen = static_cast(iv); + if (rec && devourer::chanmig::jsonl_str(line, "to", &s)) { + std::string err; + cm::ChannelDef tgt; + if (!cm::parse_chan_token(s, tgt, err)) + return; + d.kind = cm::Decision::Kind::Recommend; + d.target = tgt; + d.primary_reason = cm::Reason::RecommendBetterCandidate; + cm::CandidateScore c; + c.def = tgt; + c.qualified = true; + c.score = devourer::chanmig::jsonl_num(line, "score", &dv) ? dv : 1.0; + c.confidence = devourer::chanmig::jsonl_num(line, "conf", &dv) ? dv : 1.0; + d.ranking.push_back(c); + } else { + d.kind = cm::Decision::Kind::Hold; + } + g.latest_rec = d; + g.have_rec = true; + g.rec_age_ms = now; +} + +/* Run the automation gate on a cadence; in automatic/manual mode a Propose + * verdict starts a migration through the proposer. Emits migrate.gate. */ +static void ground_gate_tick(int64_t now) { + Ground &g = *g_ground; + if (g.mode == cm::MigMode::Off || g.mode == cm::MigMode::Advisory) + return; + cm::GateInputs in; + in.mode = g.mode; + in.rec = g.have_rec ? &g.latest_rec : nullptr; + in.scout_survey_age_ms = g.have_rec ? now - g.rec_age_ms : INT64_MAX; + in.scout_max_age_ms = 5000; + in.clock_synced = g.clock.ready(); + in.control_link_margin = 1.0; /* status/ack margin — simplified for the demo */ + in.in_flight = g.in_flight || + g.prop.state() != cm::MigState::Stable; + in.approve_next = g.approve_next; + in.inhibit_until_ms = g.inhibit_until_ms; + in.pinned = g.pinned ? &g.pinned_ch : nullptr; + cm::GateOutcome o = cm::mig_gate_decide(in, g.gate_state, g.gate_policy, now); + Ev(*g_ev, "migrate.gate").t().f("mode", cm::mig_mode_name(g.mode)) + .f("verdict", static_cast(o.verdict)) + .f("reason", cm::gate_reason_name(o.reason)); + if (o.verdict == cm::GateVerdict::Propose) { + g.approve_next = false; + /* Shadow actuation (DEVOURER_MIG_SHADOW): the gate decides and the verdict + * is recorded, but no proposal is actually sent — the #279 ladder's first + * rung, quantifying false moves without touching the live link. */ + static const bool shadow = std::getenv("DEVOURER_MIG_SHADOW") != nullptr; + if (shadow) { + Ev(*g_ev, "migrate.shadow").t().f("to", o.target.str().c_str()) + .f("gen", (unsigned long long)o.evidence_gen); + return; + } + g.in_flight = true; + ground_do(g.prop.start(o.target, static_cast(o.evidence_gen), + g.rescue, now)); + } +} + +static void ground_rx(const Packet &pkt) { + if (pkt.RxAtrib.pkt_rpt_type == RX_PACKET_TYPE::C2H_PACKET) + return; + if (pkt.Data.size() < 24 + cm::kMigHeader) + return; + const uint8_t *body = pkt.Data.data() + 24; + const size_t blen = pkt.Data.size() - 24; + cm::MigMsg m; + if (cm::mig_decode(body, blen, g_key, g_link, m) == cm::MigReason::None) { + if (cm::drop_matches(g_drop_rx, m.type, ++g_rx_idx[m.type & 7])) + return; + std::lock_guard lk(g_sm_mu); + /* feed the clock from the drone's TSF-stamped frames */ + if (m.tx_tsf != 0) + g_ground->clock.add(m.tx_tsf, pkt.RxAtrib.tsfl); + /* #280: the drone's target-validation result is reporting-only — the + * ground logs it and flags a disagreement with its own scout evidence, + * but never applies a mask/target from it (ground authority for downlink + * quality; drone authority only to veto a locally-unsafe destination). */ + if (m.type == cm::MT_VALIDATION) { + Ev(*g_ev, "migrate.validation").t().f("chan", m.target.str().c_str()) + .f("method", m.method).f("result", m.result) + .f("reason", cm::mig_reason_name(static_cast(m.reason))) + .f("nhm_busy", m.nhm_busy_pct).f("valid", m.energy_valid); + if (m.result == 1 && g_ground->have_rec && + g_ground->latest_rec.kind == cm::Decision::Kind::Recommend && + g_ground->latest_rec.target.same_rf(m.target)) + Ev(*g_ev, "migrate.disagree").t().f("chan", m.target.str().c_str()) + .f("ground_view", "clean").f("drone_view", "veto"); + return; + } + ground_do(g_ground->prop.on_message(m, now_ms())); + return; + } + /* not a control frame — canonical-SA video counts toward verify */ + if (pkt.Data.size() >= 16 && + std::memcmp(pkt.Data.data() + 10, kCanonicalSa, 6) == 0) { + std::lock_guard lk(g_sm_mu); + ground_do(g_ground->prop.on_video(now_ms())); + } +} + +/* ---------------- drone ---------------- */ + +struct Drone { + cm::MigResponder resp; + cm::MigParams params; + std::atomic markers_on{false}; + cm::ChannelDef marker_ch; + uint32_t marker_seq = 0; + std::atomic pump_on{true}; + Drone(cm::MigParams p, uint32_t epoch, cm::ChannelDef cur, cm::MigCaps caps) + : resp(p, g_link, epoch, cur, caps), params(p) {} +}; +static Drone *g_drone = nullptr; + +static uint64_t read_tsf() { + std::lock_guard lk(g_dev_mu); + return g_dev ? g_dev->ReadTsf() : 0; +} + +static void drone_do(const std::vector &acts) { + for (const cm::MigAction &a : acts) { + switch (a.kind) { + case cm::MigAction::SendUnicast: + case cm::MigAction::SendBroadcast: { + cm::MigMsg m = a.msg; + m.tx_tsf = read_tsf(); /* stamp at send time */ + tx_mig(m); + break; + } + case cm::MigAction::RetuneTo: { + Ev(*g_ev, "migrate.retune").t().f("role", "drone") + .f("chan", a.channel.str().c_str()); + bool ok = true; + { + std::lock_guard lk(g_dev_mu); + try { + if (g_dev) + g_dev->SetMonitorChannel(a.channel.to_selected()); + } catch (const std::exception &) { + ok = false; + } + } + drone_do(g_drone->resp.on_retune_done(ok, now_ms(), read_tsf())); + break; + } + case cm::MigAction::StartDrain: + g_drone->pump_on.store(false); + drone_do(g_drone->resp.on_drain_done(now_ms())); + break; + case cm::MigAction::ResumePump: + g_drone->pump_on.store(true); + break; + case cm::MigAction::ArmMarkers: + g_drone->marker_ch = a.channel; + g_drone->markers_on.store(true); + break; + case cm::MigAction::StopMarkers: + g_drone->markers_on.store(false); + break; + case cm::MigAction::EmitEvent: + /* the variant-B probe asks us to sample the destination now (we are on + * the target channel after the probe retune + a settle). */ + if (a.code == 200) { + static const int settle_ms = + std::getenv("DEVOURER_MIG_PROBE_SETTLE_MS") + ? std::atoi(std::getenv("DEVOURER_MIG_PROBE_SETTLE_MS")) + : 5; + static const int dwell_ms = + std::getenv("DEVOURER_MIG_PROBE_DWELL_MS") + ? std::atoi(std::getenv("DEVOURER_MIG_PROBE_DWELL_MS")) + : 30; + std::this_thread::sleep_for(std::chrono::milliseconds(settle_ms)); + double busy = 0.0; + bool valid = false; + { + std::lock_guard lk(g_dev_mu); + if (g_dev) { + (void)g_dev->GetRxEnergy(); /* reset the delta counters */ + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(dwell_ms)); + { + std::lock_guard lk(g_dev_mu); + if (g_dev) { + RxEnergy e = g_dev->GetRxEnergy(); + if (e.valid_nhm) { + uint32_t total = 0; + for (int k = 0; k < 12; k++) + total += e.nhm[k]; + busy = total ? static_cast(total - e.nhm[0]) / total : 0.0; + valid = true; + } else if (e.valid_fa) { + const double sec = dwell_ms / 1000.0; + busy = (e.cca_ofdm / (sec > 0 ? sec : 1)) / 1000.0; + if (busy > 1.0) + busy = 1.0; + valid = true; + } + } + } + Ev(*g_ev, "migrate.probe").t().f("busy", busy).f("valid", valid); + drone_do(g_drone->resp.on_probe_sample(busy, valid, now_ms())); + } else { + emit_action("drone", a); + } + break; + default: + emit_action("drone", a); + break; + } + } +} + +static void drone_rx(const Packet &pkt) { + if (pkt.RxAtrib.pkt_rpt_type == RX_PACKET_TYPE::C2H_PACKET) + return; + if (pkt.Data.size() < 24 + cm::kMigHeader) + return; + cm::MigMsg m; + /* The RX frame carries a trailing FCS after the message, so mig_decode reads + * the fixed-length body and locates the MAC after it (trailer ignored). */ + if (cm::mig_decode(pkt.Data.data() + 24, pkt.Data.size() - 24, g_key, g_link, + m) == cm::MigReason::None) { + if (cm::drop_matches(g_drop_rx, m.type, ++g_rx_idx[m.type & 7])) + return; + /* Read the TSF (a USB control transfer) OUTSIDE the SM lock so it never + * blocks the machine behind the pump thread's send. */ + const uint64_t tsf = read_tsf(); + std::lock_guard lk(g_sm_mu); + drone_do(g_drone->resp.on_message(m, now_ms(), tsf)); + } +} + +/* ---------------- shared helpers ---------------- */ + +static cm::ChannelDef chan_from_env(const char *name, cm::ChannelDef def) { + const char *e = std::getenv(name); + if (!e || !*e) + return def; + cm::ChannelDef d; + std::string err; + if (cm::parse_chan_token(e, d, err)) + return d; + return def; +} + +int main(int argc, char **argv) { + auto logger = std::make_shared(); + apply_logging_env(*logger); + g_ev = &logger->events(); + install_devourer_signal_handlers(); + + std::string role; + for (int i = 1; i + 1 < argc; i++) + if (std::strcmp(argv[i], "--role") == 0) + role = argv[i + 1]; + if (role.empty()) + if (const char *r = std::getenv("DEVOURER_MIG_ROLE")) + role = r; + if (role != "ground" && role != "drone") { + logger->error("chanmig needs --role ground|drone (or DEVOURER_MIG_ROLE)"); + return 2; + } + + /* control key + link id */ + const char *keytext = std::getenv("DEVOURER_MIG_KEY"); + g_key = cm::MigKey::derive(keytext ? devourer::HopSchedule::parse_seed(keytext) + : devourer::HopSchedule::Key{}); + g_link = 0xC1; + if (const char *l = std::getenv("DEVOURER_MIG_LINK")) + g_link = static_cast(std::strtoul(l, nullptr, 0)); + if (const char *d = std::getenv("DEVOURER_MIG_DROP")) + cm::parse_drop_spec(d, g_drop_tx); + if (const char *d = std::getenv("DEVOURER_MIG_DROP_RX")) + cm::parse_drop_spec(d, g_drop_rx); + + int channel = 36; + if (const char *c = std::getenv("DEVOURER_CHANNEL")) + channel = std::atoi(c); + cm::ChannelDef source; + source.band = channel <= 14 ? 2 : 5; + source.primary = static_cast(channel); + source.width = CHANNEL_WIDTH_20; + cm::normalize(source); + cm::ChannelDef rescue = chan_from_env("DEVOURER_MIG_RESCUE", source); + + cm::MigParams params; + if (const char *l = std::getenv("DEVOURER_MIG_LEAD_MS")) + params.lead_ms = std::atoll(l); + + /* random boot epoch (never persisted — orphans stale commits for free) */ + uint32_t epoch; + { + FILE *ur = std::fopen("/dev/urandom", "rb"); + if (!ur || std::fread(&epoch, sizeof(epoch), 1, ur) != 1) + epoch = static_cast(now_ms()); + if (ur) + std::fclose(ur); + epoch |= 1; + } + + /* open the adapter (duplex: RX + TX on one claimed handle) */ + libusb_context *ctx = nullptr; + if (libusb_init(&ctx) < 0) + return 1; + static constexpr uint16_t kPids[] = {0x8812, 0xc812, 0xa81a, 0x881a, + 0xc82c, 0xe822, 0x8813}; + UsbPick pick; + libusb_device_handle *handle = open_selected_usb( + ctx, logger, kPids, sizeof(kPids) / sizeof(kPids[0]), &pick); + if (!handle) { + libusb_exit(ctx); + return 1; + } + std::shared_ptr lock; + if (devourer::claim_interface_reset_reopen( + ctx, handle, logger, std::getenv("DEVOURER_SKIP_RESET") == nullptr, + lock) != 0) { + libusb_close(handle); + libusb_exit(ctx); + return 1; + } + /* Jaguar3 needs the RX filters opened at bring-up for reliable duplex. */ +#ifdef _WIN32 + _putenv_s("DEVOURER_TX_WITH_RX", "thread"); +#else + ::setenv("DEVOURER_TX_WITH_RX", "thread", 1); +#endif + WiFiDriver driver(logger); + auto dev = driver.CreateRtlDevice(handle, ctx, lock, devourer_config_from_env()); + if (!dev) { + logger->error("no driver for this chip"); + return 1; + } + g_dev = dev.get(); + + Ev(*g_ev, "migrate.id").t().f("role", role.c_str()) + .f("chip", pick.pid).f("source", source.str().c_str()) + .hexf("link", g_link, 0).hexf("epoch", epoch, 8); + + if (role == "drone") { + cm::MigCaps caps; + if (const char *al = std::getenv("DEVOURER_MIG_ALLOWED")) { + std::vector errs; + cm::parse_mig_allowed(al, caps.allowed, errs); + } + /* #280 variant B: opt-in single-radio pre-commit probe (research). Off by + * default — variant A (legality/caps checks) is the product baseline. */ + caps.probe = std::getenv("DEVOURER_MIG_PROBE") != nullptr; + if (const char *v = std::getenv("DEVOURER_MIG_VETO_BUSY")) + caps.veto_busy_frac = std::atof(v); + static Drone drone(params, epoch, source, caps); + g_drone = &drone; + dev->InitWrite(source.to_selected()); + /* link-derived unicast for the ACK responder */ + devourer::MacAddr am{{0x57, 0x42, 0x75, static_cast(g_link >> 8), + static_cast(g_link), 0x01}}; + dev->SetAckResponder(am); + + std::thread rx([&] { dev->StartRxLoop(drone_rx); }); + /* synthetic video pump */ + const int pps = std::getenv("DEVOURER_MIG_SYNTH_PPS") + ? std::atoi(std::getenv("DEVOURER_MIG_SYNTH_PPS")) + : 200; + const int plen = std::getenv("DEVOURER_MIG_SYNTH_LEN") + ? std::atoi(std::getenv("DEVOURER_MIG_SYNTH_LEN")) + : 200; + std::thread pump([&] { + auto rt = devourer::build_stream_radiotap(devourer_tx_mode_from_env()); + auto hdr = probe_req_hdr(); + std::vector f; + const int gap_us = pps > 0 ? 1000000 / pps : 5000; + while (!g_stop.load() && !g_devourer_should_stop) { + if (g_drone->pump_on.load()) { + f.clear(); + f.insert(f.end(), rt.begin(), rt.end()); + f.insert(f.end(), hdr.begin(), hdr.end()); + f.resize(f.size() + plen, 0xAB); + std::lock_guard lk(g_dev_mu); + if (g_dev) + g_dev->send_packet(f.data(), f.size()); + } + std::this_thread::sleep_for(std::chrono::microseconds(gap_us)); + } + }); + /* marker + tick loop */ + long long next_marker = 0; + while (!g_stop.load() && !g_devourer_should_stop) { + const long long t = now_ms(); + const uint64_t tsf = read_tsf(); /* USB control read OUTSIDE the SM lock */ + { + std::lock_guard lk(g_sm_mu); + drone_do(drone.resp.on_tick(t, tsf)); + if (drone.markers_on.load() && t >= next_marker) { + next_marker = t + 20; + cm::MigMsg mk; + mk.type = cm::MT_MARKER; + mk.link_id = g_link; + mk.drone_epoch = epoch; + mk.generation = drone.resp.generation(); + mk.aired_on = drone.marker_ch; + mk.tx_tsf = tsf; + tx_mig(mk); + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + g_stop.store(true); + dev->StopRxLoop(); + if (pump.joinable()) + pump.join(); + if (rx.joinable()) + rx.join(); + } else { + static Ground ground(params, epoch, source, rescue); + g_ground = &ground; + if (const char *m = std::getenv("DEVOURER_MIG_MODE")) { + std::string ms(m); + ground.mode = ms == "off" ? cm::MigMode::Off + : ms == "manual" ? cm::MigMode::Manual + : ms == "automatic" ? cm::MigMode::Automatic + : cm::MigMode::Advisory; + } + dev->InitWrite(source.to_selected()); + std::thread rx([&] { dev->StartRxLoop(ground_rx); }); + + /* operator command reader (stdin) — echoed as migrate.op for audit. */ + std::thread ops([&] { + char line[128]; + while (!g_stop.load() && std::fgets(line, sizeof(line), stdin)) { + std::string s(line); + while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) + s.pop_back(); + Ev(*g_ev, "migrate.op").t().f("cmd", s.c_str()); + const char *sp = std::strchr(s.c_str(), ' '); + if (s.rfind("propose", 0) == 0) { + cm::ChannelDef tgt; + std::string err; + if (sp && cm::parse_chan_token(sp + 1, tgt, err)) { + std::lock_guard lk(g_sm_mu); + ground.in_flight = true; + ground_do(ground.prop.start(tgt, 0, rescue, now_ms())); + } else + logger->warn("propose — bad target ({})", err); + } else if (s.rfind("mode", 0) == 0 && sp) { + std::string ms(sp + 1); + ground.mode = ms == "off" ? cm::MigMode::Off + : ms == "manual" ? cm::MigMode::Manual + : ms == "automatic" ? cm::MigMode::Automatic + : cm::MigMode::Advisory; + } else if (s == "approve-next" || s == "approve") { + ground.approve_next = true; + } else if (s.rfind("inhibit", 0) == 0 && sp) { + ground.inhibit_until_ms = now_ms() + std::atoll(sp + 1) * 1000; + } else if (s.rfind("pin", 0) == 0) { + cm::ChannelDef p; + std::string err; + if (sp && cm::parse_chan_token(sp + 1, p, err)) { + ground.pinned = true; + ground.pinned_ch = p; + } else + ground.pinned = false; /* "pin none" */ + } else if (s == "status" || s == "why") { + Ev(*g_ev, "migrate.status").t() + .f("state", cm::mig_state_name(ground.prop.state())) + .f("mode", cm::mig_mode_name(ground.mode)) + .f("chan", ground.prop.current_channel().str().c_str()) + .f("clock_ready", ground.clock.ready()) + .f("resid_p99_us", (long long)ground.clock.residual_p99()); + } + } + }); + + /* recommend feed follower (automatic/manual mode consumes it) */ + std::FILE *feed = nullptr; + std::string feed_buf, feed_path; + if (const char *fp = std::getenv("DEVOURER_MIG_RECOMMEND_FEED")) + feed_path = fp; + + int64_t next_gate = 0; + while (!g_stop.load() && !g_devourer_should_stop) { + const int64_t now = now_ms(); + std::unique_lock lk(g_sm_mu); + ground_do(ground.prop.on_tick(now)); + if (!feed_path.empty()) { + if (!feed) + feed = std::fopen(feed_path.c_str(), "r"); + if (feed) { + char chunk[2048]; + size_t n; + while ((n = std::fread(chunk, 1, sizeof(chunk), feed)) > 0) { + feed_buf.append(chunk, n); + size_t nl; + while ((nl = feed_buf.find('\n')) != std::string::npos) { + ground_ingest_rec(std::string_view(feed_buf.data(), nl), now); + feed_buf.erase(0, nl + 1); + } + } + std::clearerr(feed); + } + } + if (now >= next_gate) { + next_gate = now + 2000; + ground_gate_tick(now); + } + lk.unlock(); /* release before sleeping so the RX thread isn't starved */ + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + if (feed) + std::fclose(feed); + g_stop.store(true); + dev->StopRxLoop(); + if (ops.joinable()) + ops.detach(); /* blocked on fgets; process exit reaps it */ + if (rx.joinable()) + rx.join(); + } + + { + std::lock_guard lk(g_dev_mu); + g_dev = nullptr; + } + dev->Stop(); + libusb_release_interface(handle, 0); + libusb_close(handle); + libusb_exit(ctx); + return 0; +} diff --git a/examples/chanscout/main.cpp b/examples/chanscout/main.cpp new file mode 100644 index 0000000..bf28e10 --- /dev/null +++ b/examples/chanscout/main.cpp @@ -0,0 +1,678 @@ +/* chanscout — passive second-adapter ground spectrum scout. + * + * Continuously surveys a configured candidate-channel plan while a separate + * primary receiver stays parked on the live video channel. This process only + * MEASURES: it retunes nothing but its own scout adapter and emits versioned + * survey.dwell records (plus scout.id / scout.plan / scout.cand identity and + * scout.health state) as JSONL on stdout. Channel-change decisions live + * entirely in downstream consumers. + * + * Dwell discipline (the reason this is not just DEVOURER_RX_SWEEP): the + * chip's FA/CCA counters are delta-on-read, so after the retune + settle the + * executor performs a DISCARD read — resetting the hardware deltas and + * draining frames still in the USB pipeline from the previous channel — + * before opening the observation window. The record's counters therefore + * span exactly observe_ms on exactly this bin. + * + * Wide candidates are surveyed as their 20 MHz constituent bins (the cheap + * FastRetune path); DEVOURER_SCOUT_FULLWIDTH_MS optionally adds a periodic + * real-width verification dwell through the full SetMonitorChannel gate. + * + * Env (demo-local; the library reads no environment): + * DEVOURER_SCOUT_PLAN required — see the grammar in ChannelDef.h + * DEVOURER_SCOUT_DWELL_MS observation window per dwell (default 100) + * DEVOURER_SCOUT_SETTLE_MS post-retune settle (default 30) + * DEVOURER_SCOUT_BACKUP_MS backup-candidate revisit bound (default 1000) + * DEVOURER_SCOUT_BG_MS background revisit bound (default 5000) + * DEVOURER_SCOUT_MAX_AGE_MS evidence freshness bound (default 60000) + * DEVOURER_SCOUT_FULLWIDTH_MS wide verification cadence (default 0=off) + * DEVOURER_SCOUT_NOTE free text echoed in scout.id (antenna, slot) + * DEVOURER_VID/PID/USB_BUS/USB_PORT adapter binding (usb_select.h) + */ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "RxPacket.h" +#include "SignalStop.h" +#include "UsbOpen.h" +#include "WiFiDriver.h" +#include "caps_event.h" +#include "chanmig/ChannelDef.h" +#include "chanmig/ChannelEvents.h" +#include "chanmig/ChannelScore.h" +#include "chanmig/PrimaryFeed.h" +#include "chanmig/ScanPlan.h" +#include "chanmig/SurveyJsonl.h" +#include "chanmig/SurveyRecord.h" +#include "env_config.h" +#include "logger.h" +#include "usb_select.h" +#include + +#if defined(DEVOURER_HAVE_JAGUAR1) +#include "jaguar1/RtlJaguarDevice.h" +#endif + +namespace cm = devourer::chanmig; + +static devourer::EventSink *g_ev = nullptr; +static std::atomic g_rx_count{0}; + +/* The canonical devourer TX SA (examples/tx, examples/streamtx, regress.py): + * frames from it are OUR OWN video/probe traffic, split out of the occupancy + * picture so the scoring layer never mistakes wanted airtime for an + * interferer. */ +static const uint8_t kDvrSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; + +/* Rolling per-dwell aggregate fed by the RX callback, drained at dwell + * boundaries (rxdemo RxAgg shape + the airtime attribution buckets). */ +struct ScoutAgg { + uint32_t n = 0; + int32_t rssi_sum = 0, rssi_max = -128, snr_sum = 0, snr_min = 127; + int32_t evm_sum = 0; + uint32_t evm_n = 0; + uint32_t dvr_frames = 0; + uint64_t dvr_air_us = 0, oth_air_us = 0; +}; +static std::mutex g_agg_mu; +static ScoutAgg g_agg; + +static void packetProcessor(const Packet &packet) { + if (packet.RxAtrib.pkt_rpt_type == RX_PACKET_TYPE::C2H_PACKET) + return; + g_rx_count.fetch_add(1, std::memory_order_relaxed); + const auto &a = packet.RxAtrib; + const uint32_t air = cm::frame_airtime_us( + a.data_rate, static_cast(packet.Data.size()), a.bw, + a.sgi != 0); + const bool ours = packet.Data.size() >= 16 && + std::memcmp(packet.Data.data() + 10, kDvrSa, 6) == 0; + std::lock_guard lk(g_agg_mu); + if (a.rssi[0] > 0) { + ++g_agg.n; + g_agg.rssi_sum += a.rssi[0]; + if (a.rssi[0] > g_agg.rssi_max) + g_agg.rssi_max = a.rssi[0]; + g_agg.snr_sum += a.snr[0]; + if (a.snr[0] < g_agg.snr_min) + g_agg.snr_min = a.snr[0]; + if (a.evm[0] != 0) { + g_agg.evm_sum += a.evm[0]; + ++g_agg.evm_n; + } + } + if (ours) { + ++g_agg.dvr_frames; + g_agg.dvr_air_us += air; + } else { + g_agg.oth_air_us += air; + } +} + +static long long steady_ms() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +static uint32_t env_u32(const char *name, uint32_t def) { + const char *e = std::getenv(name); + return (e && *e) ? static_cast(std::strtoul(e, nullptr, 0)) : def; +} + +/* Chunked stop-aware sleep (the repo's 50 ms convention keeps SIGINT + * latency bounded); returns false when interrupted. */ +static bool nap_ms(int64_t ms) { + for (int64_t s = 0; s < ms; s += 50) { + if (g_devourer_should_stop) + return false; + const int64_t step = std::min(50, ms - s); + std::this_thread::sleep_for(std::chrono::milliseconds(step)); + } + return !g_devourer_should_stop; +} + +/* Health-state tracker: emits scout.health only on state/reason transitions + * so a long degraded stretch is one line, not a firehose. */ +struct HealthReporter { + std::string last_key; + void report(const char *state, const char *reason, long long detail) { + std::string key = std::string(state) + "/" + reason; + if (key == last_key) + return; + last_key = key; + devourer::Ev(*g_ev, "scout.health") + .t() + .f("state", state) + .f("reason", reason) + .f("detail", detail); + } + void ok() { + if (last_key == "ok/") + return; + last_key = "ok/"; + devourer::Ev(*g_ev, "scout.health").t().f("state", "ok").f("reason", ""); + } +}; + +int main() { + auto logger = std::make_shared(); + apply_logging_env(*logger); + g_ev = &logger->events(); + install_devourer_signal_handlers(); + + /* --- plan --- */ + const char *plan_env = std::getenv("DEVOURER_SCOUT_PLAN"); + cm::ScanPlanConfig cfg; + { + std::vector errs; + const bool ok = cm::parse_scan_plan(plan_env, cfg.candidates, errs); + for (const auto &e : errs) { + logger->error("DEVOURER_SCOUT_PLAN token '{}': {}", e.token, e.reason); + devourer::Ev(*g_ev, "scout.health") + .t() + .f("state", "fatal") + .f("reason", "plan_error") + .f("token", e.token.c_str()) + .f("what", e.reason.c_str()); + } + /* A candidate list with ANY unparseable token must not run: a silently + * missing migration candidate is a field hazard, not a warning. */ + if (!ok) { + logger->error("DEVOURER_SCOUT_PLAN is required and must parse cleanly " + "(grammar: src/chanmig/ChannelDef.h)"); + return 2; + } + } + cfg.dwell_ms = static_cast(env_u32("DEVOURER_SCOUT_DWELL_MS", 100)); + cfg.settle_ms = static_cast(env_u32("DEVOURER_SCOUT_SETTLE_MS", 30)); + cfg.backup_revisit_ms = + static_cast(env_u32("DEVOURER_SCOUT_BACKUP_MS", 1000)); + cfg.bg_revisit_ms = static_cast(env_u32("DEVOURER_SCOUT_BG_MS", 5000)); + cfg.fullwidth_ms = + static_cast(env_u32("DEVOURER_SCOUT_FULLWIDTH_MS", 0)); + cfg.max_age_ms = env_u32("DEVOURER_SCOUT_MAX_AGE_MS", 60000); + const uint32_t plan_hash = cfg.plan_hash(); + + /* --- advise mode (DEVOURER_SCOUT_ADVISE=1) --- runs the RecommendEngine + * in-process (it already owns the survey records) and tails the primary + * receiver's JSONL for the active-link delivery evidence. It emits only + * advice; it retunes nothing. */ + const bool advise = std::getenv("DEVOURER_SCOUT_ADVISE") != nullptr && + std::strcmp(std::getenv("DEVOURER_SCOUT_ADVISE"), "0") != 0; + std::optional engine; + cm::PrimaryFeedReader feed; + cm::ChannelDef active_def; + if (advise) { + const char *act = std::getenv("DEVOURER_SCOUT_ACTIVE"); + std::string aerr; + if (act == nullptr || !cm::parse_chan_token(act, active_def, aerr)) { + logger->error("DEVOURER_SCOUT_ADVISE needs a valid DEVOURER_SCOUT_ACTIVE " + "channel token ({})", act ? aerr : "unset"); + return 2; + } + cm::PolicyConfig pol; + if (const char *pf = std::getenv("DEVOURER_SCOUT_POLICY")) { + std::string perr; + if (!cm::parse_policy_file(pf, pol, &perr)) + logger->warn("DEVOURER_SCOUT_POLICY {}: {} — using defaults", pf, perr); + } + engine.emplace(pol, cfg.candidates, plan_hash, active_def); + } + + /* --- adapter --- */ + libusb_context *ctx = nullptr; + int rc = libusb_init(&ctx); + if (rc < 0) + return rc; + libusb_set_option(ctx, LIBUSB_OPTION_LOG_LEVEL, + std::getenv("DEVOURER_USB_DEBUG") ? LIBUSB_LOG_LEVEL_DEBUG + : LIBUSB_LOG_LEVEL_WARNING); + /* The scout accepts any devourer-supported chip; the default PID list is + * rxdemo's. Two identical adapters are told apart per-process via + * DEVOURER_USB_BUS/PORT (strict, no fallback). */ + static constexpr uint16_t kPids[] = { + 0x8812, 0x0811, 0xa811, 0xb811, 0x8813, 0xb812, 0xb82c, 0xc811, + 0xc82c, 0xc82e, 0xc812, 0x881a, 0x881b, 0x881c, 0xa81a, 0xe822, + 0xa82a, + }; + UsbPick pick; + libusb_device_handle *handle = open_selected_usb( + ctx, logger, kPids, sizeof(kPids) / sizeof(kPids[0]), &pick); + if (handle == nullptr) { + libusb_exit(ctx); + return 1; + } + std::shared_ptr usb_lock; + rc = devourer::claim_interface_reset_reopen( + ctx, handle, logger, std::getenv("DEVOURER_SKIP_RESET") == nullptr, + usb_lock); + if (rc != 0) { + if (handle != nullptr) + libusb_close(handle); + libusb_exit(ctx); + return 1; + } + + WiFiDriver driver(logger); + auto dev = driver.CreateRtlDevice(handle, ctx, usb_lock, + devourer_config_from_env()); + if (!dev) { + logger->error("No driver for this chip in this build — exiting"); + return 1; + } + devourer::emit_adapter_caps(*g_ev, dev.get()); + const devourer::AdapterCaps caps = dev->GetAdapterCaps(); + + /* Stable scout identity = the physical binding + silicon (FNV-1a). This is + * the calibration-domain key: evidence is only comparable within one + * scout_id, and the aggregator resets when it changes. */ + uint32_t scout_id = 2166136261u; + { + auto fold = [&scout_id](const void *p, size_t n) { + const uint8_t *b = static_cast(p); + for (size_t i = 0; i < n; i++) { + scout_id ^= b[i]; + scout_id *= 16777619u; + } + }; + fold(&pick.vid, sizeof(pick.vid)); + fold(&pick.pid, sizeof(pick.pid)); + fold(&pick.bus, sizeof(pick.bus)); + fold(pick.port.data(), pick.port.size()); + fold(caps.chip_name, std::strlen(caps.chip_name)); + } + + { + devourer::Ev ev(*g_ev, "scout.id"); + ev.t().f("role", "scout"); + char id[16]; + std::snprintf(id, sizeof(id), "%04x:%04x", pick.vid, pick.pid); + ev.f("usb_id", id) + .f("bus", pick.bus) + .f("port", pick.port.empty() ? "?" : pick.port.c_str()) + .f("usb_speed", pick.speed) + .f("chip", caps.chip_name) + .f("gen", devourer::generation_name(caps.generation)); + ev.hexf("scout_id", scout_id, 8); + if (const char *note = std::getenv("DEVOURER_SCOUT_NOTE")) + ev.f("note", note); + } + { + /* One CLOCK_REALTIME sample so offline tooling can align this stream + * with the primary receiver's (both processes log monotonic t). */ + const long long epoch_ms = + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + devourer::Ev ev(*g_ev, "scout.plan"); + ev.t().f("v", cm::kSurveySchemaV); + ev.hexf("plan", plan_hash, 8); + ev.f("epoch_unix_ms", epoch_ms) + .f("dwell_ms", cfg.dwell_ms) + .f("settle_ms", cfg.settle_ms) + .f("backup_ms", cfg.backup_revisit_ms) + .f("bg_ms", cfg.bg_revisit_ms) + .f("fullwidth_ms", cfg.fullwidth_ms) + .f("max_age_ms", (long long)cfg.max_age_ms) + .f("n_candidates", (unsigned long long)cfg.candidates.size()) + .f("spec", plan_env); + } + for (size_t i = 0; i < cfg.candidates.size(); ++i) { + const cm::ChannelDef &c = cfg.candidates[i]; + uint8_t bins[4]; + const int nb = cm::constituent_bins(c, bins); + int ib[4]; + for (int k = 0; k < nb; k++) + ib[k] = bins[k]; + devourer::Ev ev(*g_ev, "scout.cand"); + ev.f("i", (unsigned long long)i) + .f("chan", c.str().c_str()) + .f("center_mhz", c.center_mhz()) + .f("backup", c.backup) + .f("no_ir", c.no_ir) + .f("dfs", c.dfs); + ev.arr("bins", ib, static_cast(nb)); + } + + cm::ScanScheduler sched(cfg); + + /* --- RX loop on a worker thread (rxdemo sweep pattern) --- */ + IRtlDevice *devp = dev.get(); + const cm::ScanScheduler::DwellPlan first = sched.next(steady_ms()); + std::thread rx([devp, first, &logger]() { + try { + devp->Init(packetProcessor, first.def.to_selected()); + } catch (const std::exception &e) { + logger->error("scout RX bring-up failed: {}", e.what()); + g_devourer_should_stop = true; + } + }); + /* Let bring-up finish before the first retune (a retune racing FW download + * or calibration interleaves register writes). A frame proves RX is live; + * a silent band falls through after the conservative 10 s cap. */ + for (int w = 0; w < 10000 && !g_devourer_should_stop && g_rx_count == 0; + w += 50) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + +#if defined(DEVOURER_HAVE_JAGUAR1) + /* Thermal health where the sensor exists (Jaguar1 RF 0x42 poller). */ + auto *j1 = dynamic_cast(devp); + const uint32_t thermal_ms = env_u32("DEVOURER_THERMAL_POLL_MS", 0); + if (j1 != nullptr && thermal_ms > 0) + j1->start_thermal_poller(thermal_ms, + static_cast(env_u32( + "DEVOURER_THERMAL_WARN_DELTA", 15))); +#endif + + HealthReporter health; + uint64_t seq = 0; + int energy_invalid_streak = 0; + bool energy_ever_valid = false; + int quiet_dwells = 0; + bool ever_active = false; + std::vector retune_ring; + int64_t retune_p95_baseline = 0; + bool last_was_wide = false; + /* Per-bin last-successful-observation, for the stale-survey health check. */ + struct BinAge { + uint8_t ch; + int64_t last_ok_ms; + }; + std::vector bin_ages; + + /* Advise-mode feed follower + decision cadence. The primary feed is a plain + * append-only file (never a FIFO — a wedged scout must not stall the video + * receiver); open it lazily and read to EOF each pump. */ + std::FILE *feed_fp = nullptr; + std::string feed_path; + if (advise) { + if (const char *fp = std::getenv("DEVOURER_SCOUT_PRIMARY_FEED")) + feed_path = fp; + else + logger->warn("DEVOURER_SCOUT_ADVISE without DEVOURER_SCOUT_PRIMARY_FEED " + "— recommendations will lack active-link evidence"); + } + std::string feed_buf; + int64_t last_decide_ms = 0, last_emit_ms = 0; + cm::Reason last_reason = cm::Reason::HoldPrimaryTelemetryStale; + const int64_t kDecideEveryMs = 2000, kHoldEveryMs = 10000; + auto pump_feed = [&](int64_t now) { + if (!advise || feed_path.empty() || engine == std::nullopt) + return; + if (feed_fp == nullptr) { + feed_fp = std::fopen(feed_path.c_str(), "r"); + if (feed_fp == nullptr) + return; /* primary not up yet; retry next pump */ + } + char chunk[4096]; + size_t n; + while ((n = std::fread(chunk, 1, sizeof(chunk), feed_fp)) > 0) { + feed_buf.append(chunk, n); + size_t nl; + while ((nl = feed_buf.find('\n')) != std::string::npos) { + std::string_view line(feed_buf.data(), nl); + cm::ActiveLinkWindow w; + if (feed.line(line, now, w)) + engine->ingest_active(w); + feed_buf.erase(0, nl + 1); + } + } + std::clearerr(feed_fp); /* EOF now, but more may append — keep the handle */ + }; + + while (!g_devourer_should_stop) { + const int64_t t_start = steady_ms(); + cm::ScanScheduler::DwellPlan plan = sched.next(t_start); + if (!plan.valid) + break; + + cm::SurveyDwell d; + d.seq = seq++; + d.def = plan.def; + d.round = plan.round; + d.plan_hash = plan_hash; + d.t_start_ms = t_start; + d.settle_ms = cfg.settle_ms; + d.scout_id = scout_id; + d.adapter_gen = static_cast(caps.generation); + if (plan.full_width) + d.flags |= cm::kFlagFullWidth; + + /* retune. FastRetune is the same-width lean path, so the first bin dwell + * after a wide verification dwell must go through the full gate to + * restore 20 MHz — otherwise every subsequent "bin" would observe at the + * candidate's width. */ + bool tuned = false; + const auto rt0 = std::chrono::steady_clock::now(); + try { + if (plan.full_width) { + devp->SetMonitorChannel(plan.def.to_selected()); + last_was_wide = true; + } else if (last_was_wide) { + devp->SetMonitorChannel(plan.def.to_selected()); + last_was_wide = false; + } else { + devp->FastRetune(plan.bin_ch, /*cache_rf=*/true); + } + tuned = true; + } catch (const std::exception &e) { + logger->warn("scout retune ch{} failed: {}", plan.bin_ch, e.what()); + } + d.retune_us = std::chrono::duration_cast( + std::chrono::steady_clock::now() - rt0) + .count(); + + if (!tuned) { + d.flags |= cm::kFlagRetuneFailed; + d.t_end_ms = steady_ms(); + d.observe_ms = 0; + cm::emit_survey_dwell(*g_ev, d); + sched.complete(plan, steady_ms(), false); + const int cf = sched.consecutive_failures(); + if (cf >= 15) { + health.report("wedged", "retune_fail", cf); + break; /* supervisor restarts us; exit code says why */ + } + if (cf >= 5) + health.report("degraded", "retune_fail", cf); + nap_ms(50); + continue; + } + + /* settle, then the DISCARD BARRIER: reset the delta counters and drain + * frames that raced in from the previous channel. */ + if (!nap_ms(cfg.settle_ms)) + d.flags |= cm::kFlagTruncated; + (void)devp->GetRxEnergy(); + { + std::lock_guard lk(g_agg_mu); + g_agg = ScoutAgg{}; + } + const int64_t t_obs = steady_ms(); + + if (!nap_ms(cfg.dwell_ms)) + d.flags |= cm::kFlagTruncated; + + RxEnergy e = devp->GetRxEnergy(); + ScoutAgg agg; + { + std::lock_guard lk(g_agg_mu); + agg = g_agg; + g_agg = ScoutAgg{}; + } + d.t_end_ms = steady_ms(); + d.observe_ms = d.t_end_ms - t_obs; + + d.valid_fa = e.valid_fa; + d.fa_ofdm = e.fa_ofdm; + d.fa_cck = e.fa_cck; + d.cca_ofdm = e.cca_ofdm; + d.cca_cck = e.cca_cck; + d.valid_igi = e.valid_igi; + d.igi = e.igi; + d.valid_nhm = e.valid_nhm; + if (e.valid_nhm) { + uint32_t total = 0, peak = 0; + int peak_k = 0; + for (int k = 0; k < 12; k++) { + d.nhm[k] = e.nhm[k]; + total += e.nhm[k]; + if (e.nhm[k] > peak) { + peak = e.nhm[k]; + peak_k = k; + } + } + d.nhm_dur = e.nhm_duration; + d.nhm_peak = static_cast(peak_k); + d.nhm_busy_pct = static_cast( + total ? 100 * (total - e.nhm[0]) / total : 0); + } else { + d.flags |= cm::kFlagNhmMissing; + } + /* Producer-side counter plausibility (the aggregator re-checks): a delta + * beyond ~1000 events/ms of observation is a wrapped/reset counter. */ + if (e.valid_fa && d.observe_ms > 0) { + const uint64_t ceiling = + 1000ull * static_cast(d.observe_ms); + if (d.cca_ofdm > ceiling || d.fa_ofdm > ceiling || + d.cca_cck > ceiling || d.fa_cck > ceiling) + d.flags |= cm::kFlagCounterSuspect; + } + d.frames = agg.n; + if (agg.n) { + d.rssi_mean_raw = agg.rssi_sum / static_cast(agg.n); + d.rssi_max_raw = agg.rssi_max; + d.snr_mean_raw = agg.snr_sum / static_cast(agg.n); + d.snr_min_raw = agg.snr_min; + } + if (agg.evm_n) { + d.evm_mean_raw = agg.evm_sum / static_cast(agg.evm_n); + d.evm_valid = true; + } + d.dvr_frames = agg.dvr_frames; + d.dvr_air_us = agg.dvr_air_us; + d.oth_air_us = agg.oth_air_us; + + /* A generation whose energy counters were once valid going invalid is a + * read failure, not a quiet channel — flag the record before it flies. */ + if (e.valid_fa) + energy_ever_valid = true; + energy_invalid_streak = e.valid_fa ? 0 : energy_invalid_streak + 1; + if (energy_ever_valid && !e.valid_fa) + d.flags |= cm::kFlagReadFailed; + + cm::emit_survey_dwell(*g_ev, d); + const bool ok = (d.flags & (cm::kFlagRetuneFailed | cm::kFlagTruncated)) == 0; + sched.complete(plan, d.t_end_ms, ok); + if (advise && engine != std::nullopt) + engine->ingest_dwell(d, d.t_end_ms); + + /* --- health --- */ + if (energy_ever_valid && energy_invalid_streak >= 10) + health.report("degraded", "energy_read_fail", energy_invalid_streak); + const bool active = agg.n > 0 || (e.valid_fa && (e.cca_ofdm | e.cca_cck)); + if (active) { + ever_active = true; + quiet_dwells = 0; + } else if (++quiet_dwells >= 50 && ever_active) { + /* Everything silent after having heard traffic: a wedged RX front end + * looks exactly like a suddenly-empty world — flag it, let the bench + * decide. */ + health.report("degraded", "rx_stalled", quiet_dwells); + } + if (!plan.full_width) { + retune_ring.push_back(d.retune_us); + if (retune_ring.size() > 100) + retune_ring.erase(retune_ring.begin()); + if (retune_ring.size() == 100) { + std::vector sorted = retune_ring; + std::sort(sorted.begin(), sorted.end()); + const int64_t p95 = sorted[95]; + if (retune_p95_baseline == 0) + retune_p95_baseline = p95 > 0 ? p95 : 1; + else if (p95 > 5 * retune_p95_baseline) + health.report("degraded", "usb_congested", p95); + } + } +#if defined(DEVOURER_HAVE_JAGUAR1) + if (j1 != nullptr && thermal_ms > 0) { + const auto t = j1->get_thermal_snapshot(); + if (t.valid && t.delta >= static_cast(env_u32( + "DEVOURER_THERMAL_WARN_DELTA", 15))) + health.report("degraded", "thermal", t.delta); + } +#endif + { + /* Stale-survey: any bin unobserved past the freshness bound. */ + BinAge *slot = nullptr; + for (BinAge &b : bin_ages) + if (b.ch == plan.bin_ch) + slot = &b; + if (slot == nullptr) { + bin_ages.push_back(BinAge{plan.bin_ch, d.t_end_ms}); + slot = &bin_ages.back(); + } + if (ok) + slot->last_ok_ms = d.t_end_ms; + bool stale = false; + long long worst = 0; + for (const BinAge &b : bin_ages) { + const int64_t age = d.t_end_ms - b.last_ok_ms; + if (age > cfg.max_age_ms) { + stale = true; + if (age > worst) + worst = age; + } + } + if (stale) + health.report("degraded", "stale_survey", worst); + else if (sched.consecutive_failures() == 0 && + energy_invalid_streak < 10 && quiet_dwells < 50) + health.ok(); + } + + /* --- advise: pump the primary feed, decide on a cadence --- */ + if (advise && engine != std::nullopt) { + const int64_t now = d.t_end_ms; + pump_feed(now); + engine->note_scout_health(sched.consecutive_failures() < 5); + if (now - last_decide_ms >= kDecideEveryMs) { + last_decide_ms = now; + cm::Decision dec = engine->decide(now); + /* Emit on a recommendation, a reason change, or the steady hold + * cadence — so a dashboard's counterfactual stays fresh without spam. */ + if (dec.kind == cm::Decision::Kind::Recommend || + dec.primary_reason != last_reason || + now - last_emit_ms >= kHoldEveryMs) { + cm::emit_decision(*g_ev, dec, active_def); + cm::emit_ranking(*g_ev, dec); + last_reason = dec.primary_reason; + last_emit_ms = now; + } + } + } + } + + if (feed_fp != nullptr) + std::fclose(feed_fp); + devp->StopRxLoop(); + if (rx.joinable()) + rx.join(); + devp->Stop(); + libusb_release_interface(handle, 0); + libusb_close(handle); + libusb_exit(ctx); + return sched.consecutive_failures() >= 15 ? 3 : 0; +} diff --git a/examples/common/usb_select.cpp b/examples/common/usb_select.cpp new file mode 100644 index 0000000..a69bff4 --- /dev/null +++ b/examples/common/usb_select.cpp @@ -0,0 +1,135 @@ +#include "usb_select.h" + +#include +#include +#include + +/* Fill the pick descriptor from an opened handle (best-effort — identity + * logging must never fail an open that succeeded). */ +static void describe(libusb_device_handle *h, uint16_t vid, uint16_t pid, + UsbPick *picked) { + if (picked == nullptr || h == nullptr) + return; + picked->vid = vid; + picked->pid = pid; + libusb_device *dev = libusb_get_device(h); + if (dev == nullptr) + return; + picked->bus = libusb_get_bus_number(dev); + uint8_t ports[8]; + const int pc = libusb_get_port_numbers(dev, ports, sizeof(ports)); + picked->port.clear(); + for (int p = 0; p < pc; ++p) + picked->port += (picked->port.empty() ? "" : ".") + std::to_string(ports[p]); + picked->speed = libusb_get_device_speed(dev); +} + +libusb_device_handle * +open_selected_usb(libusb_context *ctx, const std::shared_ptr &logger, + const uint16_t *default_pids, size_t n_default_pids, + UsbPick *picked) { + /* DEVOURER_PID env var (hex, e.g. "0x8813") restricts the open loop to a + * single PID. Useful when multiple Realtek adapters are plugged. + * DEVOURER_VID overrides the VID (default 0x0bda Realtek) — needed to reach + * OEM-rebadged Jaguar dongles like the TP-Link Archer T2U Plus (2357:0120). */ + const char *pid_env = std::getenv("DEVOURER_PID"); + uint16_t target_pid = 0; + if (pid_env != nullptr) { + target_pid = static_cast(std::strtoul(pid_env, nullptr, 0)); + logger->info("DEVOURER_PID={:04x} (limiting to this PID)", target_pid); + } + uint16_t target_vid = 0x0bda; + if (const char *vid_env = std::getenv("DEVOURER_VID")) { + target_vid = static_cast(std::strtoul(vid_env, nullptr, 0)); + logger->info("DEVOURER_VID={:04x} (overriding default VID)", target_vid); + } + libusb_device_handle *dev_handle = nullptr; + + /* DEVOURER_USB_BUS (+ optional DEVOURER_USB_PORT) select a specific device by + * USB topology when several share one VID:PID and even the serial — e.g. two + * RTL8814AU dongles (CF-938AC vs CF-960AC) that enumerate identically, so only + * the bus/port tells them apart. DEVOURER_USB_PORT is the dotted libusb port + * path (as in sysfs `devpath` / `lsusb -t`, e.g. "2.3.2"). When bus is unset, + * the VID:PID open loop below runs as before. */ + if (const char *bus_env = std::getenv("DEVOURER_USB_BUS")) { + const auto want_bus = static_cast(std::strtoul(bus_env, nullptr, 0)); + const char *port_env = std::getenv("DEVOURER_USB_PORT"); + /* A named socket is EXPECTED to hold the device — but the previous + * session's close/kill leaves the chip re-enumerating (firmware reload + * through ROM, sometimes via the ZeroCD id) for several seconds. Poll + * bounded instead of failing on the first empty scan. */ + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(15); + bool waited = false; + uint16_t matched_pid = 0; + do { + libusb_device **list = nullptr; + ssize_t n = libusb_get_device_list(ctx, &list); + for (ssize_t i = 0; i < n && dev_handle == nullptr; ++i) { + libusb_device_descriptor dd{}; + if (libusb_get_device_descriptor(list[i], &dd) != 0) continue; + if (dd.idVendor != target_vid) continue; + if (target_pid != 0 && dd.idProduct != target_pid) continue; + if (libusb_get_bus_number(list[i]) != want_bus) continue; + if (port_env != nullptr) { + uint8_t ports[8]; + int pc = libusb_get_port_numbers(list[i], ports, sizeof(ports)); + std::string path; + for (int p = 0; p < pc; ++p) + path += (path.empty() ? "" : ".") + std::to_string(ports[p]); + if (path != port_env) continue; + } + if (libusb_open(list[i], &dev_handle) == 0) { + matched_pid = dd.idProduct; + logger->info("Opened device {:04x}:{:04x} on bus {} port {}", + dd.idVendor, dd.idProduct, want_bus, + port_env ? port_env : "(any)"); + } + } + if (list != nullptr) libusb_free_device_list(list, 1); + if (dev_handle != nullptr) break; + if (!waited) { + logger->warn("DEVOURER_USB_BUS={} PORT={} matched no device — waiting " + "for it to (re-)enumerate", + want_bus, port_env ? port_env : "(any)"); + waited = true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } while (std::chrono::steady_clock::now() < deadline); + /* Topology selection is strict: falling through to the VID:PID loop here + * would silently open a DIFFERENT adapter sharing the id (the exact + * ambiguity DEVOURER_USB_BUS exists to resolve) — fail instead. */ + if (dev_handle == nullptr) { + logger->error("DEVOURER_USB_BUS={} PORT={} matched no device", want_bus, + port_env ? port_env : "(any)"); + return nullptr; + } + describe(dev_handle, target_vid, matched_pid, picked); + return dev_handle; + } + + for (size_t i = 0; i < n_default_pids; ++i) { + const uint16_t pid = default_pids[i]; + if (target_pid != 0 && pid != target_pid) continue; + dev_handle = libusb_open_device_with_vid_pid(ctx, target_vid, pid); + if (dev_handle != nullptr) { + logger->info("Opened device {:04x}:{:04x}", target_vid, pid); + describe(dev_handle, target_vid, pid, picked); + return dev_handle; + } + } + /* DEVOURER_PID can name a PID not in the default list (e.g. 0x0120 for the + * T2U Plus). Try that direct combination once before giving up. */ + if (target_pid != 0) { + dev_handle = libusb_open_device_with_vid_pid(ctx, target_vid, target_pid); + if (dev_handle != nullptr) { + logger->info("Opened device {:04x}:{:04x} (via DEVOURER_PID)", + target_vid, target_pid); + describe(dev_handle, target_vid, target_pid, picked); + return dev_handle; + } + } + logger->error("Cannot find any supported device under VID {:04x}", + target_vid); + return nullptr; +} diff --git a/examples/common/usb_select.h b/examples/common/usb_select.h new file mode 100644 index 0000000..2aca868 --- /dev/null +++ b/examples/common/usb_select.h @@ -0,0 +1,43 @@ +#pragma once + +/* Shared demo-side USB device selection — the DEVOURER_PID / DEVOURER_VID / + * DEVOURER_USB_BUS / DEVOURER_USB_PORT open loop, factored out of rxdemo so + * multi-adapter demos (chanscout as the second radio next to a primary + * receiver) bind deterministically without a third copy of the logic. + * + * Selection rules (unchanged from the historical rxdemo behaviour): + * DEVOURER_VID=0xNNNN override the default Realtek VID (OEM-rebadged + * dongles, e.g. TP-Link 2357:xxxx). + * DEVOURER_PID=0xNNNN restrict to one PID; may name a PID outside the + * caller's default list (tried directly). + * DEVOURER_USB_BUS=N select by USB topology when several adapters share + * DEVOURER_USB_PORT=a.b.c one VID:PID (and even the serial). The port path + * is the dotted libusb port chain (sysfs devpath / + * `lsusb -t`). Topology selection is STRICT: no + * silent fallback to a different same-id adapter, + * and it polls up to 15 s for a device still + * re-enumerating after the previous session. + * + * Returns an opened (not claimed) handle, or nullptr after logging why. */ + +#include + +#include +#include +#include + +#include "logger.h" + +/* Physical identity of the opened device, for role/identity logging (the + * scout.id event): what was matched and where it sits on the bus. */ +struct UsbPick { + uint16_t vid = 0, pid = 0; + uint8_t bus = 0; + std::string port; /* dotted port path, empty when unavailable */ + int speed = 0; /* libusb_get_device_speed enum value */ +}; + +libusb_device_handle * +open_selected_usb(libusb_context *ctx, const std::shared_ptr &logger, + const uint16_t *default_pids, size_t n_default_pids, + UsbPick *picked = nullptr); diff --git a/examples/rx/main.cpp b/examples/rx/main.cpp index 3de2214..6824aa0 100644 --- a/examples/rx/main.cpp +++ b/examples/rx/main.cpp @@ -35,6 +35,7 @@ #include "UsbOpen.h" #include "WiFiDriver.h" #include "env_config.h" +#include "usb_select.h" #if defined(DEVOURER_HAVE_PCIE) #include "PcieTransport.h" #endif @@ -414,7 +415,12 @@ struct RxAgg { * LDPC delivery on the same link). Counts what the chip *reports*: on the * 8814A ldpc reads 0 even on LDPC frames (AdapterCaps.ldpc_rx_flag). */ uint32_t n_ldpc = 0, n_stbc = 0; - void add(int rssi, int snr, int evm, bool ldpc = false, bool stbc = false) { + /* FCS/ICV-failed frames in the window — nonzero only under + * DEVOURER_RX_KEEP_CORRUPTED (the parser drops them otherwise). The + * active-link delivery evidence a channel-migration policy consumes. */ + uint32_t n_crc = 0, n_icv = 0; + void add(int rssi, int snr, int evm, bool ldpc = false, bool stbc = false, + bool crc = false, bool icv = false) { ++n; rssi_sum += rssi; if (rssi > rssi_max) rssi_max = rssi; @@ -423,6 +429,8 @@ struct RxAgg { if (evm != 0) { evm_sum += evm; ++evm_n; } if (ldpc) ++n_ldpc; if (stbc) ++n_stbc; + if (crc) ++n_crc; + if (icv) ++n_icv; } }; static RxAgg g_rxagg; @@ -595,7 +603,8 @@ static void packetProcessor(const Packet &packet) { std::lock_guard lk(g_rxagg_mu); g_rxagg.add(packet.RxAtrib.rssi[0], packet.RxAtrib.snr[0], packet.RxAtrib.evm[0], packet.RxAtrib.ldpc != 0, - packet.RxAtrib.stbc != 0); + packet.RxAtrib.stbc != 0, packet.RxAtrib.crc_err, + packet.RxAtrib.icv_err); } if (g_rx_count == 1) { @@ -953,103 +962,12 @@ int main() { ? LIBUSB_LOG_LEVEL_DEBUG : LIBUSB_LOG_LEVEL_WARNING); - /* DEVOURER_PID env var (hex, e.g. "0x8813") restricts the open loop to a - * single PID. Useful when multiple Realtek adapters are plugged. - * DEVOURER_VID overrides the VID (default 0x0bda Realtek) — needed to reach - * OEM-rebadged Jaguar dongles like the TP-Link Archer T2U Plus (2357:0120). */ - const char *pid_env = std::getenv("DEVOURER_PID"); - uint16_t target_pid = 0; - if (pid_env != nullptr) { - target_pid = static_cast(std::strtoul(pid_env, nullptr, 0)); - logger->info("DEVOURER_PID={:04x} (limiting to this PID)", target_pid); - } - uint16_t target_vid = USB_VENDOR_ID; - if (const char *vid_env = std::getenv("DEVOURER_VID")) { - target_vid = static_cast(std::strtoul(vid_env, nullptr, 0)); - logger->info("DEVOURER_VID={:04x} (overriding default VID)", target_vid); - } - libusb_device_handle *dev_handle = nullptr; - - /* DEVOURER_USB_BUS (+ optional DEVOURER_USB_PORT) select a specific device by - * USB topology when several share one VID:PID and even the serial — e.g. two - * RTL8814AU dongles (CF-938AC vs CF-960AC) that enumerate identically, so only - * the bus/port tells them apart. DEVOURER_USB_PORT is the dotted libusb port - * path (as in sysfs `devpath` / `lsusb -t`, e.g. "2.3.2"). When bus is unset, - * the VID:PID open loop below runs as before. */ - if (const char *bus_env = std::getenv("DEVOURER_USB_BUS")) { - const auto want_bus = static_cast(std::strtoul(bus_env, nullptr, 0)); - const char *port_env = std::getenv("DEVOURER_USB_PORT"); - /* A named socket is EXPECTED to hold the device — but the previous - * session's close/kill leaves the chip re-enumerating (firmware reload - * through ROM, sometimes via the ZeroCD id) for several seconds. Poll - * bounded instead of failing on the first empty scan. */ - const auto deadline = - std::chrono::steady_clock::now() + std::chrono::seconds(15); - bool waited = false; - do { - libusb_device **list = nullptr; - ssize_t n = libusb_get_device_list(ctx, &list); - for (ssize_t i = 0; i < n && dev_handle == NULL; ++i) { - libusb_device_descriptor dd{}; - if (libusb_get_device_descriptor(list[i], &dd) != 0) continue; - if (dd.idVendor != target_vid) continue; - if (target_pid != 0 && dd.idProduct != target_pid) continue; - if (libusb_get_bus_number(list[i]) != want_bus) continue; - if (port_env != nullptr) { - uint8_t ports[8]; - int pc = libusb_get_port_numbers(list[i], ports, sizeof(ports)); - std::string path; - for (int p = 0; p < pc; ++p) - path += (path.empty() ? "" : ".") + std::to_string(ports[p]); - if (path != port_env) continue; - } - if (libusb_open(list[i], &dev_handle) == 0) - logger->info("Opened device {:04x}:{:04x} on bus {} port {}", - dd.idVendor, dd.idProduct, want_bus, - port_env ? port_env : "(any)"); - } - if (list != nullptr) libusb_free_device_list(list, 1); - if (dev_handle != NULL) break; - if (!waited) { - logger->warn("DEVOURER_USB_BUS={} PORT={} matched no device — waiting " - "for it to (re-)enumerate", - want_bus, port_env ? port_env : "(any)"); - waited = true; - } - std::this_thread::sleep_for(std::chrono::milliseconds(500)); - } while (std::chrono::steady_clock::now() < deadline); - /* Topology selection is strict: falling through to the VID:PID loop here - * would silently open a DIFFERENT adapter sharing the id (the exact - * ambiguity DEVOURER_USB_BUS exists to resolve) — fail instead. */ - if (dev_handle == NULL) { - logger->error("DEVOURER_USB_BUS={} PORT={} matched no device", want_bus, - port_env ? port_env : "(any)"); - libusb_exit(ctx); - return 1; - } - } - - for (uint16_t pid : kRealtekProductIds) { - if (dev_handle != NULL) break; - if (target_pid != 0 && pid != target_pid) continue; - dev_handle = libusb_open_device_with_vid_pid(ctx, target_vid, pid); - if (dev_handle != NULL) { - logger->info("Opened device {:04x}:{:04x}", target_vid, pid); - break; - } - } - /* DEVOURER_PID can name a PID not in kRealtekProductIds (e.g. 0x0120 for the - * T2U Plus). Try that direct combination once before giving up. */ - if (dev_handle == NULL && target_pid != 0) { - dev_handle = libusb_open_device_with_vid_pid(ctx, target_vid, target_pid); - if (dev_handle != NULL) { - logger->info("Opened device {:04x}:{:04x} (via DEVOURER_PID)", - target_vid, target_pid); - } - } + /* DEVOURER_PID / DEVOURER_VID / DEVOURER_USB_BUS / DEVOURER_USB_PORT device + * selection — shared with the other multi-adapter demos (usb_select.h). */ + libusb_device_handle *dev_handle = open_selected_usb( + ctx, logger, kRealtekProductIds, + sizeof(kRealtekProductIds) / sizeof(kRealtekProductIds[0])); if (dev_handle == NULL) { - logger->error("Cannot find any supported device under VID {:04x}", - target_vid); libusb_exit(ctx); return 1; } @@ -1215,6 +1133,8 @@ int main() { ev.f("frames", agg.n) .f("frames_ldpc", agg.n_ldpc) .f("frames_stbc", agg.n_stbc) + .f("crc_err", agg.n_crc) + .f("icv_err", agg.n_icv) .f("rssi_mean", rssi_mean) .f("rssi_max", agg.n ? agg.rssi_max : 0) .f("snr_mean", snr_mean) @@ -1544,6 +1464,8 @@ int main() { .f("frames", agg.n) .f("frames_ldpc", agg.n_ldpc) .f("frames_stbc", agg.n_stbc) + .f("crc_err", agg.n_crc) + .f("icv_err", agg.n_icv) .f("rssi_mean", rssi_mean) .f("rssi_max", agg.n ? agg.rssi_max : 0) .f("snr_mean", snr_mean) diff --git a/src/chanmig/ActiveLink.h b/src/chanmig/ActiveLink.h new file mode 100644 index 0000000..10c7537 --- /dev/null +++ b/src/chanmig/ActiveLink.h @@ -0,0 +1,208 @@ +/* Active-link evidence — the primary receiver's view of the LIVE video + * channel, the authoritative half of a migration decision. + * + * The asymmetry the whole stack is built around: on the active channel the + * scout's energy reading is confounded by the wanted video's own airtime, so + * it is NOT the impairment signal — the primary receiver's DELIVERY is. This + * models that delivery as a rolling window of `ActiveLinkWindow` records + * (fed from the primary's rx.txhit / rx.quality / rx.energy JSONL) and + * classifies each window as impaired-or-not with a fault domain, so a policy + * can require PERSISTENT channel-attributable impairment before it even looks + * at candidates. + * + * Fault domains matter because migration cannot fix them: a weak signal is a + * range problem (add power / close distance, not change channel), near-field + * saturation is an overload problem (back power off), and a broad multi-band + * degradation is not this channel's fault. Only "decodes are being lost to + * something local to this channel" is a migration trigger. + * + * Pure: no clocks (caller passes now), no I/O. Selftested via ChannelScore. */ +#ifndef DEVOURER_CHANMIG_ACTIVE_LINK_H +#define DEVOURER_CHANMIG_ACTIVE_LINK_H + +#include +#include + +#include "LinkHealth.h" + +namespace devourer { +namespace chanmig { + +/* 12-bit 802.11 sequence-number loss estimator (wrap-aware). Fed the seq of + * each delivered canonical-SA frame; a gap up to half the space is counted as + * that many expected-but-missing frames, a larger jump is a TX restart (one + * expected). delivered/expected over a window give the loss ratio. */ +class SeqLossEstimator { +public: + void observe(uint16_t seq) { + ++delivered_; + if (have_prev_) { + const uint16_t gap = static_cast((seq - prev_) & 0xFFF); + expected_ += (gap >= 1 && gap < 2048) ? gap : 1; + } else { + expected_ += 1; + have_prev_ = true; + } + prev_ = seq; + } + uint32_t delivered() const { return delivered_; } + uint32_t expected() const { return expected_ < delivered_ ? delivered_ + : expected_; } + double loss_ratio() const { + const uint32_t e = expected(); + return e ? 1.0 - static_cast(delivered_) / e : 0.0; + } + void reset() { + delivered_ = 0; + expected_ = 0; + have_prev_ = false; + } + +private: + uint32_t delivered_ = 0, expected_ = 0; + uint16_t prev_ = 0; + bool have_prev_ = false; +}; + +/* One primary-telemetry window on the active channel. Optional fields carry a + * validity flag; the impairment test uses whatever delivery evidence is + * present, preferring the strongest (FEC margin > seq loss > crc/icv rate > + * frame collapse). */ +struct ActiveLinkWindow { + int64_t t_ms = 0; + + bool have_delivery = false; + uint32_t delivered = 0; /* frames this window */ + uint32_t expected = 0; /* seq-gap estimate; 0 = unknown */ + + bool have_fec = false; + double fec_margin = 0.0; /* external feeder (wfb-ng); <=0 = at/over the edge */ + + bool have_crc = false; + uint32_t crc_err = 0, icv_err = 0; /* DEVOURER_RX_KEEP_CORRUPTED */ + + bool have_quality = false; + LinkVerdict verdict = LinkVerdict::NoSignal; + int rssi_max_dbm = 0; + double snr_mean_db = 0.0, evm_mean_db = 0.0; + + bool telemetry_ok = true; /* false when the primary feed went stale/missing */ +}; + +/* Impairment verdict for one window: whether it counts as impaired, and if + * not-channel-attributable, why (so a policy can say "don't migrate, it's + * weak-signal"). */ +enum class Impair { + Healthy, /* delivering fine */ + Channel, /* losing decodes to something on THIS channel — migratable */ + WeakSignal, /* range/sensitivity limit — migration won't help */ + Saturation, /* near-field overload — back power off */ + TelemetryDown, /* no trustworthy primary evidence */ +}; + +inline const char *verdict_label(LinkVerdict v) { + switch (v) { + case LinkVerdict::NoSignal: return "NO_SIGNAL"; + case LinkVerdict::Saturated: return "SATURATED"; + case LinkVerdict::Interference: return "INTERFERENCE"; + case LinkVerdict::Weak: return "WEAK"; + case LinkVerdict::Marginal: return "MARGINAL"; + case LinkVerdict::Healthy: return "HEALTHY"; + } + return "?"; +} + +struct ActivePolicy { + double loss_impair = 0.05; /* >5% seq loss = impaired */ + double crc_impair = 0.10; /* >10% crc/icv of delivered = impaired */ + double fec_margin_impair = 0.0; /* fec_margin <= this = impaired */ + uint32_t min_frames = 20; /* fewer = window not judgeable on delivery */ +}; + +/* Classify one window. LinkHealth's verdict is reused as the fault-domain + * discriminator (its EVM/RSSI split already separates weak from saturated + * from interfered — we do not re-derive it). */ +inline Impair classify_window(const ActiveLinkWindow &w, + const ActivePolicy &p) { + if (!w.telemetry_ok) + return Impair::TelemetryDown; + + /* Fault domain first: a weak or saturated link is not a channel problem no + * matter what delivery does. */ + if (w.have_quality) { + if (w.verdict == LinkVerdict::Weak) + return Impair::WeakSignal; + if (w.verdict == LinkVerdict::Saturated) + return Impair::Saturation; + if (w.verdict == LinkVerdict::NoSignal && w.delivered == 0) + return Impair::WeakSignal; /* nothing at all: treat as range, not channel */ + } + + bool impaired = false; + if (w.have_fec) + impaired = impaired || w.fec_margin <= p.fec_margin_impair; + if (w.have_delivery && w.expected >= p.min_frames) { + const double loss = + w.expected ? 1.0 - static_cast(w.delivered) / w.expected : 0.0; + impaired = impaired || loss > p.loss_impair; + } + if (w.have_crc && w.delivered >= p.min_frames) { + const double bad = static_cast(w.crc_err + w.icv_err) / + (w.delivered + w.crc_err + w.icv_err); + impaired = impaired || bad > p.crc_impair; + } + /* A window with quality evidence but INTERFERENCE verdict and no delivery + * signal still counts as channel-impaired (raised floor, high FA). */ + if (!impaired && w.have_quality && w.verdict == LinkVerdict::Interference && + !w.have_delivery && !w.have_fec && !w.have_crc) + impaired = true; + + return impaired ? Impair::Channel : Impair::Healthy; +} + +/* Rolling window of active-link records + the derived persistence signals a + * decision needs: consecutive channel-impaired windows, and whether the + * newest window's fault domain is a non-channel one (which forces a hold). */ +class ActiveLinkTrack { +public: + explicit ActiveLinkTrack(ActivePolicy p = {}, size_t cap = 64) + : policy_(p), cap_(cap) {} + + void add(const ActiveLinkWindow &w) { + const Impair im = classify_window(w, policy_); + if (im == Impair::Channel) + ++consec_channel_; + else + consec_channel_ = 0; + last_ = im; + last_t_ms_ = w.t_ms; + have_last_ = true; + if (win_.size() >= cap_) + win_.erase(win_.begin()); + win_.push_back(w); + } + + int consecutive_channel_impaired() const { return consec_channel_; } + Impair last_domain() const { return last_; } + bool have() const { return have_last_; } + int64_t last_t_ms() const { return last_t_ms_; } + + /* Newest window's verdict, for the decision's active_verdict field. */ + LinkVerdict last_verdict() const { + return win_.empty() ? LinkVerdict::NoSignal : win_.back().verdict; + } + +private: + ActivePolicy policy_; + size_t cap_; + std::vector win_; + int consec_channel_ = 0; + Impair last_ = Impair::TelemetryDown; + int64_t last_t_ms_ = 0; + bool have_last_ = false; +}; + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_ACTIVE_LINK_H */ diff --git a/src/chanmig/ChannelDef.h b/src/chanmig/ChannelDef.h new file mode 100644 index 0000000..aee44e9 --- /dev/null +++ b/src/chanmig/ChannelDef.h @@ -0,0 +1,406 @@ +/* ChannelDef — the channel identity the adaptive-channel-migration stack + * (scout survey → scoring → migration protocol) agrees on. + * + * Identity is the exact tuple SetMonitorChannel consumes — (band, primary, + * width, offset) — because that is what both endpoints must ultimately tune; + * the center frequency is derived, never stored. Validation here is GRID + * legality only (a 40 MHz pair must be a real pair, an 80 MHz primary must + * sit inside a real quad): there is no regulatory database anywhere in + * devourer — the caller owns compliance, and the no_ir/dfs flags are + * caller-provided attributes carried through so policy can honor them. + * + * Offset semantics match SelectedChannel::ChannelOffset: + * 40 MHz: 1 = primary is the lower half (secondary above), 2 = upper. + * 80 MHz: 1..4 = primary position lowest..highest within the quad + * (the HALs derive the block center as primary +6/+2/-2/-6). + * On the 5 GHz grid both are fully determined by the primary, so the parser + * derives them and an explicit contradicting suffix is an error; a 2.4 GHz + * 40 MHz pair is genuinely ambiguous (ch 6 pairs either way) and needs the + * suffix. */ +#ifndef DEVOURER_CHANMIG_CHANNEL_DEF_H +#define DEVOURER_CHANMIG_CHANNEL_DEF_H + +#include +#include +#include +#include +#include +#include + +#include "ChannelFreq.h" +#include "SelectedChannel.h" + +namespace devourer { +namespace chanmig { + +struct ChannelDef { + uint8_t band = 0; /* 2 = 2.4 GHz, 5 = 5 GHz */ + uint8_t primary = 0; /* primary 20 MHz channel number */ + ChannelWidth_t width = CHANNEL_WIDTH_20; + uint8_t offset = 0; /* SelectedChannel::ChannelOffset semantics (above) */ + /* Caller-provided attributes (not identity): */ + bool no_ir = false; /* receive-only — never a migration target */ + bool dfs = false; /* informational */ + bool backup = false; /* preferred backup — higher scout revisit cadence */ + + /* Identity key over the RF tuple only (flags are attributes). */ + uint32_t key() const { + return (uint32_t(band) << 24) | (uint32_t(primary) << 16) | + (uint32_t(width) << 8) | offset; + } + bool same_rf(const ChannelDef &o) const { return key() == o.key(); } + + /* Occupied width in half-MHz units (5 MHz narrowband needs the half). */ + int span_halfmhz() const { + switch (width) { + case CHANNEL_WIDTH_5: return 10; + case CHANNEL_WIDTH_10: return 20; + case CHANNEL_WIDTH_40: return 80; + case CHANNEL_WIDTH_80: return 160; + default: return 40; /* 20 MHz */ + } + } + + /* Center of the occupied block, half-MHz units (MHz * 2). */ + int center_halfmhz() const { + const int p = 2 * static_cast(chan_to_freq(primary)); + if (width == CHANNEL_WIDTH_40) + return p + (offset == 1 ? 20 : -20); + if (width == CHANNEL_WIDTH_80) { + /* offset = primary position 1..4 → center at +6/+2/-2/-6 channels. */ + static const int off_ch[4] = {6, 2, -2, -6}; + const int i = (offset >= 1 && offset <= 4) ? offset - 1 : 0; + return p + 2 * 5 * off_ch[i]; + } + return p; /* 20 / 5 / 10 MHz: primary is the center */ + } + uint16_t center_mhz() const { + return static_cast(center_halfmhz() / 2); + } + + SelectedChannel to_selected() const { + return SelectedChannel{primary, offset, width, 0}; + } + + /* Canonical text: ":/[u|l]" — "5:104/40l", "5:36/80", + * "2:6/40u", "5:149/20". The 40 MHz suffix is always emitted (u = secondary + * above / primary lower, l = secondary below); 80 MHz needs none (position + * derives from the primary). Buffer must hold >= 16 bytes. */ + void format(char *out, size_t n) const { + static const char *w[] = {"20", "40", "80", "160", "80+80", "5", "10"}; + const char *ws = (width < CHANNEL_WIDTH_MAX) ? w[width] : "?"; + const char *suf = ""; + if (width == CHANNEL_WIDTH_40) + suf = (offset == 1) ? "u" : "l"; + std::snprintf(out, n, "%u:%u/%s%s", band, primary, ws, suf); + } + std::string str() const { + char b[20]; + format(b, sizeof(b)); + return b; + } +}; + +enum class DefError { + Ok, + BadBand, /* band not 2 or 5 */ + BadPrimary, /* primary outside the band's channel range */ + BadWidth, /* width unsupported as a migration candidate (160/80+80) */ + BadOffset, /* offset contradicts the grid-derived one, or out of range */ + OffGrid40, /* no legal 40 MHz pair contains this primary */ + OffGrid80, /* no legal 80 MHz quad contains this primary */ + AmbiguousOffset, /* 2.4 GHz 40 MHz with no explicit u/l */ +}; + +inline const char *def_error_name(DefError e) { + switch (e) { + case DefError::Ok: return "ok"; + case DefError::BadBand: return "bad_band"; + case DefError::BadPrimary: return "bad_primary"; + case DefError::BadWidth: return "bad_width"; + case DefError::BadOffset: return "bad_offset"; + case DefError::OffGrid40: return "off_grid_40"; + case DefError::OffGrid80: return "off_grid_80"; + case DefError::AmbiguousOffset: return "ambiguous_offset"; + } + return "?"; +} + +/* Validate grid legality and derive a missing offset where the grid fully + * determines it (5 GHz 40/80). Mutates only the offset field. */ +inline DefError normalize(ChannelDef &d) { + if (d.band != 2 && d.band != 5) + return DefError::BadBand; + if (d.band == 2 && (d.primary < 1 || d.primary > 14)) + return DefError::BadPrimary; + /* 5 GHz: the synth grid runs past the UNII channels (chan_to_freq covers + * 16..253); grid math below constrains 40/80 further. */ + if (d.band == 5 && (d.primary < 16 || d.primary > 253)) + return DefError::BadPrimary; + + switch (d.width) { + case CHANNEL_WIDTH_20: + case CHANNEL_WIDTH_5: + case CHANNEL_WIDTH_10: + if (d.offset != 0) + return DefError::BadOffset; + return DefError::Ok; + + case CHANNEL_WIDTH_40: + if (d.band == 2) { + /* Any ±4 pair inside ch1..13. Ch14 sits 12 MHz above ch13 (not on the + * 5 MHz grid), so it can be neither primary nor secondary of a pair. */ + if (d.primary == 14) + return DefError::OffGrid40; + const bool up_ok = d.primary + 4 <= 13; + const bool dn_ok = d.primary >= 5; + if (!up_ok && !dn_ok) + return DefError::OffGrid40; + if (d.offset == 0) { + if (up_ok && dn_ok) + return DefError::AmbiguousOffset; + d.offset = up_ok ? 1 : 2; + return DefError::Ok; + } + if (d.offset == 1) + return up_ok ? DefError::Ok : DefError::OffGrid40; + if (d.offset == 2) + return dn_ok ? DefError::Ok : DefError::OffGrid40; + return DefError::BadOffset; + } + { + /* 5 GHz pairs are grid-fixed. The raster restarts at 149 (5745) — the + * UNII-3 block is not on the 36-anchored raster. */ + const int anchor = d.primary >= 149 ? 149 : 36; + const int rel = d.primary - anchor; + if (rel < 0 || rel % 4 != 0) + return DefError::OffGrid40; + const uint8_t derived = (rel % 8 == 0) ? 1 : 2; + if (d.offset == 0) { + d.offset = derived; + return DefError::Ok; + } + return d.offset == derived ? DefError::Ok : DefError::BadOffset; + } + + case CHANNEL_WIDTH_80: { + if (d.band == 2) + return DefError::BadWidth; + const int anchor = d.primary >= 149 ? 149 : 36; + const int rel = d.primary - anchor; + if (rel < 0 || rel % 4 != 0) + return DefError::OffGrid80; + const uint8_t derived = static_cast((rel % 16) / 4 + 1); + if (d.offset == 0) { + d.offset = derived; + return DefError::Ok; + } + return d.offset == derived ? DefError::Ok : DefError::BadOffset; + } + + default: + return DefError::BadWidth; + } +} + +inline DefError validate(const ChannelDef &d) { + ChannelDef copy = d; + const DefError e = normalize(copy); + if (e != DefError::Ok) + return e; + /* An unset offset that normalize() had to fill means the caller's def was + * incomplete but derivable — treat as valid (identical key after fill). */ + return DefError::Ok; +} + +/* The 20 MHz constituent bins of the occupied block, ascending. Returns the + * bin count (1/2/4); out must hold 4. Narrowband occupies within one bin. */ +inline int constituent_bins(const ChannelDef &d, uint8_t out[4]) { + switch (d.width) { + case CHANNEL_WIDTH_40: { + const uint8_t lo = (d.offset == 1) ? d.primary + : static_cast(d.primary - 4); + out[0] = lo; + out[1] = static_cast(lo + 4); + return 2; + } + case CHANNEL_WIDTH_80: { + const uint8_t pos = (d.offset >= 1 && d.offset <= 4) ? d.offset : 1; + const uint8_t base = static_cast(d.primary - 4 * (pos - 1)); + for (int i = 0; i < 4; i++) + out[i] = static_cast(base + 4 * i); + return 4; + } + default: + out[0] = d.primary; + return 1; + } +} + +/* Span intersection in MHz (0 when disjoint or cross-band). */ +inline int overlap_mhz(const ChannelDef &a, const ChannelDef &b) { + if (a.band != b.band) + return 0; + const int alo = a.center_halfmhz() - a.span_halfmhz() / 2; + const int ahi = a.center_halfmhz() + a.span_halfmhz() / 2; + const int blo = b.center_halfmhz() - b.span_halfmhz() / 2; + const int bhi = b.center_halfmhz() + b.span_halfmhz() / 2; + const int lo = alo > blo ? alo : blo; + const int hi = ahi < bhi ? ahi : bhi; + return hi > lo ? (hi - lo) / 2 : 0; +} + +/* Disjoint but with an edge gap under 20 MHz — the adjacent-channel-leakage + * regime a scoring policy penalizes. */ +inline bool adjacent(const ChannelDef &a, const ChannelDef &b) { + if (a.band != b.band || overlap_mhz(a, b) > 0) + return false; + const int ahi = a.center_halfmhz() + a.span_halfmhz() / 2; + const int alo = a.center_halfmhz() - a.span_halfmhz() / 2; + const int bhi = b.center_halfmhz() + b.span_halfmhz() / 2; + const int blo = b.center_halfmhz() - b.span_halfmhz() / 2; + const int gap = (alo >= bhi) ? alo - bhi : blo - ahi; + return gap >= 0 && gap < 40; /* half-MHz units */ +} + +/* --- scan-plan grammar --- + * + * plan = tok[,tok...] + * tok = [/[u|l]][:] + * width = 5 | 10 | 20 (default) | 40 | 80 + * u/l = 40 MHz secondary above / below (required on 2.4 GHz, derived and + * checked on 5 GHz) + * flags = any of b (preferred backup) p (no-IR / receive-only) + * d (DFS, informational) + * + * e.g. "104/40l:b,36/80,132/20:p" + * + * Band derives from the primary (<=14 → 2.4 GHz); mixed-band plans are + * rejected because the scout's cheap retune path is intra-band. Malformed and + * off-grid tokens are REPORTED, never silently dropped — a silently missing + * migration candidate is a field hazard. */ +struct PlanParseError { + std::string token; + std::string reason; +}; + +inline bool parse_chan_token(const std::string &tok, ChannelDef &out, + std::string &err) { + out = ChannelDef{}; + std::string t = tok; + /* Optional canonical band prefix ("5:104/40l" — what format() emits), so + * formatted defs round-trip through the parser. Checked against the band + * the primary derives. */ + int want_band = 0; + if (t.size() >= 2 && (t[0] == '2' || t[0] == '5') && t[1] == ':') { + want_band = t[0] - '0'; + t = t.substr(2); + } + /* flags suffix */ + const size_t colon = t.find(':'); + if (colon != std::string::npos) { + for (size_t i = colon + 1; i < t.size(); ++i) { + switch (t[i]) { + case 'b': out.backup = true; break; + case 'p': out.no_ir = true; break; + case 'd': out.dfs = true; break; + default: + err = "unknown flag"; + return false; + } + } + t = t.substr(0, colon); + } + /* width (+ u/l) */ + ChannelWidth_t width = CHANNEL_WIDTH_20; + uint8_t offset = 0; + const size_t slash = t.find('/'); + if (slash != std::string::npos) { + std::string w = t.substr(slash + 1); + t = t.substr(0, slash); + if (!w.empty() && (w.back() == 'u' || w.back() == 'l')) { + offset = (w.back() == 'u') ? 1 : 2; + w.pop_back(); + } + if (w == "5") width = CHANNEL_WIDTH_5; + else if (w == "10") width = CHANNEL_WIDTH_10; + else if (w == "20") width = CHANNEL_WIDTH_20; + else if (w == "40") width = CHANNEL_WIDTH_40; + else if (w == "80") width = CHANNEL_WIDTH_80; + else { + err = "bad width"; + return false; + } + if (offset != 0 && width != CHANNEL_WIDTH_40) { + err = "u/l suffix is 40 MHz-only"; + return false; + } + } + char *end = nullptr; + const long ch = std::strtol(t.c_str(), &end, 10); + if (t.empty() || end == nullptr || *end != '\0' || ch <= 0 || ch > 253) { + err = "bad primary channel"; + return false; + } + out.primary = static_cast(ch); + out.band = (ch <= 14) ? 2 : 5; + if (want_band != 0 && want_band != out.band) { + err = "band prefix contradicts the primary"; + return false; + } + out.width = width; + out.offset = offset; + const DefError e = normalize(out); + if (e != DefError::Ok) { + err = def_error_name(e); + return false; + } + return true; +} + +inline bool parse_scan_plan(const char *text, std::vector &out, + std::vector &errors) { + out.clear(); + errors.clear(); + if (text == nullptr || *text == '\0') { + errors.push_back({"", "empty plan"}); + return false; + } + const std::string s = text; + size_t pos = 0; + while (pos <= s.size()) { + const size_t comma = s.find(',', pos); + const std::string tok = s.substr( + pos, comma == std::string::npos ? std::string::npos : comma - pos); + pos = (comma == std::string::npos) ? s.size() + 1 : comma + 1; + if (tok.empty()) + continue; + ChannelDef d; + std::string err; + if (!parse_chan_token(tok, d, err)) { + errors.push_back({tok, err}); + continue; + } + if (!out.empty() && d.band != out.front().band) { + errors.push_back({tok, "mixed band (scout plans are single-band)"}); + continue; + } + bool dup = false; + for (const ChannelDef &o : out) + if (o.same_rf(d)) { + errors.push_back({tok, "duplicate candidate"}); + dup = true; + break; + } + if (!dup) + out.push_back(d); + } + if (out.empty() && errors.empty()) + errors.push_back({"", "empty plan"}); + return errors.empty() && !out.empty(); +} + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_CHANNEL_DEF_H */ diff --git a/src/chanmig/ChannelEvents.h b/src/chanmig/ChannelEvents.h new file mode 100644 index 0000000..eda3930 --- /dev/null +++ b/src/chanmig/ChannelEvents.h @@ -0,0 +1,94 @@ +/* channel.recommend / channel.hold / channel.reject JSONL binding — the + * advisory engine's output, emitted from a Decision. One header so the schema + * has a single definition (chanscout emits; the migration controller and the + * dashboard parse). Every event carries the evidence generation, plan hash, + * and policy hash, so any decision is reproducible from the logs. */ +#ifndef DEVOURER_CHANMIG_CHANNEL_EVENTS_H +#define DEVOURER_CHANMIG_CHANNEL_EVENTS_H + +#include "Event.h" +#include "chanmig/ChannelScore.h" + +namespace devourer { +namespace chanmig { + +inline const char *impair_name(Impair im) { + switch (im) { + case Impair::Healthy: return "healthy"; + case Impair::Channel: return "channel"; + case Impair::WeakSignal: return "weak_signal"; + case Impair::Saturation: return "saturation"; + case Impair::TelemetryDown: return "telemetry_down"; + } + return "?"; +} + +/* Emit the decision. `recommend` and `hold` share one envelope (kind field); + * a candidate that lost qualification since the previous decision is emitted + * as a separate channel.reject so a consumer watching only rejects sees it. */ +inline void emit_decision(EventSink &sink, const Decision &d, + const ChannelDef &active) { + Ev ev(sink, d.kind == Decision::Kind::Recommend ? "channel.recommend" + : "channel.hold"); + ev.t().f("v", 1); + ev.hexf("gen", d.evidence_gen, 0); + ev.hexf("plan", d.plan_hash, 8); + ev.hexf("policy", d.policy_hash, 8); + char from[20]; + active.format(from, sizeof(from)); + ev.f("from", from).f("reason", reason_name(d.primary_reason)); + ev.f("active_verdict", verdict_label(d.active_verdict)); + ev.f("active_domain", impair_name(d.active_domain)) + .f("impaired_windows", d.active_impaired_windows); + if (d.kind == Decision::Kind::Recommend) { + char to[20]; + d.target.format(to, sizeof(to)); + ev.f("to", to); + for (const CandidateScore &c : d.ranking) + if (c.def.same_rf(d.target)) { + ev.f("score", c.score).f("conf", c.confidence).f("occ", c.occ_q50) + .f("rounds", (unsigned long long)c.rounds) + .f("obs_ms", (unsigned long long)c.observe_ms); + break; + } + } + ev.f("text", d.human_reason.c_str()); +} + +/* Full per-candidate ranking as one line, so the dashboard can render the + * counterfactual (why NOT each candidate) purely from the log. */ +inline void emit_ranking(EventSink &sink, const Decision &d) { + Ev ev(sink, "channel.ranking"); + ev.t().hexf("gen", d.evidence_gen, 0).f("n", (long long)d.ranking.size()); + int rank = 0; + for (const CandidateScore &c : d.ranking) { + char chan[20]; + c.def.format(chan, sizeof(chan)); + /* one flat field per candidate, keyed by rank index — no nested objects */ + char key[8]; + std::snprintf(key, sizeof(key), "c%d", rank); + char buf[112]; + std::snprintf(buf, sizeof(buf), "%s q=%d score=%.2f occ=%.2f rej=%d", chan, + c.qualified ? 1 : 0, c.score, c.occ_q50, c.n_rejections); + ev.f(key, buf); + ++rank; + } +} + +/* Emit a candidate that just lost qualification (a channel.reject watcher). */ +inline void emit_reject(EventSink &sink, const CandidateScore &c, + uint64_t gen) { + Ev ev(sink, "channel.reject"); + char chan[20]; + c.def.format(chan, sizeof(chan)); + ev.t().hexf("gen", gen, 0).f("chan", chan); + if (c.n_rejections > 0) + ev.f("reason", reason_name(c.rejections[0])); + ev.f("occ", c.occ_q50).f("age_ms", (long long)c.evidence_age_ms) + .f("rounds", (unsigned long long)c.rounds); +} + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_CHANNEL_EVENTS_H */ diff --git a/src/chanmig/ChannelScore.cpp b/src/chanmig/ChannelScore.cpp new file mode 100644 index 0000000..341f5a7 --- /dev/null +++ b/src/chanmig/ChannelScore.cpp @@ -0,0 +1,353 @@ +#include "chanmig/ChannelScore.h" + +#include +#include +#include +#include + +namespace devourer { +namespace chanmig { + +const char *reason_name(Reason r) { + switch (r) { + case Reason::RecommendBetterCandidate: return "RecommendBetterCandidate"; + case Reason::HoldActiveHealthy: return "HoldActiveHealthy"; + case Reason::HoldImpairmentNotPersistent: return "HoldImpairmentNotPersistent"; + case Reason::HoldImpairmentNotChannel: return "HoldImpairmentNotChannel"; + case Reason::HoldBroadDegradation: return "HoldBroadDegradation"; + case Reason::HoldCooldown: return "HoldCooldown"; + case Reason::HoldPrimaryTelemetryStale: return "HoldPrimaryTelemetryStale"; + case Reason::HoldNoQualifiedCandidate: return "HoldNoQualifiedCandidate"; + case Reason::HoldImprovementMargin: return "HoldImprovementMargin"; + case Reason::HoldScoutUnhealthy: return "HoldScoutUnhealthy"; + case Reason::RejEvidenceStale: return "RejEvidenceStale"; + case Reason::RejInsufficientObservation: return "RejInsufficientObservation"; + case Reason::RejInsufficientRounds: return "RejInsufficientRounds"; + case Reason::RejIncompleteWidth: return "RejIncompleteWidth"; + case Reason::RejIllegalDef: return "RejIllegalDef"; + case Reason::RejNoIr: return "RejNoIr"; + case Reason::RejOccupied: return "RejOccupied"; + case Reason::RejBursty: return "RejBursty"; + case Reason::RejOverlapPenalty: return "RejOverlapPenalty"; + case Reason::RejMixedAdapter: return "RejMixedAdapter"; + case Reason::RejScoutUnhealthy: return "RejScoutUnhealthy"; + case Reason::RejActiveChannel: return "RejActiveChannel"; + } + return "?"; +} + +uint32_t PolicyConfig::policy_hash() const { + uint32_t h = 2166136261u; + auto fold = [&h](const void *p, size_t n) { + const uint8_t *b = static_cast(p); + for (size_t i = 0; i < n; i++) { + h ^= b[i]; + h *= 16777619u; + } + }; + fold(&impaired_windows_min, sizeof(impaired_windows_min)); + fold(&active.loss_impair, sizeof(active.loss_impair)); + fold(&active.crc_impair, sizeof(active.crc_impair)); + fold(&active.fec_margin_impair, sizeof(active.fec_margin_impair)); + fold(&active.min_frames, sizeof(active.min_frames)); + fold(&min_rounds, sizeof(min_rounds)); + fold(&min_clean_obs_ms, sizeof(min_clean_obs_ms)); + fold(&max_evidence_age_ms, sizeof(max_evidence_age_ms)); + fold(&qualify_max_occupancy, sizeof(qualify_max_occupancy)); + fold(&bursty_max, sizeof(bursty_max)); + fold(&improvement_margin, sizeof(improvement_margin)); + fold(&min_recommend_interval_ms, sizeof(min_recommend_interval_ms)); + fold(&broad_degrade_frac, sizeof(broad_degrade_frac)); + fold(&fa_half_rate, sizeof(fa_half_rate)); + fold(&overlap_penalty, sizeof(overlap_penalty)); + fold(&age_decay_tau_ms, sizeof(age_decay_tau_ms)); + return h; +} + +bool parse_policy_file(const char *path, PolicyConfig &cfg, std::string *err) { + std::FILE *f = std::fopen(path, "r"); + if (f == nullptr) { + if (err) + *err = "cannot open policy file"; + return false; + } + char line[256]; + while (std::fgets(line, sizeof(line), f)) { + char key[64]; + double val; + /* `key value`, '#' comments, blank lines ignored. */ + if (line[0] == '#' || line[0] == '\n') + continue; + if (std::sscanf(line, "%63s %lf", key, &val) != 2) + continue; + if (!std::strcmp(key, "impaired_windows_min")) + cfg.impaired_windows_min = static_cast(val); + else if (!std::strcmp(key, "loss_impair")) + cfg.active.loss_impair = val; + else if (!std::strcmp(key, "crc_impair")) + cfg.active.crc_impair = val; + else if (!std::strcmp(key, "fec_margin_impair")) + cfg.active.fec_margin_impair = val; + else if (!std::strcmp(key, "active_min_frames")) + cfg.active.min_frames = static_cast(val); + else if (!std::strcmp(key, "min_rounds")) + cfg.min_rounds = static_cast(val < 0 ? 0 : val); + else if (!std::strcmp(key, "min_clean_obs_ms")) + cfg.min_clean_obs_ms = static_cast(val); + else if (!std::strcmp(key, "max_evidence_age_ms")) + cfg.max_evidence_age_ms = static_cast(val); + else if (!std::strcmp(key, "qualify_max_occupancy")) + cfg.qualify_max_occupancy = val; + else if (!std::strcmp(key, "bursty_max")) + cfg.bursty_max = val; + else if (!std::strcmp(key, "improvement_margin")) + cfg.improvement_margin = val; + else if (!std::strcmp(key, "min_recommend_interval_ms")) + cfg.min_recommend_interval_ms = static_cast(val); + else if (!std::strcmp(key, "broad_degrade_frac")) + cfg.broad_degrade_frac = val; + else if (!std::strcmp(key, "fa_half_rate")) + cfg.fa_half_rate = val; + else if (!std::strcmp(key, "overlap_penalty")) + cfg.overlap_penalty = val; + else if (!std::strcmp(key, "age_decay_tau_ms")) + cfg.age_decay_tau_ms = val; + } + std::fclose(f); + return true; +} + +RecommendEngine::RecommendEngine(PolicyConfig policy, + std::vector candidates, + uint32_t plan_hash, ChannelDef active) + : policy_(policy), plan_hash_(plan_hash), active_(active), + store_(std::move(candidates), plan_hash, policy.max_evidence_age_ms), + track_(policy.active) {} + +/* Per-cell occupancy proxy in [0,1] from the two physical-ish signals that + * on-air-track real occupancy across generations: decoded foreign airtime, + * and a bounded false-alarm term (NHM is deliberately excluded — its absolute + * floor is generation-dependent, e.g. the 8822B reads busy on a quiet + * channel). */ +static double cell_occupancy(const BinCell &c, double fa_half) { + const double fa_term = c.fa_rate / (c.fa_rate + fa_half); + double occ = c.oth_air_frac + fa_term; + if (occ > 1.0) + occ = 1.0; + return occ; +} + +static double quantile(std::vector v, double q) { + if (v.empty()) + return 0.0; + std::sort(v.begin(), v.end()); + const double idx = q * (v.size() - 1); + const size_t lo = static_cast(idx); + const size_t hi = lo + 1 < v.size() ? lo + 1 : lo; + const double frac = idx - lo; + return v[lo] * (1.0 - frac) + v[hi] * frac; +} + +CandidateScore RecommendEngine::score_candidate(size_t i, + int64_t now_ms) const { + CandidateScore s; + s.def = store_.candidate(i); + uint8_t bins[4]; + const int nb = constituent_bins(s.def, bins); + s.bins_total = nb; + s.bins_covered = store_.bins_covered(i, now_ms); + s.evidence_age_ms = store_.evidence_age_ms(i, now_ms); + s.rounds = store_.rounds_covered(i, now_ms); + + auto reject = [&s](Reason r) { + if (s.n_rejections < 6) + s.rejections[s.n_rejections++] = r; + }; + + /* Occupancy = the WORST constituent bin (a wide channel is only as clean as + * its dirtiest 20 MHz slice). Per bin, q50/q90 of cell occupancy. */ + double worst_q50 = 0.0, worst_q90 = 0.0; + uint64_t total_obs = 0; + bool any_cells = false; + int64_t min_bin_clean_ms = INT64_MAX; + for (int b = 0; b < nb; b++) { + std::vector cells; + store_.fresh_cells(bins[b], now_ms, cells); + int64_t bin_obs = 0; + std::vector occ; + for (const BinCell *c : cells) { + occ.push_back(cell_occupancy(*c, policy_.fa_half_rate)); + bin_obs += c->observe_ms; + total_obs += static_cast(c->observe_ms); + any_cells = true; + } + if (!occ.empty()) { + const double q50 = quantile(occ, 0.5), q90 = quantile(occ, 0.9); + if (q50 > worst_q50) + worst_q50 = q50; + if (q90 > worst_q90) + worst_q90 = q90; + } + if (bin_obs < min_bin_clean_ms) + min_bin_clean_ms = bin_obs; + } + s.observe_ms = total_obs; + s.occ_q50 = worst_q50; + s.occ_q90 = worst_q90; + s.burstiness = worst_q90 - worst_q50; + + /* Overlap penalty: a candidate overlapping or adjacent to the (impaired) + * active channel may inherit its interferer. */ + if (overlap_mhz(s.def, active_) > 0 || adjacent(s.def, active_)) + s.overlap_pen = policy_.overlap_penalty; + + const double occupancy = + std::min(1.0, s.occ_q50 + s.overlap_pen); + s.score = 1.0 - occupancy; + + /* Confidence: an engineering scalar (not a formal CI) folding observation + * volume, round coverage, and evidence age. Documented in the doc. */ + const double obs_factor = + std::min(1.0, static_cast(total_obs) / + std::max(1, policy_.min_clean_obs_ms * nb * 2)); + const double rounds_factor = + std::min(1.0, static_cast(s.rounds) / + std::max(1, policy_.min_rounds * 2)); + const double age_factor = + s.evidence_age_ms < 0 + ? 0.0 + : std::exp(-static_cast(s.evidence_age_ms) / + policy_.age_decay_tau_ms); + s.confidence = obs_factor * rounds_factor * age_factor; + + /* Gates (order = most-fundamental first; all recorded). */ + if (s.def.no_ir) + reject(Reason::RejNoIr); + if (validate(s.def) != DefError::Ok) + reject(Reason::RejIllegalDef); + if (s.def.same_rf(active_)) + reject(Reason::RejActiveChannel); + if (!scout_healthy_) + reject(Reason::RejScoutUnhealthy); + if (!any_cells || s.evidence_age_ms < 0) + reject(Reason::RejEvidenceStale); + else if (s.evidence_age_ms > policy_.max_evidence_age_ms) + reject(Reason::RejEvidenceStale); + if (s.bins_covered < s.bins_total) + reject(Reason::RejIncompleteWidth); + if (any_cells && min_bin_clean_ms < policy_.min_clean_obs_ms) + reject(Reason::RejInsufficientObservation); + if (s.rounds < policy_.min_rounds) + reject(Reason::RejInsufficientRounds); + if (any_cells && occupancy > policy_.qualify_max_occupancy) + reject(Reason::RejOccupied); + if (any_cells && s.burstiness > policy_.bursty_max) + reject(Reason::RejBursty); + + s.qualified = (s.n_rejections == 0); + return s; +} + +Decision RecommendEngine::decide(int64_t now_ms) { + Decision d; + d.plan_hash = plan_hash_; + d.policy_hash = policy_.policy_hash(); + d.evidence_gen = store_.generation(); + d.active_impaired_windows = track_.consecutive_channel_impaired(); + d.active_verdict = track_.last_verdict(); + d.active_domain = track_.last_domain(); + + /* Score + rank every candidate (best score first, deterministic tie-break + * by ascending RF key). */ + for (size_t i = 0; i < store_.candidate_count(); ++i) + d.ranking.push_back(score_candidate(i, now_ms)); + std::sort(d.ranking.begin(), d.ranking.end(), + [](const CandidateScore &a, const CandidateScore &b) { + if (a.qualified != b.qualified) + return a.qualified; /* qualified first */ + if (a.score != b.score) + return a.score > b.score; + return a.def.key() < b.def.key(); + }); + + auto finish = [&](Decision::Kind k, Reason r, const std::string &text) { + d.kind = k; + d.primary_reason = r; + d.human_reason = text; + return d; + }; + + /* leg 1 — active-channel fault domain and persistence. */ + if (!track_.have() || d.active_domain == Impair::TelemetryDown) + return finish(Decision::Kind::Hold, Reason::HoldPrimaryTelemetryStale, + "no trustworthy primary delivery telemetry"); + if (!scout_healthy_) + return finish(Decision::Kind::Hold, Reason::HoldScoutUnhealthy, + "scout unhealthy — candidate evidence untrusted"); + if (d.active_domain == Impair::WeakSignal) + return finish(Decision::Kind::Hold, Reason::HoldImpairmentNotChannel, + "active link weak (range/sensitivity) — a move will not help"); + if (d.active_domain == Impair::Saturation) + return finish(Decision::Kind::Hold, Reason::HoldImpairmentNotChannel, + "active link saturated (near-field) — reduce power, do not move"); + if (d.active_domain == Impair::Healthy && + d.active_impaired_windows < policy_.impaired_windows_min) + return finish(Decision::Kind::Hold, Reason::HoldActiveHealthy, + "active video delivering — hold"); + if (d.active_impaired_windows < policy_.impaired_windows_min) + return finish(Decision::Kind::Hold, Reason::HoldImpairmentNotPersistent, + "impairment not yet persistent enough to act"); + + /* Broad degradation: if most candidates are ALSO busy, the interference is + * not this channel's — moving won't escape it. */ + int busy = 0, scored = 0; + for (const CandidateScore &c : d.ranking) { + if (c.evidence_age_ms < 0) + continue; + ++scored; + if (std::min(1.0, c.occ_q50 + c.overlap_pen) > policy_.qualify_max_occupancy) + ++busy; + } + if (scored > 0 && + static_cast(busy) / scored >= policy_.broad_degrade_frac) + return finish(Decision::Kind::Hold, Reason::HoldBroadDegradation, + "interference spans most candidates — broad, not channel-local"); + + /* leg 2 — a qualified, materially-clean candidate. */ + const CandidateScore &best = d.ranking.front(); + if (!best.qualified) + return finish(Decision::Kind::Hold, Reason::HoldNoQualifiedCandidate, + "no candidate passed all qualification gates"); + /* Materially better than merely-acceptable: score must clear the qualify + * floor by the configured margin (a barely-clean candidate is not worth a + * whole-link move). */ + const double floor = 1.0 - policy_.qualify_max_occupancy; + if (best.score < floor + policy_.improvement_margin) + return finish(Decision::Kind::Hold, Reason::HoldImprovementMargin, + "best candidate clean but not by the required margin"); + + /* Cooldown: rate-limit recommendations so a rank alternation between two + * near-equal candidates cannot churn the operator. */ + if (last_recommend_ms_ != INT64_MIN && + now_ms - last_recommend_ms_ < policy_.min_recommend_interval_ms) + return finish(Decision::Kind::Hold, Reason::HoldCooldown, + "within the minimum interval since the last recommendation"); + + last_recommend_ms_ = now_ms; + d.target = best.def; + char from[20], to[20]; + active_.format(from, sizeof(from)); + best.def.format(to, sizeof(to)); + char text[160]; + std::snprintf(text, sizeof(text), + "%s impaired %d windows; %s clean (occ %.2f, %llu rounds, " + "%llu ms) — recommend move", + from, d.active_impaired_windows, to, best.occ_q50, + (unsigned long long)best.rounds, + (unsigned long long)best.observe_ms); + return finish(Decision::Kind::Recommend, Reason::RecommendBetterCandidate, + text); +} + +} /* namespace chanmig */ +} /* namespace devourer */ diff --git a/src/chanmig/ChannelScore.h b/src/chanmig/ChannelScore.h new file mode 100644 index 0000000..84bb4ac --- /dev/null +++ b/src/chanmig/ChannelScore.h @@ -0,0 +1,164 @@ +/* RecommendEngine — the pure, explainable channel-recommendation policy. + * + * Inputs are timestamped records (scout survey folds + primary active-link + * windows); output is a Decision that is either Hold or Recommend, carrying a + * full per-candidate ranking with exact reason/rejection codes and the + * evidence generation it was computed at. The engine reads no clock (the + * caller passes now), no env, no device — it only ever emits advice, so it + * structurally cannot retune anything. + * + * The recommendation law has two independent legs, deliberately kept in + * separate unit worlds (the "never compare raw across adapters / across the + * primary-scout boundary" rule): + * leg 1 the ACTIVE channel is PERSISTENTLY impaired by DELIVERY evidence + * (the primary receiver's loss/FEC/CRC), and the impairment's fault + * domain is channel-attributable — not weak-signal, not near-field + * saturation, not a broad multi-candidate degradation; + * leg 2 a CANDIDATE is sufficiently observed (rounds, per-bin clean time, + * full-width coverage, freshness), legal, and materially clean in + * the scout's own within-scout-normalized occupancy terms. + * A single threshold crossing on either leg never recommends a move. + * + * Thresholds live in PolicyConfig, loadable from a bare `key value` file so + * the exact policy artifact is versionable and hashable; policy_hash is + * stamped into every decision, making any output reproducible from the logs. + * + * Pure; selftested against the issue's offline scenario matrix. */ +#ifndef DEVOURER_CHANMIG_CHANNEL_SCORE_H +#define DEVOURER_CHANMIG_CHANNEL_SCORE_H + +#include +#include +#include + +#include "chanmig/ActiveLink.h" +#include "chanmig/ChannelDef.h" +#include "chanmig/EvidenceStore.h" + +namespace devourer { +namespace chanmig { + +enum class Reason { + /* recommend */ + RecommendBetterCandidate, + /* holds (whole-decision) */ + HoldActiveHealthy, + HoldImpairmentNotPersistent, + HoldImpairmentNotChannel, + HoldBroadDegradation, + HoldCooldown, + HoldPrimaryTelemetryStale, + HoldNoQualifiedCandidate, + HoldImprovementMargin, + HoldScoutUnhealthy, + /* per-candidate rejections */ + RejEvidenceStale, + RejInsufficientObservation, + RejInsufficientRounds, + RejIncompleteWidth, + RejIllegalDef, + RejNoIr, + RejOccupied, + RejBursty, + RejOverlapPenalty, + RejMixedAdapter, + RejScoutUnhealthy, + RejActiveChannel, +}; + +const char *reason_name(Reason r); + +struct PolicyConfig { + /* leg 1 — active-channel impairment */ + int impaired_windows_min = 5; /* consecutive channel-impaired windows */ + ActivePolicy active; + + /* leg 2 — candidate qualification */ + uint64_t min_rounds = 3; /* distinct complete scan rounds */ + int64_t min_clean_obs_ms = 3000; /* fresh observation per constituent bin */ + int64_t max_evidence_age_ms = 60000; + double qualify_max_occupancy = 0.20; /* candidate occupancy above = Occupied */ + double bursty_max = 0.30; /* q90-q50 above = Bursty */ + /* A candidate can be acceptable (qualifies) yet not worth a whole-link move. + * To RECOMMEND, its score must clear the qualify floor (1 - occupancy bar) + * by this margin — hysteresis between "fine to sit on" and "worth escaping + * to". Kept small enough that floor + margin stays <= 1. */ + double improvement_margin = 0.08; + + /* anti-churn / broad-degradation */ + int64_t min_recommend_interval_ms = 30000; + double broad_degrade_frac = 0.75; /* fraction of candidates busy = broad */ + + /* occupancy model + confidence */ + double fa_half_rate = 200.0; /* fa/s giving a 0.5 fa occupancy term */ + double overlap_penalty = 0.15; /* added occupancy for an active-adjacent cand */ + double age_decay_tau_ms = 30000.0; /* confidence decay constant */ + + uint32_t policy_hash() const; +}; + +/* Parse a bare `key value` policy file into cfg (unknown keys ignored, values + * clamped to sane ranges). Returns false only on an unreadable file. */ +bool parse_policy_file(const char *path, PolicyConfig &cfg, + std::string *err = nullptr); + +struct CandidateScore { + ChannelDef def; + bool qualified = false; + double score = 0.0; /* [0,1], 1 = clean */ + double confidence = 0.0; /* [0,1] engineering scalar (obs, rounds, age) */ + double occ_q50 = 0.0, occ_q90 = 0.0; + double burstiness = 0.0; + double overlap_pen = 0.0; + int64_t evidence_age_ms = -1; + uint64_t observe_ms = 0; + uint64_t rounds = 0; + int bins_covered = 0, bins_total = 0; + Reason rejections[6] = {}; + int n_rejections = 0; +}; + +struct Decision { + enum class Kind { Hold, Recommend } kind = Kind::Hold; + Reason primary_reason = Reason::HoldPrimaryTelemetryStale; + ChannelDef target; /* Recommend only */ + std::vector ranking; /* every candidate, best first */ + int active_impaired_windows = 0; + LinkVerdict active_verdict = LinkVerdict::NoSignal; + Impair active_domain = Impair::TelemetryDown; + uint64_t evidence_gen = 0; + uint32_t plan_hash = 0, policy_hash = 0; + std::string human_reason; +}; + +class RecommendEngine { +public: + RecommendEngine(PolicyConfig policy, std::vector candidates, + uint32_t plan_hash, ChannelDef active); + + EvidenceStore::Fold ingest_dwell(const SurveyDwell &d, int64_t now_ms) { + return store_.ingest(d, now_ms); + } + void ingest_active(const ActiveLinkWindow &w) { track_.add(w); } + void note_scout_health(bool healthy) { scout_healthy_ = healthy; } + + uint64_t generation() const { return store_.generation(); } + + Decision decide(int64_t now_ms); + +private: + CandidateScore score_candidate(size_t i, int64_t now_ms) const; + + PolicyConfig policy_; + uint32_t plan_hash_; + ChannelDef active_; + EvidenceStore store_; + ActiveLinkTrack track_; + bool scout_healthy_ = true; + int64_t last_recommend_ms_ = INT64_MIN; +}; + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_CHANNEL_SCORE_H */ diff --git a/src/chanmig/EvidenceStore.h b/src/chanmig/EvidenceStore.h new file mode 100644 index 0000000..5ad7b39 --- /dev/null +++ b/src/chanmig/EvidenceStore.h @@ -0,0 +1,295 @@ +/* EvidenceStore — the rolling per-candidate evidence the scoring engine + * decides from, fed by SurveyDwell records (live from the dwell executor, or + * replayed from JSONL). + * + * Structure follows the physics: evidence is stored per 20 MHz BIN (a dwell + * observes one bin; two candidates sharing a bin share its evidence), and a + * candidate's occupancy derives from its constituent bins at decision time + * with worst-bin gating — a wide channel is only clean if every bin is clean. + * + * The fold path is the trust boundary. A record is folded only when it + * carries the same plan hash (same immutable candidate set), the same scout + * identity (raw energy units are only comparable within one adapter — a + * record from a different scout resets the store rather than silently mixing + * calibration domains), no failure flags, a plausible counter delta for its + * observation window (a hardware counter wrap or mid-read reset shows up as + * an absurd rate — better to drop one dwell than to poison a ranking), and + * an age inside the freshness bound. Every rejection is counted by reason so + * a health layer can tell "quiet" from "being fed garbage". + * + * Pure: no clocks (caller passes now), no I/O, no env. Selftested. */ +#ifndef DEVOURER_CHANMIG_EVIDENCE_STORE_H +#define DEVOURER_CHANMIG_EVIDENCE_STORE_H + +#include +#include + +#include "chanmig/ChannelDef.h" +#include "chanmig/SurveyRecord.h" + +namespace devourer { +namespace chanmig { + +/* One accepted dwell, reduced to the rate/fraction form scoring consumes. + * Rates are per-second of valid observation. nhm_busy_pct 255 = absent. */ +struct BinCell { + int64_t t_ms = 0; /* record time (dwell end, producer's monotonic clock) */ + int64_t observe_ms = 0; + double cca_rate = 0.0; + double fa_rate = 0.0; + uint8_t nhm_busy_pct = 255; + double dvr_air_frac = 0.0; /* recognized own-video airtime / observe */ + double oth_air_frac = 0.0; /* other decoded airtime / observe */ + uint32_t frames = 0; + uint64_t round = 0; +}; + +class EvidenceStore { +public: + enum class Fold { + Accepted, + RejectedFlags, /* dwell aborted / truncated / read failed */ + RejectedSuspect, /* counter delta implausible for the window */ + RejectedStale, /* record older than max_age at ingest */ + RejectedUnknownBin, /* bin serves no configured candidate */ + RejectedMixedScout, /* different scout_id — store reset */ + RejectedPlanMismatch, /* produced under a different plan hash */ + }; + static constexpr int kFoldReasons = 7; + + EvidenceStore(std::vector candidates, uint32_t plan_hash, + int64_t max_age_ms, size_t ring_cap = 32) + : plan_hash_(plan_hash), max_age_ms_(max_age_ms), ring_cap_(ring_cap) { + for (const ChannelDef &c : candidates) { + Cand cd; + cd.def = c; + cd.n_bins = constituent_bins(c, cd.bins); + cands_.push_back(cd); + for (int i = 0; i < cd.n_bins; i++) + if (find_bin(cd.bins[i]) == nullptr) + bins_.push_back(BinRing{cd.bins[i], {}}); + } + } + + /* Counter-plausibility ceiling: no chip emits more than ~1000 CCA or FA + * events per millisecond of observation; a wrapped/reset hardware counter + * reads orders of magnitude above it. */ + static constexpr uint64_t kMaxCountsPerMs = 1000; + + Fold ingest(const SurveyDwell &d, int64_t now_ms) { + if (d.plan_hash != plan_hash_) + return count(Fold::RejectedPlanMismatch); + if (d.flags & (kFlagRetuneFailed | kFlagReadFailed | kFlagTruncated)) + return count(Fold::RejectedFlags); + if (scout_id_ != 0 && d.scout_id != scout_id_) { + /* Calibration domain changed: raw units are no longer comparable with + * what the rings hold. Reset rather than mix. */ + for (BinRing &b : bins_) + b.cells.clear(); + scout_id_ = d.scout_id; + return count(Fold::RejectedMixedScout); + } + if (scout_id_ == 0) + scout_id_ = d.scout_id; + if (now_ms - d.t_end_ms > max_age_ms_) + return count(Fold::RejectedStale); + if (d.flags & kFlagCounterSuspect) + return count(Fold::RejectedSuspect); + if (d.valid_fa) { + const uint64_t ceiling = + kMaxCountsPerMs * static_cast( + d.observe_ms > 0 ? d.observe_ms : 1); + if (d.cca_ofdm > ceiling || d.fa_ofdm > ceiling || + d.cca_cck > ceiling || d.fa_cck > ceiling) + return count(Fold::RejectedSuspect); + } + if (d.observe_ms <= 0) + return count(Fold::RejectedFlags); + + if (d.flags & kFlagFullWidth) { + Cand *c = find_cand(d.def); + if (c == nullptr) + return count(Fold::RejectedUnknownBin); + push_cell(c->wide, d); + bump(); + return count(Fold::Accepted); + } + BinRing *b = find_bin(d.def.primary); + if (b == nullptr) + return count(Fold::RejectedUnknownBin); + push_cell(b->cells, d); + bump(); + return count(Fold::Accepted); + } + + /* Evidence generation: increments on every accepted fold. Decisions cite + * the generation they were computed at, making any output reproducible + * from the record stream. */ + uint64_t generation() const { return gen_; } + uint32_t scout_id() const { return scout_id_; } + uint32_t fold_count(Fold f) const { + return fold_counts_[static_cast(f)]; + } + + size_t candidate_count() const { return cands_.size(); } + const ChannelDef &candidate(size_t i) const { return cands_[i].def; } + + /* Coverage: how many of the candidate's constituent bins hold at least one + * fresh cell (age <= max_age at `now`). A wide candidate with partial + * coverage must never rank — the missing bin could hide the interferer. */ + int bins_covered(size_t cand, int64_t now_ms) const { + const Cand &c = cands_[cand]; + int n = 0; + for (int i = 0; i < c.n_bins; i++) { + const BinRing *b = find_bin_const(c.bins[i]); + if (b && newest_fresh(*b, now_ms) != nullptr) + ++n; + } + return n; + } + int bin_count(size_t cand) const { return cands_[cand].n_bins; } + + /* Age of the candidate's OLDEST freshest-per-bin cell — the evidence age a + * decision must cite (the least-recently-observed constituent bin bounds + * how much can have changed unseen). -1 when any bin has no fresh cell. */ + int64_t evidence_age_ms(size_t cand, int64_t now_ms) const { + const Cand &c = cands_[cand]; + int64_t worst = -1; + for (int i = 0; i < c.n_bins; i++) { + const BinRing *b = find_bin_const(c.bins[i]); + const BinCell *cell = b ? newest_fresh(*b, now_ms) : nullptr; + if (cell == nullptr) + return -1; + const int64_t age = now_ms - cell->t_ms; + if (age > worst) + worst = age; + } + return worst; + } + + /* All fresh cells of one constituent bin (newest last), for the scoring + * layer's quantile/burstiness derivations. */ + void fresh_cells(uint8_t bin_ch, int64_t now_ms, + std::vector &out) const { + out.clear(); + const BinRing *b = find_bin_const(bin_ch); + if (b == nullptr) + return; + for (const BinCell &cell : b->cells) + if (now_ms - cell.t_ms <= max_age_ms_) + out.push_back(&cell); + } + void wide_cells(size_t cand, int64_t now_ms, + std::vector &out) const { + out.clear(); + for (const BinCell &cell : cands_[cand].wide) + if (now_ms - cell.t_ms <= max_age_ms_) + out.push_back(&cell); + } + + /* Distinct sweep rounds represented in the candidate's fresh evidence — + * the min-rounds gate input (a single lucky round is not persistence). */ + uint64_t rounds_covered(size_t cand, int64_t now_ms) const { + const Cand &c = cands_[cand]; + uint64_t lo = UINT64_MAX, hi = 0; + bool any = false; + for (int i = 0; i < c.n_bins; i++) { + const BinRing *b = find_bin_const(c.bins[i]); + if (b == nullptr) + continue; + for (const BinCell &cell : b->cells) { + if (now_ms - cell.t_ms > max_age_ms_) + continue; + any = true; + if (cell.round < lo) + lo = cell.round; + if (cell.round > hi) + hi = cell.round; + } + } + return any ? hi - lo + 1 : 0; + } + + int64_t max_age_ms() const { return max_age_ms_; } + +private: + struct BinRing { + uint8_t ch; + std::vector cells; /* bounded ring, newest last */ + }; + struct Cand { + ChannelDef def; + uint8_t bins[4] = {}; + int n_bins = 0; + std::vector wide; /* full-width verification dwells */ + }; + + BinRing *find_bin(uint8_t ch) { + for (BinRing &b : bins_) + if (b.ch == ch) + return &b; + return nullptr; + } + const BinRing *find_bin_const(uint8_t ch) const { + for (const BinRing &b : bins_) + if (b.ch == ch) + return &b; + return nullptr; + } + Cand *find_cand(const ChannelDef &d) { + for (Cand &c : cands_) + if (c.def.same_rf(d)) + return &c; + return nullptr; + } + const BinCell *newest_fresh(const BinRing &b, int64_t now_ms) const { + for (auto it = b.cells.rbegin(); it != b.cells.rend(); ++it) + if (now_ms - it->t_ms <= max_age_ms_) + return &*it; + return nullptr; + } + void push_cell(std::vector &ring, const SurveyDwell &d) { + BinCell cell; + cell.t_ms = d.t_end_ms; + cell.observe_ms = d.observe_ms; + const double sec = static_cast(d.observe_ms) / 1000.0; + if (d.valid_fa && sec > 0) { + cell.cca_rate = (d.cca_ofdm + d.cca_cck) / sec; + cell.fa_rate = (d.fa_ofdm + d.fa_cck) / sec; + } + cell.nhm_busy_pct = d.valid_nhm ? d.nhm_busy_pct : 255; + if (d.observe_ms > 0) { + const double us = static_cast(d.observe_ms) * 1000.0; + cell.dvr_air_frac = static_cast(d.dvr_air_us) / us; + cell.oth_air_frac = static_cast(d.oth_air_us) / us; + if (cell.dvr_air_frac > 1.0) + cell.dvr_air_frac = 1.0; + if (cell.oth_air_frac > 1.0) + cell.oth_air_frac = 1.0; + } + cell.frames = d.frames; + cell.round = d.round; + if (ring.size() >= ring_cap_) + ring.erase(ring.begin()); + ring.push_back(cell); + } + Fold count(Fold f) { + ++fold_counts_[static_cast(f)]; + return f; + } + void bump() { ++gen_; } + + uint32_t plan_hash_; + int64_t max_age_ms_; + size_t ring_cap_; + uint32_t scout_id_ = 0; + uint64_t gen_ = 0; + std::vector cands_; + std::vector bins_; + uint32_t fold_counts_[kFoldReasons] = {}; +}; + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_EVIDENCE_STORE_H */ diff --git a/src/chanmig/JsonlLite.h b/src/chanmig/JsonlLite.h new file mode 100644 index 0000000..bada0a6 --- /dev/null +++ b/src/chanmig/JsonlLite.h @@ -0,0 +1,191 @@ +/* JsonlLite — minimal field extraction from devourer machine-event lines. + * + * Devourer events are FLAT JSON objects, one per line, first field always + * "ev" (src/Event.h contract). Consumers inside the chanmig stack (the scout + * tail-following the primary receiver's JSONL, the replay tools) need a + * handful of typed lookups, not a JSON library: this is a single-pass scanner + * that tracks string state (so a "text" value containing '"key":' can never + * false-match), understands the exact value shapes Ev emits — numbers, + * strings with its escape set, true/false/null, flat arrays of numbers — and + * nothing else. Non-event lines (stderr noise, partial writes) simply fail + * every lookup. Pure, header-only, selftested. */ +#ifndef DEVOURER_CHANMIG_JSONL_LITE_H +#define DEVOURER_CHANMIG_JSONL_LITE_H + +#include +#include +#include +#include + +namespace devourer { +namespace chanmig { + +namespace jsonl_detail { +/* Locate the raw value span of "key": in a flat object. Returns false when + * the key is absent (or only occurs inside a string value). */ +inline bool find_value(std::string_view line, std::string_view key, + size_t &vbegin, size_t &vend) { + size_t i = 0; + const size_t n = line.size(); + while (i < n) { + const char c = line[i]; + if (c == '"') { + /* A string at top level is either a key (followed by ':') or a string + * value. Scan it with escapes. */ + const size_t sbegin = i + 1; + size_t j = sbegin; + while (j < n && line[j] != '"') { + if (line[j] == '\\') + ++j; + ++j; + } + if (j >= n) + return false; /* unterminated — not a complete event line */ + const size_t send = j; /* exclusive */ + i = j + 1; + /* key? */ + if (i < n && line[i] == ':') { + ++i; + if (line.substr(sbegin, send - sbegin) == key) { + vbegin = i; + /* value runs to the matching top-level ',' or '}'. Strings and + * arrays are skipped structurally; nested objects don't occur in + * this schema but are skipped defensively. */ + int depth = 0; + size_t k = i; + while (k < n) { + const char v = line[k]; + if (v == '"') { + ++k; + while (k < n && line[k] != '"') { + if (line[k] == '\\') + ++k; + ++k; + } + if (k >= n) + return false; + } else if (v == '[' || v == '{') { + ++depth; + } else if (v == ']' || v == '}') { + if (depth == 0) + break; + --depth; + } else if (v == ',' && depth == 0) { + break; + } + ++k; + } + vend = k; + return vend > vbegin; + } + /* not our key: fall through, the value scanner below advances */ + } + continue; + } + ++i; + } + return false; +} +} /* namespace jsonl_detail */ + +/* True when the line is an event line named `name` (first-field contract: + * the line starts with {"ev":""). */ +inline bool jsonl_ev_is(std::string_view line, std::string_view name) { + static const char prefix[] = "{\"ev\":\""; + if (line.size() < sizeof(prefix) - 1 + name.size() + 1) + return false; + if (line.compare(0, sizeof(prefix) - 1, prefix) != 0) + return false; + if (line.compare(sizeof(prefix) - 1, name.size(), name) != 0) + return false; + return line[sizeof(prefix) - 1 + name.size()] == '"'; +} + +/* Number lookup. false when absent, null, or not a number. */ +inline bool jsonl_num(std::string_view line, std::string_view key, + double *out) { + size_t b, e; + if (!jsonl_detail::find_value(line, key, b, e)) + return false; + const std::string v(line.substr(b, e - b)); + if (v == "null" || v.empty() || v[0] == '"' || v[0] == '[') + return false; + char *end = nullptr; + const double d = std::strtod(v.c_str(), &end); + if (end == v.c_str()) + return false; + *out = d; + return true; +} + +inline bool jsonl_int(std::string_view line, std::string_view key, + long long *out) { + double d; + if (!jsonl_num(line, key, &d)) + return false; + *out = static_cast(d); + return true; +} + +/* String lookup with the Ev escape set unescaped (\" \\ \n \t \r; \uXXXX + * folds to '?' — the schema only emits it for control bytes). */ +inline bool jsonl_str(std::string_view line, std::string_view key, + std::string *out) { + size_t b, e; + if (!jsonl_detail::find_value(line, key, b, e)) + return false; + if (e - b < 2 || line[b] != '"' || line[e - 1] != '"') + return false; + out->clear(); + for (size_t i = b + 1; i < e - 1; ++i) { + char c = line[i]; + if (c == '\\' && i + 1 < e - 1) { + const char x = line[++i]; + switch (x) { + case 'n': c = '\n'; break; + case 't': c = '\t'; break; + case 'r': c = '\r'; break; + case 'u': + i += 4 < e - 1 - i ? 4 : e - 2 - i; + c = '?'; + break; + default: c = x; break; + } + } + out->push_back(c); + } + return true; +} + +/* Flat numeric array lookup: fills out[0..max) and sets *n to the element + * count found (clamped to max). false when absent or not an array. */ +inline bool jsonl_arr(std::string_view line, std::string_view key, int *out, + int max, int *n) { + size_t b, e; + if (!jsonl_detail::find_value(line, key, b, e)) + return false; + if (line[b] != '[') + return false; + int count = 0; + size_t i = b + 1; + while (i < e && count < max) { + while (i < e && (line[i] == ',' || line[i] == ' ')) + ++i; + if (i >= e || line[i] == ']') + break; + const std::string v(line.substr(i, e - i)); + char *end = nullptr; + const long val = std::strtol(v.c_str(), &end, 10); + if (end == v.c_str()) + break; + out[count++] = static_cast(val); + i += static_cast(end - v.c_str()); + } + *n = count; + return true; +} + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_JSONL_LITE_H */ diff --git a/src/chanmig/MigClock.h b/src/chanmig/MigClock.h new file mode 100644 index 0000000..814d68a --- /dev/null +++ b/src/chanmig/MigClock.h @@ -0,0 +1,92 @@ +/* MigClock — the ground side's estimate of the drone's activation instant, + * and the guard time around it. + * + * The drone stamps its hardware TSF (tx_tsf) into every commit/status; the + * ground feeds (tx_tsf, local RX tsfl) pairs into the existing TsfSync fit. + * Because tx_tsf is software-stamped (not a beacon's hardware egress), the fit + * residual carries the host->air latency — floor tens of µs but a CSMA tail in + * the low ms. PeerClock wraps TsfSync with a ring of recent residuals so the + * guard is derived from the MEASURED p99, never a fixed guess. + * + * Scheduling itself does not need the fit: the machines schedule activation + * from the RELATIVE offset (activate_tsf - commit.tx_tsf), whose error is one + * frame's one-way latency, absorbed by the lead + verify window. The fit only + * tightens the guard and is the health readout. Pure, header-only. */ +#ifndef DEVOURER_CHANMIG_MIG_CLOCK_H +#define DEVOURER_CHANMIG_MIG_CLOCK_H + +#include +#include +#include + +#include "TsfSync.h" + +namespace devourer { +namespace chanmig { + +class PeerClock { +public: + /* Feed one (drone tx_tsf, local RX tsfl) pair. */ + void add(uint64_t remote_tsf, uint32_t local_tsfl) { + if (sync_.Ready()) { + const int64_t predicted = sync_.LocalForRemote(remote_tsf); + /* the reconstructed local time of this sample */ + const int64_t observed = recon_local(local_tsfl); + int64_t resid = observed - predicted; + if (resid < 0) + resid = -resid; + if (ring_.size() >= kRing) + ring_.erase(ring_.begin()); + ring_.push_back(resid); + } else { + (void)recon_local(local_tsfl); /* keep the local wrap tracker warm */ + } + sync_.Add(remote_tsf, local_tsfl); + } + + bool ready() const { return sync_.Ready() && ring_.size() >= 8; } + double skew_ppm() const { return sync_.SkewPpm(); } + int64_t offset_us() const { return sync_.OffsetUs(); } + long long samples() const { return sync_.Count(); } + + int64_t residual_p50() const { return percentile(0.50); } + int64_t residual_p99() const { return percentile(0.99); } + + /* The activation guard (µs): the measured fit residual p99, plus the TX + * drain p99.9 and the worst retune the demo passes in, plus a margin. All + * measured, none guessed. Falls back to a floor when the fit isn't ready. */ + int64_t guard_us(int64_t drain_p999_us, int64_t retune_worst_us, + int64_t margin_us = 2000) const { + const int64_t resid = ready() ? residual_p99() : 3000; + return resid + drain_p999_us + retune_worst_us + margin_us; + } + +private: + static constexpr size_t kRing = 64; + int64_t recon_local(uint32_t lo) { + if (linit_ && lo < plo_) + hi_ += (1LL << 32); + plo_ = lo; + linit_ = true; + return hi_ + lo; + } + int64_t percentile(double q) const { + if (ring_.empty()) + return 0; + std::vector v = ring_; + std::sort(v.begin(), v.end()); + size_t idx = static_cast(q * (v.size() - 1)); + return v[idx]; + } + + TsfSync sync_; + std::vector ring_; + int64_t hi_ = 0; + uint32_t plo_ = 0; + bool linit_ = false; +}; + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_MIG_CLOCK_H */ diff --git a/src/chanmig/MigConfig.h b/src/chanmig/MigConfig.h new file mode 100644 index 0000000..aa9cd91 --- /dev/null +++ b/src/chanmig/MigConfig.h @@ -0,0 +1,152 @@ +/* MigConfig — timer/bound parameters for the migration state machines, plus + * the pure string parsers the demo uses to map env → config (the library + * reads no env; the demo does, matching the rest of devourer). + * + * The defaults are conservative starting points; the on-air clock bench + * (tests/chanmig_clock_bench.sh) measures the real guard and the demo may + * raise lead_ms from it. */ +#ifndef DEVOURER_CHANMIG_MIG_CONFIG_H +#define DEVOURER_CHANMIG_MIG_CONFIG_H + +#include +#include +#include +#include + +#include "chanmig/ChannelDef.h" + +namespace devourer { +namespace chanmig { + +struct MigParams { + /* proposal (ground) */ + int64_t proposal_interval_ms = 200; + int proposal_max_tries = 25; + /* commit (drone) + status ack (ground) */ + int64_t commit_interval_ms = 100; + int64_t status_interval_ms = 500; /* 2 Hz drone status */ + int64_t ack_timeout_ms = 2000; /* drone: no ground ack -> abort */ + /* activation */ + int64_t countdown_ms = 500; /* drone: activation lead it picks */ + int64_t lead_ms = 40; /* ground: retune this early (from clock) */ + int64_t min_lead_ms = 50; /* activation bounds (drone TSF) */ + int64_t max_horizon_ms = 5000; + int64_t drain_deadline_ms = 20; + int64_t blank_window_ms = 5; /* ground: drop stale post-retune frames */ + /* verify (ground) */ + int64_t verify_ms = 3000; + int verify_markers = 3; + int verify_video = 10; + /* rollback / recovery */ + int64_t rollback_ms = 5000; /* drone: rollback if unconfirmed by */ + int64_t recovery_dwell_ms = 500; /* ground: per-channel scan dwell */ + int64_t recovery_giveup_ms = 60000; + int64_t proposal_hold_down_ms = 10000; /* drone: after a rollback */ +}; + +/* Parse a channel spec token, "5:104/40l" or "104/40u" or "36/80" — reuses the + * ChannelDef grammar (parse_chan_token). Returns false + fills err on a bad + * token. */ +inline bool parse_mig_chanspec(const std::string &tok, ChannelDef &out, + std::string &err) { + return parse_chan_token(tok, out, err); +} + +/* Parse a comma-separated allowed-channel list. Malformed tokens are reported + * in errs; the caller decides whether to run. */ +inline bool parse_mig_allowed(const char *text, std::vector &out, + std::vector &errs) { + out.clear(); + errs.clear(); + if (text == nullptr || *text == '\0') + return false; + const std::string s = text; + size_t pos = 0; + while (pos <= s.size()) { + const size_t comma = s.find(',', pos); + const std::string tok = s.substr( + pos, comma == std::string::npos ? std::string::npos : comma - pos); + pos = (comma == std::string::npos) ? s.size() + 1 : comma + 1; + if (tok.empty()) + continue; + ChannelDef d; + std::string err; + if (parse_chan_token(tok, d, err)) + out.push_back(d); + else + errs.push_back({tok, err}); + } + return errs.empty() && !out.empty(); +} + +/* Fault-injection drop spec (demo-side, deterministic): "type:spec[,...]", + * spec = N | N-M | * | every:K. Parsed into a per-type matcher the demo's + * send/receive seam consults. Kept here so the grammar has one definition + * shared by the on-air scripts' documentation and the code. */ +struct DropRule { + uint8_t type = 0; /* MigMsgType, 0 = any */ + int lo = -1, hi = -1; /* index range, -1 = unset */ + int every = 0; /* every:K, 0 = off */ + bool all = false; /* '*' */ +}; + +inline bool parse_drop_spec(const char *text, std::vector &out) { + out.clear(); + if (text == nullptr || *text == '\0') + return true; + const std::string s = text; + size_t pos = 0; + while (pos <= s.size()) { + const size_t comma = s.find(',', pos); + std::string tok = s.substr( + pos, comma == std::string::npos ? std::string::npos : comma - pos); + pos = (comma == std::string::npos) ? s.size() + 1 : comma + 1; + if (tok.empty()) + continue; + DropRule r; + const size_t colon = tok.find(':'); + std::string tstr = tok.substr(0, colon); + r.type = static_cast(std::strtoul(tstr.c_str(), nullptr, 0)); + if (colon == std::string::npos) { + out.push_back(r); + continue; + } + std::string spec = tok.substr(colon + 1); + if (spec == "*") { + r.all = true; + } else if (spec.rfind("every:", 0) == 0) { + r.every = std::atoi(spec.c_str() + 6); + } else { + const size_t dash = spec.find('-'); + if (dash == std::string::npos) { + r.lo = r.hi = std::atoi(spec.c_str()); + } else { + r.lo = std::atoi(spec.substr(0, dash).c_str()); + r.hi = std::atoi(spec.substr(dash + 1).c_str()); + } + } + out.push_back(r); + } + return true; +} + +/* Should the index-th message of `type` be dropped under these rules? */ +inline bool drop_matches(const std::vector &rules, uint8_t type, + int index) { + for (const DropRule &r : rules) { + if (r.type != 0 && r.type != type) + continue; + if (r.all) + return true; + if (r.every > 0 && index % r.every == 0) + return true; + if (r.lo >= 0 && index >= r.lo && index <= r.hi) + return true; + } + return false; +} + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_MIG_CONFIG_H */ diff --git a/src/chanmig/MigGate.cpp b/src/chanmig/MigGate.cpp new file mode 100644 index 0000000..8b29c42 --- /dev/null +++ b/src/chanmig/MigGate.cpp @@ -0,0 +1,199 @@ +#include "chanmig/MigGate.h" + +namespace devourer { +namespace chanmig { + +const char *gate_reason_name(GateReason r) { + switch (r) { + case GateReason::ModeOff: return "MODE_OFF"; + case GateReason::Advisory: return "ADVISORY"; + case GateReason::NotImpaired: return "NOT_IMPAIRED"; + case GateReason::FaultWeakLink: return "FAULT_WEAK_LINK"; + case GateReason::FaultBroadband: return "FAULT_BROADBAND"; + case GateReason::FaultSaturation: return "FAULT_SATURATION"; + case GateReason::FaultScout: return "FAULT_SCOUT"; + case GateReason::FaultUsb: return "FAULT_USB"; + case GateReason::TelemetryUnhealthy: return "TELEMETRY_UNHEALTHY"; + case GateReason::ClockUnsynced: return "CLOCK_UNSYNCED"; + case GateReason::ScoutStale: return "SCOUT_STALE"; + case GateReason::EvidenceStale: return "EVIDENCE_STALE"; + case GateReason::EvidenceIncomplete: return "EVIDENCE_INCOMPLETE"; + case GateReason::Illegal: return "ILLEGAL"; + case GateReason::MarginLow: return "MARGIN_LOW"; + case GateReason::ConfidenceLow: return "CONFIDENCE_LOW"; + case GateReason::Cooldown: return "COOLDOWN"; + case GateReason::MoveCap: return "MOVE_CAP"; + case GateReason::Residency: return "RESIDENCY"; + case GateReason::HolddownChannel: return "HOLDDOWN_CHANNEL"; + case GateReason::Backoff: return "BACKOFF"; + case GateReason::InFlight: return "IN_FLIGHT"; + case GateReason::Pinned: return "PINNED"; + case GateReason::Inhibited: return "INHIBITED"; + case GateReason::ApproveRequired: return "APPROVE_REQUIRED"; + case GateReason::ControlMarginLow: return "CONTROL_MARGIN_LOW"; + case GateReason::NoRescueVerified: return "NO_RESCUE_VERIFIED"; + case GateReason::ProbationActive: return "PROBATION_ACTIVE"; + case GateReason::ProbationRollback: return "PROBATION_ROLLBACK"; + case GateReason::MaterialChange: return "MATERIAL_CHANGE"; + case GateReason::Propose: return "PROPOSE"; + } + return "?"; +} + +static GateOutcome hold(GateReason r) { + GateOutcome o; + o.verdict = GateVerdict::Hold; + o.reason = r; + return o; +} + +GateOutcome mig_gate_decide(const GateInputs &in, GateState &st, + const GatePolicy &pol, int64_t now_ms) { + /* --- operator overrides + mode (highest precedence) --- */ + if (in.mode == MigMode::Off) + return hold(GateReason::ModeOff); + if (in.pinned != nullptr) + return hold(GateReason::Pinned); + if (in.inhibit_until_ms != INT64_MIN && now_ms < in.inhibit_until_ms) + return hold(GateReason::Inhibited); + + /* --- probation: destination must prove itself, else roll back --- */ + if (in.probation_active) { + if (!in.probation_delivery_ok) { + GateOutcome o; + o.verdict = GateVerdict::ProposeRollback; + o.reason = GateReason::ProbationRollback; + return o; + } + return hold(GateReason::ProbationActive); + } + + /* --- an in-flight migration: only abort on a material evidence change --- */ + if (in.in_flight) { + if (st.have_frozen && in.rec != nullptr && + in.rec->kind == Decision::Kind::Recommend) { + const bool target_moved = !in.rec->target.same_rf(st.frozen_target); + double best = 0.0; + for (const CandidateScore &c : in.rec->ranking) + if (c.def.same_rf(st.frozen_target)) { + best = c.score; + break; + } + const bool score_dropped = + best < st.frozen_score - pol.material_change_frac * 0.1; + if (target_moved || score_dropped) { + GateOutcome o; + o.verdict = GateVerdict::AbortInFlight; + o.reason = GateReason::MaterialChange; + return o; + } + } + return hold(GateReason::InFlight); + } + + /* Advisory mode never proposes (but still reports the counterfactual). */ + if (in.mode == MigMode::Advisory) + return hold(GateReason::Advisory); + + /* --- health / fault domains --- */ + if (!in.telemetry_ok) + return hold(GateReason::TelemetryUnhealthy); + if (!in.clock_synced) + return hold(GateReason::ClockUnsynced); + if (!in.usb_ok) + return hold(GateReason::FaultUsb); + if (!in.scout_healthy) + return hold(GateReason::FaultScout); + if (in.scout_survey_age_ms > in.scout_max_age_ms) + return hold(GateReason::ScoutStale); + + /* --- the advisory recommendation must itself be a Recommend --- */ + if (in.rec == nullptr) + return hold(GateReason::NotImpaired); + if (in.rec->kind != Decision::Kind::Recommend) + return hold(gate_reason_for_hold(in.rec->primary_reason)); + + /* find the recommended target's score/confidence in the ranking */ + double conf = 0.0; + bool found = false; + for (const CandidateScore &c : in.rec->ranking) + if (c.def.same_rf(in.rec->target)) { + conf = c.confidence; + found = true; + break; + } + if (!found) + return hold(GateReason::EvidenceIncomplete); + if (conf < pol.confidence_min) + return hold(GateReason::ConfidenceLow); + + /* --- anti-oscillation gates --- */ + if (st.last_move_ms != INT64_MIN && now_ms - st.last_move_ms < pol.cooldown_ms) + return hold(GateReason::Cooldown); + if (st.residency_start_ms != INT64_MIN && + now_ms - st.residency_start_ms < pol.residency_ms) + return hold(GateReason::Residency); + if (st.moves_session >= pol.move_cap) + return hold(GateReason::MoveCap); + const int64_t hd = st.holddown_for(in.rec->target.key()); + if (hd != INT64_MIN && now_ms < hd) + return hold(GateReason::HolddownChannel); + + /* --- can we actually complete the move? --- */ + if (in.control_link_margin < pol.control_margin_min) + return hold(GateReason::ControlMarginLow); + if (!in.rescue_verified) + return hold(GateReason::NoRescueVerified); + + /* --- manual mode requires an explicit one-shot approval --- */ + if (in.mode == MigMode::Manual && !in.approve_next) + return hold(GateReason::ApproveRequired); + + /* all gates passed — propose, and freeze the evidence for the + * material-change abort test. */ + st.have_frozen = true; + st.frozen_gen = in.rec->evidence_gen; + st.frozen_target = in.rec->target; + double best = 0.0; + for (const CandidateScore &c : in.rec->ranking) + if (c.def.same_rf(in.rec->target)) { + best = c.score; + break; + } + st.frozen_score = best; + + GateOutcome o; + o.verdict = GateVerdict::Propose; + o.reason = GateReason::Propose; + o.target = in.rec->target; + o.evidence_gen = in.rec->evidence_gen; + return o; +} + +void mig_gate_on_confirmed(GateState &st, const ChannelDef &now_on, + int64_t now_ms) { + (void)now_on; + st.last_move_ms = now_ms; + st.residency_start_ms = now_ms; + ++st.moves_session; + st.rollback_streak = 0; + st.have_frozen = false; +} + +void mig_gate_on_rolledback(GateState &st, const ChannelDef &target, + const GatePolicy &pol, int64_t now_ms) { + ++st.rollback_streak; + int64_t backoff = pol.holddown_base_ms; + for (int i = 1; i < st.rollback_streak && backoff < pol.holddown_cap_ms; ++i) + backoff *= 2; + if (backoff > pol.holddown_cap_ms) + backoff = pol.holddown_cap_ms; + st.set_holddown(target.key(), now_ms + backoff); + st.have_frozen = false; + /* a rollback also counts against the move budget so a flapping channel + * cannot exhaust retries silently */ + st.last_move_ms = now_ms; +} + +} /* namespace chanmig */ +} /* namespace devourer */ diff --git a/src/chanmig/MigGate.h b/src/chanmig/MigGate.h new file mode 100644 index 0000000..4b759f4 --- /dev/null +++ b/src/chanmig/MigGate.h @@ -0,0 +1,197 @@ +/* MigGate — the conservative autonomous-migration policy (issue #279). + * + * It sits between the advisory engine (a ChannelScore Decision) and the + * migration protocol (MigProposer): it may submit a proposal automatically, + * but only when a conjunction of conservative conditions holds, and it hedges + * every migration with cooldown, residency, per-channel backoff, a session + * move cap, probation, and an operator kill switch. Stable single-channel + * video is the normal state; migrations are exceptional. + * + * Pure and deterministic: no clock reads (the caller passes now), no RNG, no + * device — same input trace ⇒ same decisions, so the whole policy is replayable + * and selftested against the issue's scenario matrix. The gate does not + * actuate; it returns a verdict the ground controller feeds to MigProposer. */ +#ifndef DEVOURER_CHANMIG_MIG_GATE_H +#define DEVOURER_CHANMIG_MIG_GATE_H + +#include +#include + +#include "chanmig/ChannelDef.h" +#include "chanmig/ChannelScore.h" + +namespace devourer { +namespace chanmig { + +enum class MigMode : uint8_t { Off, Advisory, Manual, Automatic }; + +inline const char *mig_mode_name(MigMode m) { + switch (m) { + case MigMode::Off: return "off"; + case MigMode::Advisory: return "advisory"; + case MigMode::Manual: return "manual"; + case MigMode::Automatic: return "automatic"; + } + return "?"; +} + +enum class GateVerdict : uint8_t { Hold, Propose, AbortInFlight, ProposeRollback }; + +enum class GateReason : uint8_t { + ModeOff, + Advisory, + NotImpaired, /* the recommendation was a hold */ + FaultWeakLink, /* migration cannot fix a range problem */ + FaultBroadband, + FaultSaturation, + FaultScout, + FaultUsb, + TelemetryUnhealthy, + ClockUnsynced, + ScoutStale, + EvidenceStale, + EvidenceIncomplete, + Illegal, + MarginLow, + ConfidenceLow, + Cooldown, + MoveCap, + Residency, + HolddownChannel, + Backoff, + InFlight, + Pinned, + Inhibited, + ApproveRequired, + ControlMarginLow, + NoRescueVerified, + ProbationActive, + ProbationRollback, + MaterialChange, /* in-flight evidence changed → abort + rescore */ + Propose, /* all gates passed */ +}; + +const char *gate_reason_name(GateReason r); + +struct GatePolicy { + double confidence_min = 0.9; + int64_t cooldown_ms = 300000; /* 5 min between moves */ + int64_t residency_ms = 120000; /* 2 min minimum on a channel */ + int move_cap = 10; /* per session */ + int64_t holddown_base_ms = 60000; /* per-channel backoff base (×2^streak) */ + int64_t holddown_cap_ms = 3600000; + int64_t probation_ms = 60000; + double control_margin_min = 0.3; /* min recent control-frame delivery */ + double material_change_frac = 0.5; /* score drop (× margin) that aborts in-flight */ +}; + +/* Everything the gate reads for one decision. `rec` is the newest advisory + * Decision (from ChannelScore); the rest are health/fault flags the ground + * controller assembles from the primary + scout + control link. */ +struct GateInputs { + MigMode mode = MigMode::Advisory; + const Decision *rec = nullptr; /* null = no recommendation yet */ + + bool telemetry_ok = true; + bool clock_synced = true; + bool scout_healthy = true; + int64_t scout_survey_age_ms = 0; + int64_t scout_max_age_ms = 60000; + bool usb_ok = true; + + double control_link_margin = 1.0; /* recent status/ack delivery rate */ + bool rescue_verified = true; + bool in_flight = false; + + bool probation_active = false; + bool probation_delivery_ok = true; + + /* operator surface */ + bool approve_next = false; /* manual mode: one-shot approval armed */ + int64_t inhibit_until_ms = INT64_MIN; + const ChannelDef *pinned = nullptr; +}; + +/* Mutable anti-oscillation state the gate carries across decisions. */ +struct GateState { + int64_t last_move_ms = INT64_MIN; + int moves_session = 0; + int64_t residency_start_ms = INT64_MIN; + int rollback_streak = 0; + struct Holddown { + uint32_t key; + int64_t until_ms; + }; + std::vector holddowns; + /* frozen in-flight proposal, for the material-change abort test */ + bool have_frozen = false; + uint64_t frozen_gen = 0; + ChannelDef frozen_target; + double frozen_score = 0.0; + + int64_t holddown_for(uint32_t key) const { + for (const Holddown &h : holddowns) + if (h.key == key) + return h.until_ms; + return INT64_MIN; + } + void set_holddown(uint32_t key, int64_t until) { + for (Holddown &h : holddowns) + if (h.key == key) { + h.until_ms = until; + return; + } + holddowns.push_back({key, until}); + } +}; + +struct GateOutcome { + GateVerdict verdict = GateVerdict::Hold; + GateReason reason = GateReason::Advisory; + ChannelDef target; + uint64_t evidence_gen = 0; +}; + +/* Map a ChannelScore hold reason to the gate's fault taxonomy, so a hold's + * cause is preserved in the migrate.gate event. */ +inline GateReason gate_reason_for_hold(Reason r) { + switch (r) { + case Reason::HoldActiveHealthy: + case Reason::HoldImpairmentNotPersistent: + return GateReason::NotImpaired; + case Reason::HoldImpairmentNotChannel: + return GateReason::FaultWeakLink; /* weak OR saturated — both non-channel */ + case Reason::HoldBroadDegradation: + return GateReason::FaultBroadband; + case Reason::HoldScoutUnhealthy: + return GateReason::FaultScout; + case Reason::HoldPrimaryTelemetryStale: + return GateReason::TelemetryUnhealthy; + case Reason::HoldImprovementMargin: + return GateReason::MarginLow; + case Reason::HoldCooldown: + return GateReason::Cooldown; + case Reason::HoldNoQualifiedCandidate: + return GateReason::EvidenceIncomplete; + default: + return GateReason::NotImpaired; + } +} + +/* The decision. Pure; `now_ms` is caller-supplied. Mutates only GateState + * (cooldown/holddown/frozen latches); it does NOT record a move — the caller + * calls on_confirmed()/on_rolledback() when the protocol resolves. */ +GateOutcome mig_gate_decide(const GateInputs &in, GateState &st, + const GatePolicy &pol, int64_t now_ms); + +/* Protocol-resolution callbacks the controller invokes so the anti-oscillation + * state stays honest. */ +void mig_gate_on_confirmed(GateState &st, const ChannelDef &now_on, + int64_t now_ms); +void mig_gate_on_rolledback(GateState &st, const ChannelDef &target, + const GatePolicy &pol, int64_t now_ms); + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_MIG_GATE_H */ diff --git a/src/chanmig/MigProposer.h b/src/chanmig/MigProposer.h new file mode 100644 index 0000000..8216b64 --- /dev/null +++ b/src/chanmig/MigProposer.h @@ -0,0 +1,435 @@ +/* MigProposer — the GROUND state machine (pure). + * + * Ground proposes a target and, on an authenticated matching COMMIT, becomes a + * follower of the drone's activation schedule: it acks the commit (its nonce + * echo IS the ack the drone waits for), retunes its primary RX at the relative + * activation instant, verifies generation-tagged markers + video on the new + * channel, and confirms — or falls into a bounded recovery scan of + * old/new/rescue. It reads no clock and does no crypto: inputs are decoded + * MigMsgs + local observations + monotonic `now`; outputs are MigActions the + * demo executes (stamping tx_tsf and encoding on the way out). + * + * Convergence invariants (proven per row in the failure-matrix selftest): + * I1 never retune before adopting a nonce+generation-matched commit + * I2 every post-activation path has a bounded route back to a channel in + * the recovery scan set + * I5 a replayed/forged exchange can never be adopted (nonce+gen gate) */ +#ifndef DEVOURER_CHANMIG_MIG_PROPOSER_H +#define DEVOURER_CHANMIG_MIG_PROPOSER_H + +#include + +#include "chanmig/MigConfig.h" +#include "chanmig/MigTypes.h" + +namespace devourer { +namespace chanmig { + +/* Deterministic nonce: unpredictable across sessions (the epoch is random, + * seeded by the demo from std::random_device), reproducible within a trace. */ +inline uint32_t mig_mix_nonce(uint32_t epoch, uint32_t gen, uint32_t salt) { + uint32_t h = epoch * 2654435761u + gen * 40503u + salt * 2246822519u; + h ^= h >> 15; + h *= 2246822519u; + h ^= h >> 13; + return h ? h : 1u; +} + +class MigProposer { +public: + MigProposer(MigParams params, uint32_t link_id, uint32_t ground_epoch, + ChannelDef source) + : p_(params), link_id_(link_id), epoch_(ground_epoch), source_(source), + current_(source) {} + + MigState state() const { return st_; } + ChannelDef current_channel() const { return current_; } + uint32_t generation() const { return gen_; } + uint32_t wrong_channel_frames() const { return wrong_channel_; } + + /* Operator/gate trigger. No-op unless Stable. */ + std::vector start(const ChannelDef &target, uint32_t evidence_gen, + const ChannelDef &rescue, int64_t now) { + std::vector a; + if (st_ != MigState::Stable) + return a; + ++gen_; + nonce_ = mig_mix_nonce(epoch_, gen_, 0xA1); + target_ = target; + rescue_ = rescue; + evidence_gen_ = evidence_gen; + st_ = MigState::Proposed; + tries_ = 1; + next_send_ms_ = now + p_.proposal_interval_ms; + a.push_back(send(MT_PROPOSAL, make_proposal(), true)); + a.push_back(ev()); + return a; + } + + std::vector on_message(const MigMsg &m, int64_t now) { + switch (m.type) { + case MT_COMMIT: return on_commit(m, now); + case MT_STATUS: return on_status(m, now); + case MT_MARKER: return on_marker(m, now); + case MT_ABORT: return on_abort(m, now); + default: return {}; + } + } + + std::vector on_tick(int64_t now) { + switch (st_) { + case MigState::Proposed: return tick_proposed(now); + case MigState::Committed: return tick_committed(now); + case MigState::Verifying: return tick_verifying(now); + case MigState::Recovery: return tick_recovery(now); + default: return {}; + } + } + + /* The demo's RX retune finished. */ + std::vector on_retune_done(int64_t now) { + if (st_ != MigState::Switching) + return {}; + if (follow_pending_) { + /* we were retuning to follow the drone's reported settled channel */ + follow_pending_ = false; + st_ = MigState::Stable; + current_ = follow_channel_; + const uint8_t code = follow_channel_.same_rf(commit_target_) ? 0 : 1; + return {ev(), gate(code), done(code)}; + } + st_ = MigState::Verifying; + verify_deadline_ms_ = activate_local_ms_ + p_.verify_ms; + markers_seen_ = 0; + video_seen_ = 0; + got_gen_marker_ = false; + verified_ = false; + current_ = commit_target_; + return {ev()}; + } + + /* A canonical-SA video frame decoded on the current channel. */ + std::vector on_video(int64_t now) { + if (st_ == MigState::Verifying) { + if (now < blank_until_ms_ || !got_gen_marker_) + return {}; /* blank window / pre-marker: stale, ignore */ + ++video_seen_; + return maybe_confirm(now); + } + if (st_ == MigState::Recovery) + return recover_to(recov_channel(), now); + return {}; + } + +private: + MigMsg make_proposal() const { + MigMsg m; + m.type = MT_PROPOSAL; + m.link_id = link_id_; + m.ground_epoch = epoch_; + m.generation = gen_; + m.source = source_; + m.target = target_; + m.evidence_gen = evidence_gen_; + m.fallback_mode = 0; + m.rescue = rescue_; + m.ground_nonce = nonce_; + return m; + } + MigMsg make_status_ack() const { + MigMsg m; + m.type = MT_STATUS; + m.link_id = link_id_; + m.role = 0; + m.sender_epoch = epoch_; + m.generation = gen_; + m.state = static_cast(st_); + m.current = current_; + m.peer_nonce_echo = drone_nonce_; /* THE commit-ack */ + return m; + } + MigMsg make_confirm() const { + MigMsg m; + m.type = MT_CONFIRM; + m.link_id = link_id_; + m.ground_epoch = epoch_; + m.generation = gen_; + m.drone_nonce_echo = drone_nonce_; + m.marker_count = static_cast(markers_seen_); + m.video_frames = static_cast(video_seen_); + return m; + } + MigAction send(MigMsgType t, const MigMsg &m, bool unicast) const { + MigAction a; + a.kind = unicast ? MigAction::SendUnicast : MigAction::SendBroadcast; + a.msg = m; + a.msg.type = t; + return a; + } + MigAction retune(const ChannelDef &c) const { + MigAction a; + a.kind = MigAction::RetuneTo; + a.channel = c; + return a; + } + MigAction ev() const { + MigAction a; + a.kind = MigAction::EmitEvent; + a.code = static_cast(st_); + return a; + } + MigAction gate(uint8_t code) const { + MigAction a; + a.kind = MigAction::GateNotify; + a.code = code; + return a; + } + MigAction done(uint8_t code) const { + MigAction a; + a.kind = MigAction::Done; + a.code = code; + return a; + } + + std::vector on_commit(const MigMsg &m, int64_t now) { + /* nonce + generation gate — the replay/forgery wall (I5). */ + if (m.generation != gen_ || m.ground_nonce_echo != nonce_) + return {}; + if (st_ == MigState::Proposed) { + drone_epoch_ = m.drone_epoch; + drone_nonce_ = m.drone_nonce; + commit_target_ = m.target; + commit_rescue_ = m.rescue; + activate_tsf_ = m.activate_tsf; + commit_tx_tsf_ = m.tx_tsf; + const int64_t offset_ms = + static_cast((activate_tsf_ - commit_tx_tsf_) / 1000); + activate_local_ms_ = now + offset_ms; + retune_at_ms_ = activate_local_ms_ - p_.lead_ms; + if (retune_at_ms_ < now) + retune_at_ms_ = now; + st_ = MigState::Committed; + next_status_ms_ = now; + /* The ground follows the drone's authoritative settled STATUS; this + * deadline (past the drone's own rollback deadline) is the backstop for + * a total control-path loss, after which the ground recovers by scan. */ + follow_deadline_ms_ = activate_local_ms_ + p_.rollback_ms + 2000; + std::vector a; + a.push_back(ev()); + auto t = tick_committed(now); /* sends the first ack immediately */ + a.insert(a.end(), t.begin(), t.end()); + return a; + } + if (st_ == MigState::Committed) /* drone retransmit: re-ack */ + return {send(MT_STATUS, make_status_ack(), false)}; + return {}; + } + + std::vector on_status(const MigMsg &m, int64_t now) { + if (st_ == MigState::Proposed && m.reason != 0) { + /* the drone rejected our proposal (source mismatch / illegal / busy) */ + st_ = MigState::Stable; + current_ = source_; + MigAction e; + e.kind = MigAction::EmitEvent; + e.code = m.reason; + return {e, done(1)}; + } + /* Follow the drone's authoritative settled channel. The drone is the + * schedule authority: once it reports Stable somewhere, the ground goes + * there (this is what closes every dropped-confirm / rollback split-brain + * — the ground never unilaterally commits to a channel the drone left). */ + if (m.role == 1 && m.generation == gen_ && + (st_ == MigState::Committed || st_ == MigState::Switching || + st_ == MigState::Verifying) && + m.state == static_cast(MigState::Stable)) + return follow_drone(m.current, now); + return {}; + } + + std::vector follow_drone(const ChannelDef &chan, int64_t now) { + (void)now; + if (current_.same_rf(chan)) { + st_ = MigState::Stable; + current_ = chan; + const uint8_t code = chan.same_rf(commit_target_) ? 0 : 1; + std::vector a; + if (chan.same_rf(commit_target_)) + a.push_back(send(MT_CONFIRM, make_confirm(), true)); /* ack the drone */ + a.push_back(ev()); + a.push_back(gate(code)); + a.push_back(done(code)); + return a; + } + /* retune to where the drone settled, then go Stable there */ + follow_channel_ = chan; + follow_pending_ = true; + st_ = MigState::Switching; + return {retune(chan), ev()}; + } + + std::vector on_marker(const MigMsg &m, int64_t now) { + if (m.generation != gen_ || m.drone_epoch != drone_epoch_) + return {}; /* foreign / stale generation */ + if (st_ == MigState::Verifying) { + if (!m.aired_on.same_rf(commit_target_)) { + ++wrong_channel_; /* delayed-USB / wrong-channel frame */ + return {}; + } + if (now < blank_until_ms_) + return {}; + got_gen_marker_ = true; + ++markers_seen_; + return maybe_confirm(now); + } + if (st_ == MigState::Recovery) + return recover_to(m.aired_on, now); + return {}; + } + + std::vector on_abort(const MigMsg &m, int64_t now) { + if (m.effective == 0) { /* pre-activation cancel: nothing moved */ + st_ = MigState::Stable; + current_ = source_; + return {ev(), done(1)}; + } + /* drone rolled back — scan to reunite */ + enter_recovery(now); + return {ev(), retune(recov_channel())}; + } + + std::vector tick_proposed(int64_t now) { + if (now < next_send_ms_) + return {}; + if (tries_ >= p_.proposal_max_tries) { + st_ = MigState::Stable; + current_ = source_; + return {ev(), done(1)}; /* gave up; stayed on old */ + } + ++tries_; + next_send_ms_ = now + p_.proposal_interval_ms; + return {send(MT_PROPOSAL, make_proposal(), true)}; + } + + std::vector tick_committed(int64_t now) { + std::vector a; + if (now >= next_status_ms_) { + a.push_back(send(MT_STATUS, make_status_ack(), false)); + next_status_ms_ = now + p_.commit_interval_ms; + } + if (now >= retune_at_ms_) { + st_ = MigState::Switching; + blank_until_ms_ = now + p_.blank_window_ms; + a.push_back(retune(commit_target_)); + a.push_back(ev()); + } + return a; + } + + std::vector tick_verifying(int64_t now) { + std::vector a; + /* Once the markers prove the channel, tell the drone we followed (CONFIRM, + * repeated) — but do NOT declare success: the ground reaches Stable only + * when the drone's own STATUS says it settled on the target (follow_drone), + * so a dropped CONFIRM + drone rollback can never strand the ground here. */ + if (verified_ && now >= next_confirm_ms_) { + a.push_back(send(MT_CONFIRM, make_confirm(), true)); + next_confirm_ms_ = now + p_.commit_interval_ms; + } + /* Control-path-loss backstop: no authoritative drone STATUS by the follow + * deadline -> scan to reunite. */ + if (now >= follow_deadline_ms_) { + enter_recovery(now); + a.push_back(ev()); + a.push_back(retune(recov_channel())); + } + return a; + } + + std::vector tick_recovery(int64_t now) { + if (now < recov_next_ms_) + return {}; + recov_idx_ = (recov_idx_ + 1) % recov_len_; + recov_next_ms_ = now + p_.recovery_dwell_ms; + std::vector a{retune(recov_channel())}; + if (now - recov_start_ms_ > p_.recovery_giveup_ms) { + MigAction e; + e.kind = MigAction::EmitEvent; + e.code = 255; /* recovery exhausted */ + a.push_back(e); + } + return a; + } + + std::vector maybe_confirm(int64_t now) { + /* Markers proved the channel — start confirming (the drone needs to learn + * the ground followed), but the ground stays Verifying until the drone's + * STATUS confirms it settled (follow_drone). */ + if (!verified_ && + (markers_seen_ >= p_.verify_markers || video_seen_ >= p_.verify_video)) { + verified_ = true; + next_confirm_ms_ = now + p_.commit_interval_ms; + return {send(MT_CONFIRM, make_confirm(), true)}; + } + return {}; + } + + void enter_recovery(int64_t now) { + recov_pattern_[0] = source_; + recov_pattern_[1] = commit_target_; + recov_pattern_[2] = source_; + recov_pattern_[3] = commit_rescue_; + recov_len_ = 4; + recov_idx_ = 0; + recov_start_ms_ = now; + recov_next_ms_ = now + p_.recovery_dwell_ms; + st_ = MigState::Recovery; + current_ = recov_channel(); + } + ChannelDef recov_channel() const { return recov_pattern_[recov_idx_]; } + + std::vector recover_to(const ChannelDef &c, int64_t now) { + (void)now; + st_ = MigState::Stable; + current_ = c; + const uint8_t code = c.same_rf(commit_target_) ? 0 : 1; + return {ev(), gate(code), done(code)}; + } + + MigParams p_; + uint32_t link_id_, epoch_; + ChannelDef source_, current_; + MigState st_ = MigState::Stable; + + /* current exchange */ + uint32_t gen_ = 0, nonce_ = 0, evidence_gen_ = 0; + ChannelDef target_, rescue_; + int tries_ = 0; + int64_t next_send_ms_ = 0; + + /* committed */ + uint32_t drone_epoch_ = 0, drone_nonce_ = 0; + ChannelDef commit_target_, commit_rescue_; + uint64_t activate_tsf_ = 0, commit_tx_tsf_ = 0; + int64_t activate_local_ms_ = 0, retune_at_ms_ = 0, next_status_ms_ = 0; + + /* verify */ + int64_t verify_deadline_ms_ = 0, blank_until_ms_ = 0, next_confirm_ms_ = 0; + int64_t follow_deadline_ms_ = 0; + int markers_seen_ = 0, video_seen_ = 0; + bool got_gen_marker_ = false, verified_ = false; + uint32_t wrong_channel_ = 0; + /* following the drone's authoritative settled channel */ + ChannelDef follow_channel_; + bool follow_pending_ = false; + + /* recovery */ + ChannelDef recov_pattern_[4]; + int recov_len_ = 0, recov_idx_ = 0; + int64_t recov_next_ms_ = 0, recov_start_ms_ = 0; +}; + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_MIG_PROPOSER_H */ diff --git a/src/chanmig/MigResponder.h b/src/chanmig/MigResponder.h new file mode 100644 index 0000000..8024ebb --- /dev/null +++ b/src/chanmig/MigResponder.h @@ -0,0 +1,549 @@ +/* MigResponder — the DRONE (video TX) state machine (pure). + * + * The drone is the final schedule authority: it validates a ground proposal, + * fixes an absolute activation instant on its own TSF, and — crucially — + * arms that activation ONLY after it has seen the ground echo its nonce (the + * commit-ack). That single rule closes every replay-initiated and + * control-loss split-brain row: a commit the current ground never heard is + * never activated, so the drone can never strand itself on a channel the + * ground isn't following. + * + * At activation it drains/deterministically-drops pending TX, retunes through + * the safe gate, re-arms its ACK responder, and airs generation-tagged low-MCS + * markers; if the ground never confirms by the rollback deadline it returns to + * the fallback channel. Pure: inputs are decoded MigMsgs + local observations + * + monotonic `now` + the drone's own TSF (needed once, to fix the absolute + * activation instant); outputs are MigActions. + * + * The #280 target-validation hook lives in validate_target(); stage 3 ships + * the variant-A instant checks (legality / caps / allowed-list), stage 5 adds + * the pre-commit probe. */ +#ifndef DEVOURER_CHANMIG_MIG_RESPONDER_H +#define DEVOURER_CHANMIG_MIG_RESPONDER_H + +#include + +#include "chanmig/MigConfig.h" +#include "chanmig/MigProposer.h" /* mig_mix_nonce */ +#include "chanmig/MigTypes.h" +#include "chanmig/MigWire.h" /* ReplayWindow */ + +namespace devourer { +namespace chanmig { + +/* Static capabilities + policy the drone validates a target against. */ +struct MigCaps { + uint8_t bands = 0x02 | 0x04; /* bit1 = 2.4, bit2 = 5 (matches ChannelDef.band as a set) */ + uint8_t widths = 0xFF; /* 1< allowed; /* empty = any legal channel */ + bool band_ok(uint8_t band) const { + if (band == 2) + return bands & 0x02; + if (band == 5) + return bands & 0x04; + return false; + } + bool width_ok(ChannelWidth_t w) const { return widths & (1u << w); } + bool allowed_ok(const ChannelDef &d) const { + if (allowed.empty()) + return true; + for (const ChannelDef &c : allowed) + if (c.same_rf(d)) + return true; + return false; + } + /* #280 variant B: opt-in single-radio pre-commit probe (research). Off by + * default — variant A (these checks) is the product baseline. When on, the + * drone briefly retunes to the target before committing and vetoes an + * extreme-occupancy destination. */ + bool probe = false; + double veto_busy_frac = 0.6; +}; + +class MigResponder { +public: + MigResponder(MigParams params, uint32_t link_id, uint32_t drone_epoch, + ChannelDef current, MigCaps caps) + : p_(params), link_id_(link_id), epoch_(drone_epoch), current_(current), + caps_(caps) {} + + MigState state() const { return st_; } + ChannelDef current_channel() const { return current_; } + uint32_t generation() const { return gen_; } + + std::vector on_message(const MigMsg &m, int64_t now, + uint64_t now_tsf) { + switch (m.type) { + case MT_PROPOSAL: return on_proposal(m, now, now_tsf); + case MT_STATUS: return on_status(m, now); + case MT_CONFIRM: return on_confirm(m, now); + case MT_ABORT: return {}; /* ground doesn't abort us */ + default: return {}; + } + } + + std::vector on_tick(int64_t now, uint64_t now_tsf) { + std::vector a; + /* Periodic STATUS is the ground's authoritative signal for where the + * drone actually settled — the ground FOLLOWS it, so it must keep flowing + * for a grace window after the drone reaches a terminal state. */ + if (now >= next_status_ms_ && + (st_ != MigState::Stable || now < settle_status_until_ms_) && + gen_ != 0) { + a.push_back(send(make_status(), false)); + next_status_ms_ = now + p_.status_interval_ms; + } + switch (st_) { + case MigState::Committed: { + auto t = tick_committed(now, now_tsf); + a.insert(a.end(), t.begin(), t.end()); + break; + } + case MigState::Switching: { + /* Drain-stall backstop: if the drain callback never lands by its + * deadline, drop the pending TX deterministically and retune anyway — + * the migration must not hang on a stuck USB completion. */ + if (phase_ == Phase::Draining && now >= drain_deadline_ms_) { + phase_ = Phase::Retuning; + a.push_back(retune(commit_target_)); + } + break; + } + case MigState::Verifying: { + auto t = tick_verifying(now, now_tsf); + a.insert(a.end(), t.begin(), t.end()); + break; + } + default: break; + } + return a; + } + + std::vector on_drain_done(int64_t now) { + (void)now; + if (st_ == MigState::Switching && phase_ == Phase::Draining) { + phase_ = Phase::Retuning; + return {retune(commit_target_)}; + } + if (st_ == MigState::Validating && pp_ == ProbePhase::Drain) { + pp_ = ProbePhase::Retune; + return {retune(commit_target_)}; /* probe: hop to the target to sample */ + } + return {}; + } + + /* #280 variant B: the demo samples GetRxEnergy after the probe retune settles + * and reports the destination's occupancy here. valid=false ⇒ unknown. */ + std::vector on_probe_sample(double busy_frac, bool valid, + int64_t now) { + (void)now; + if (st_ != MigState::Validating || pp_ != ProbePhase::Sampling) + return {}; + probe_busy_ = busy_frac; + probe_valid_ = valid; + probe_veto_ = valid && busy_frac > caps_.veto_busy_frac; + pp_ = ProbePhase::Return; + return {retune(commit_source_)}; /* always return to source before deciding */ + } + + std::vector on_retune_done(bool ok, int64_t now, uint64_t now_tsf) { + (void)now; + (void)now_tsf; + if (st_ == MigState::Switching && phase_ == Phase::Retuning) { + if (ok) { + current_ = commit_target_; + st_ = MigState::Verifying; + MigAction arm; + arm.kind = MigAction::ArmMarkers; + arm.channel = commit_target_; + MigAction rp; + rp.kind = MigAction::ResumePump; + return {arm, rp, ev()}; + } + /* retune failed: fall back to the source, then rescue */ + phase_ = Phase::RollbackRetune; + current_ = fallback_channel(); /* provisional */ + return {retune(fallback_channel())}; + } + if (st_ == MigState::Switching && phase_ == Phase::RollbackRetune) { + if (ok) { + current_ = fallback_channel(); + return finish_rollback(now); + } + /* even the fallback retune failed: rescue + abort */ + current_ = commit_rescue_; + st_ = MigState::Stable; + hold_down_until_ms_ = now + p_.proposal_hold_down_ms; + return {retune(commit_rescue_), abort_msg(MigReason::RetuneFail, 1), + gate(1), done(2)}; + } + if (st_ == MigState::Rollback) { + current_ = fallback_channel(); + return finish_rollback(now); + } + /* --- probe (variant B) retune callbacks --- */ + if (st_ == MigState::Validating && pp_ == ProbePhase::Retune) { + if (ok) { + current_ = commit_target_; /* on the target, awaiting the sample */ + pp_ = ProbePhase::Sampling; + MigAction e; /* tell the demo to sample GetRxEnergy now */ + e.kind = MigAction::EmitEvent; + e.code = kProbeSampleNow; + return {e}; + } + /* couldn't even reach the target: return to source, veto */ + probe_veto_ = true; + probe_valid_ = false; + pp_ = ProbePhase::Return; + return {retune(commit_source_)}; + } + if (st_ == MigState::Validating && pp_ == ProbePhase::Return) { + current_ = ok ? commit_source_ : commit_rescue_; + pp_ = ProbePhase::None; + MigAction resume; + resume.kind = MigAction::ResumePump; + if (!ok) { + /* return retune failed — land on rescue, reject the migration */ + st_ = MigState::Stable; + rw_.clear_in_flight(); + return {resume, + validation_msg(commit_target_, 1, 1, MigReason::RetuneFail), + status_reject(ground_epoch_, gen_, MigReason::RetuneFail), + ev()}; + } + if (probe_veto_) { + st_ = MigState::Stable; + rw_.clear_in_flight(); + return {resume, + validation_msg(commit_target_, 1, 1, MigReason::Veto), + status_reject(ground_epoch_, gen_, MigReason::Veto), ev()}; + } + /* accepted — report it and commit for real */ + auto acts = enter_committed(now, now_tsf); + acts.insert(acts.begin(), resume); + acts.insert(acts.begin() + 1, + validation_msg(commit_target_, 1, 0, MigReason::None)); + return acts; + } + return {}; + } + +private: + enum class Phase { None, Draining, Retuning, RollbackRetune }; + enum class ProbePhase { None, Drain, Retune, Sampling, Return }; + static constexpr uint8_t kProbeSampleNow = 200; + + std::vector enter_probe(int64_t now) { + (void)now; + st_ = MigState::Validating; + pp_ = ProbePhase::Drain; + probe_veto_ = false; + probe_valid_ = false; + MigAction d; + d.kind = MigAction::StartDrain; + return {d, ev()}; + } + + MigAction validation_msg(const ChannelDef &target, uint8_t method, + uint8_t result, MigReason reason) const { + MigMsg m = base(MT_VALIDATION); + m.drone_epoch = epoch_; + m.generation = gen_; + m.target = target; + m.method = method; + m.result = result; + m.reason = static_cast(reason); + m.nhm_busy_pct = static_cast(probe_busy_ * 100.0); + m.energy_valid = probe_valid_ ? 1 : 0; + return send(m, false); + } + + MigMsg base(MigMsgType t) const { + MigMsg m; + m.type = t; + m.link_id = link_id_; + return m; + } + MigAction send(const MigMsg &m, bool unicast) const { + MigAction a; + a.kind = unicast ? MigAction::SendUnicast : MigAction::SendBroadcast; + a.msg = m; + return a; + } + MigAction retune(const ChannelDef &c) const { + MigAction a; + a.kind = MigAction::RetuneTo; + a.channel = c; + return a; + } + MigAction ev() const { + MigAction a; + a.kind = MigAction::EmitEvent; + a.code = static_cast(st_); + return a; + } + MigAction gate(uint8_t code) const { + MigAction a; + a.kind = MigAction::GateNotify; + a.code = code; + return a; + } + MigAction done(uint8_t code) const { + MigAction a; + a.kind = MigAction::Done; + a.code = code; + return a; + } + MigAction abort_msg(MigReason r, uint8_t effective) const { + MigMsg m = base(MT_ABORT); + m.role = 1; + m.sender_epoch = epoch_; + m.generation = gen_; + m.reason = static_cast(r); + m.effective = effective; + return send(m, false); + } + MigAction status_reject(uint32_t ground_epoch, uint32_t gen, + MigReason r) const { + MigMsg m = base(MT_STATUS); + m.role = 1; + m.sender_epoch = epoch_; + m.generation = gen; + m.state = static_cast(MigState::Stable); + m.current = current_; + m.reason = static_cast(r); + (void)ground_epoch; + return send(m, false); + } + MigMsg make_status() const { + MigMsg m = base(MT_STATUS); + m.role = 1; + m.sender_epoch = epoch_; + m.generation = gen_; + m.state = static_cast(st_); + m.current = current_; + return m; + } + ChannelDef fallback_channel() const { + return fallback_mode_ == 1 ? commit_rescue_ : commit_source_; + } + + /* #280 variant-A validation: legality + caps + allowed list. */ + MigReason validate_target(const ChannelDef &t) const { + if (validate(t) != DefError::Ok) + return MigReason::IllegalTarget; + if (!caps_.band_ok(t.band)) + return MigReason::UnsupportedWidth; + if (!caps_.width_ok(t.width)) + return MigReason::UnsupportedWidth; + if (t.no_ir) + return MigReason::IllegalTarget; + if (!caps_.allowed_ok(t)) + return MigReason::IllegalTarget; + return MigReason::None; + } + + std::vector on_proposal(const MigMsg &m, int64_t now, + uint64_t now_tsf) { + /* epoch handling: a proposal may introduce/adopt a ground epoch. A ground + * restart (fresh epoch) orphans our in-flight migration (I4). Crucially, + * if we have already left the source channel, we must ROLL BACK to it — + * the restarted ground believes we are on source, so sitting on the target + * would strand us (a split-brain). Reject the triggering proposal as Busy + * while the rollback runs; the ground reunites with us on source. */ + const bool epoch_change = + rw_.have_peer && m.ground_epoch != rw_.peer_epoch; + if (epoch_change && st_ != MigState::Stable) { + const bool moved = gen_ != 0 && !current_.same_rf(commit_source_); + phase_ = Phase::None; + rw_.accept_epoch(m.ground_epoch, /*may_introduce=*/true); + if (moved) { + st_ = MigState::Rollback; + rw_.clear_in_flight(); + return {retune(commit_source_), + status_reject(m.ground_epoch, m.generation, MigReason::Busy), + ev()}; + } + st_ = MigState::Stable; /* still on source: just orphan and re-evaluate */ + } + if (!rw_.accept_epoch(m.ground_epoch, /*may_introduce=*/true)) + return {status_reject(m.ground_epoch, m.generation, MigReason::StaleEpoch)}; + + const int cls = rw_.classify_proposal(m.generation); + if (cls == 0) /* replay */ + return {}; + if (cls == 1) { /* idempotent: re-send the cached commit */ + if (st_ == MigState::Committed) + return {send(cached_commit_, true)}; + return {}; + } + /* fresh proposal */ + if (st_ != MigState::Stable || now < hold_down_until_ms_) { + rw_.clear_in_flight(); + return {status_reject(m.ground_epoch, m.generation, MigReason::Busy)}; + } + /* source must match where we actually are */ + if (!m.source.same_rf(current_)) { + rw_.clear_in_flight(); + return {status_reject(m.ground_epoch, m.generation, + MigReason::SourceMismatch)}; + } + const MigReason vr = validate_target(m.target); /* variant A checks */ + if (vr != MigReason::None) { + rw_.clear_in_flight(); + return {status_reject(m.ground_epoch, m.generation, vr), + validation_msg(m.target, 0 /*A*/, 1 /*veto*/, vr)}; + } + + /* stash the accepted proposal (used by enter_committed, and by the probe + * before it commits). */ + gen_ = m.generation; + ground_epoch_ = m.ground_epoch; + ground_nonce_ = m.ground_nonce; + commit_target_ = m.target; + commit_source_ = m.source; + commit_rescue_ = m.rescue; + fallback_mode_ = m.fallback_mode; + + /* Variant B: run the single-radio pre-commit probe before committing. + * Off by default (variant A is the product baseline). */ + if (caps_.probe) + return enter_probe(now); + + return enter_committed(now, now_tsf); + } + + /* Build the commit + arm the Committed timers from the stashed proposal. */ + std::vector enter_committed(int64_t now, uint64_t now_tsf) { + drone_nonce_ = mig_mix_nonce(epoch_, gen_, 0xD2); + activate_tsf_ = now_tsf + p_.countdown_ms * 1000; + rollback_deadline_tsf_ = activate_tsf_ + p_.rollback_ms * 1000; + armed_ = false; + ack_deadline_ms_ = now + p_.ack_timeout_ms; + next_commit_ms_ = now + p_.commit_interval_ms; + next_status_ms_ = now + p_.status_interval_ms; + cached_commit_ = make_commit(); + st_ = MigState::Committed; + return {send(cached_commit_, true), ev()}; + } + + MigMsg make_commit() const { + MigMsg m = base(MT_COMMIT); + m.drone_epoch = epoch_; + m.ground_epoch = ground_epoch_; + m.generation = gen_; + m.target = commit_target_; + m.activate_tsf = activate_tsf_; + m.confirm_window_us = static_cast(p_.verify_ms * 1000); + m.rollback_deadline_tsf = rollback_deadline_tsf_; + m.rescue = commit_rescue_; + m.ground_nonce_echo = ground_nonce_; + m.drone_nonce = drone_nonce_; + m.armed = 0; + return m; + } + + std::vector on_status(const MigMsg &m, int64_t now) { + (void)now; + /* the ground ack: it echoes OUR nonce. */ + if (st_ == MigState::Committed && m.peer_nonce_echo == drone_nonce_ && + m.generation == gen_) + armed_ = true; + return {}; + } + + std::vector on_confirm(const MigMsg &m, int64_t now) { + (void)now; + if (st_ == MigState::Verifying && m.generation == gen_ && + m.drone_nonce_echo == drone_nonce_) { + st_ = MigState::Stable; + current_ = commit_target_; + settle_status_until_ms_ = now + kSettleStatusMs; + next_status_ms_ = now; /* let the ground learn we settled on target */ + MigAction stop; + stop.kind = MigAction::StopMarkers; + return {stop, ev(), gate(0), done(0)}; + } + return {}; + } + + std::vector tick_committed(int64_t now, uint64_t now_tsf) { + std::vector a; + if (!armed_ && now >= ack_deadline_ms_) { + /* never heard the ground ack: abort, never activate unheard */ + st_ = MigState::Stable; + settle_status_until_ms_ = now + kSettleStatusMs; + next_status_ms_ = now; + rw_.clear_in_flight(); + a.push_back(abort_msg(MigReason::NoAck, 0)); + a.push_back(ev()); + return a; + } + if (now >= next_commit_ms_) { + a.push_back(send(cached_commit_, true)); /* tx_tsf re-stamped by demo */ + next_commit_ms_ = now + p_.commit_interval_ms; + } + const uint64_t drain_budget_tsf = + static_cast(p_.drain_deadline_ms) * 1000; + if (armed_ && now_tsf + drain_budget_tsf >= activate_tsf_) { + st_ = MigState::Switching; + phase_ = Phase::Draining; + drain_deadline_ms_ = now + p_.drain_deadline_ms; + MigAction d; + d.kind = MigAction::StartDrain; + a.push_back(d); + a.push_back(ev()); + } + return a; + } + + std::vector tick_verifying(int64_t now, uint64_t now_tsf) { + if (now_tsf >= rollback_deadline_tsf_) { + st_ = MigState::Rollback; + (void)now; + MigAction stop; + stop.kind = MigAction::StopMarkers; + return {stop, retune(fallback_channel()), ev()}; + } + return {}; + } + + std::vector finish_rollback(int64_t now) { + st_ = MigState::Stable; + hold_down_until_ms_ = now + p_.proposal_hold_down_ms; + settle_status_until_ms_ = now + kSettleStatusMs; + next_status_ms_ = now; /* tell the ground we returned to the fallback */ + rw_.clear_in_flight(); + MigAction stop; + stop.kind = MigAction::StopMarkers; + return {stop, ev(), gate(1), done(1)}; + } + + static constexpr int64_t kSettleStatusMs = 8000; + + MigParams p_; + uint32_t link_id_, epoch_; + ChannelDef current_; + MigCaps caps_; + MigState st_ = MigState::Stable; + Phase phase_ = Phase::None; + ReplayWindow rw_; + + uint32_t gen_ = 0, ground_epoch_ = 0, ground_nonce_ = 0, drone_nonce_ = 0; + ChannelDef commit_target_, commit_source_, commit_rescue_; + uint8_t fallback_mode_ = 0; + uint64_t activate_tsf_ = 0, rollback_deadline_tsf_ = 0; + bool armed_ = false; + int64_t ack_deadline_ms_ = 0, next_commit_ms_ = 0, hold_down_until_ms_ = 0; + int64_t next_status_ms_ = 0, settle_status_until_ms_ = 0, drain_deadline_ms_ = 0; + MigMsg cached_commit_; + /* variant-B probe */ + ProbePhase pp_ = ProbePhase::None; + double probe_busy_ = 0.0; + bool probe_valid_ = false, probe_veto_ = false; +}; + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_MIG_RESPONDER_H */ diff --git a/src/chanmig/MigTypes.h b/src/chanmig/MigTypes.h new file mode 100644 index 0000000..29046e9 --- /dev/null +++ b/src/chanmig/MigTypes.h @@ -0,0 +1,185 @@ +/* Shared types for the coordinated channel-migration protocol (ground proposes, + * drone commits). The state/reason/action enums both pure machines + * (MigProposer, MigResponder) speak, kept in one header so the wire codec, the + * machines, the demo, and the selftests agree on every code. + * + * Authority model: ground proposes a target, the drone (the video TX) validates + * and becomes the final schedule authority by committing an activation instant + * on its own clock. Neither endpoint retunes on a PROPOSAL; both act only on an + * authenticated, matching, acknowledged COMMIT. */ +#ifndef DEVOURER_CHANMIG_MIG_TYPES_H +#define DEVOURER_CHANMIG_MIG_TYPES_H + +#include +#include + +#include "chanmig/ChannelDef.h" + +namespace devourer { +namespace chanmig { + +enum MigMsgType : uint8_t { + MT_PROPOSAL = 1, + MT_COMMIT = 2, + MT_STATUS = 3, + MT_CONFIRM = 4, + MT_ABORT = 5, + MT_MARKER = 6, + MT_VALIDATION = 7, +}; + +/* Canonical migration state names (issue #278). Both roles use this enum; + * a role only visits the states meaningful to it. */ +enum class MigState : uint8_t { + Stable, + Proposed, /* ground: proposal sent, awaiting commit */ + Validating, /* drone: proposal received, running target validation (#280) */ + Committed, /* commit exchanged; activation scheduled */ + Switching, /* draining + retuning at the activation instant */ + Verifying, /* on the new channel, checking generation-tagged evidence */ + Confirmed, /* the move succeeded */ + Rollback, /* returning to the fallback channel */ + Recovery, /* ground: lost the peer, scanning old/new/rescue */ +}; + +inline const char *mig_state_name(MigState s) { + switch (s) { + case MigState::Stable: return "STABLE"; + case MigState::Proposed: return "PROPOSED"; + case MigState::Validating: return "VALIDATING"; + case MigState::Committed: return "COMMITTED"; + case MigState::Switching: return "SWITCHING"; + case MigState::Verifying: return "VERIFYING"; + case MigState::Confirmed: return "CONFIRMED"; + case MigState::Rollback: return "ROLLBACK"; + case MigState::Recovery: return "RECOVERY"; + } + return "?"; +} + +/* Reject / status reason codes (wire byte + event string). */ +enum class MigReason : uint8_t { + None = 0, + BadMac, + BadVersion, + BadType, + BadLinkId, + Truncated, + ReplayGen, + StaleEpoch, + SourceMismatch, + UnknownChannel, + UnsupportedWidth, + IllegalTarget, + ActivationBounds, + Busy, + NoAck, + RetuneFail, + Veto, + NonceMismatch, +}; + +inline const char *mig_reason_name(MigReason r) { + switch (r) { + case MigReason::None: return "none"; + case MigReason::BadMac: return "bad_mac"; + case MigReason::BadVersion: return "bad_version"; + case MigReason::BadType: return "bad_type"; + case MigReason::BadLinkId: return "bad_link_id"; + case MigReason::Truncated: return "truncated"; + case MigReason::ReplayGen: return "replay_gen"; + case MigReason::StaleEpoch: return "stale_epoch"; + case MigReason::SourceMismatch: return "source_mismatch"; + case MigReason::UnknownChannel: return "unknown_channel"; + case MigReason::UnsupportedWidth: return "unsupported_width"; + case MigReason::IllegalTarget: return "illegal_target"; + case MigReason::ActivationBounds: return "activation_bounds"; + case MigReason::Busy: return "busy"; + case MigReason::NoAck: return "no_ack"; + case MigReason::RetuneFail: return "retune_fail"; + case MigReason::Veto: return "veto"; + case MigReason::NonceMismatch: return "nonce_mismatch"; + } + return "?"; +} + +/* The decoded control message — a flat superset; each type populates its own + * fields (kept flat, no variant, so the state machines read fields by name). + * The pure machines produce and consume these; the demo/LinkSim does the + * crypto (encode/decode/MAC via MigWire) and the TSF stamping. */ +struct MigMsg { + MigMsgType type = MT_PROPOSAL; + uint32_t link_id = 0; + + /* identity / anti-replay */ + uint32_t ground_epoch = 0, drone_epoch = 0, sender_epoch = 0; + uint32_t generation = 0; + uint32_t ground_nonce = 0, drone_nonce = 0; + uint32_t ground_nonce_echo = 0, drone_nonce_echo = 0, peer_nonce_echo = 0; + + /* channels */ + ChannelDef source, target, rescue, current, aired_on; + + /* proposal */ + uint32_t evidence_gen = 0; + uint64_t evidence_digest = 0; + uint64_t earliest_tsf = 0, latest_tsf = 0; + uint8_t fallback_mode = 0; /* 0 = source, 1 = rescue */ + + /* commit */ + uint64_t activate_tsf = 0, rollback_deadline_tsf = 0; + uint32_t confirm_window_us = 0; + uint8_t armed = 0; /* bit0: activation armed (ground ack seen) */ + + /* status */ + uint8_t role = 0; /* 0 = ground, 1 = drone */ + uint8_t state = 0; /* MigState */ + + /* confirm */ + uint16_t marker_count = 0, video_frames = 0; + uint32_t first_marker_tsfl = 0; + + /* abort / reason-carrying */ + uint8_t reason = 0; /* MigReason */ + uint8_t effective = 0; /* 0 = cancel pre-activation, 1 = rollback */ + + /* marker */ + uint32_t seq = 0; + uint8_t marker_flags = 0; /* bit0 rollback, bit1 probe-return */ + + /* validation (#280) */ + uint8_t method = 0; /* 0 checks / 1 probe / 2 probation / 3 scout */ + uint8_t result = 0; /* 0 accept / 1 veto / 2 unknown */ + uint32_t obs_age_ms = 0, obs_dur_ms = 0, cca_delta = 0, fa_delta = 0; + uint8_t igi = 0, nhm_busy_pct = 0, energy_valid = 0; + uint16_t cost_est_ms = 0; + + /* clock: the sender's TSF, stamped by the demo at send time */ + uint64_t tx_tsf = 0; +}; + +/* An action the pure machine asks its host (the demo) to perform. The machine + * itself does no I/O and no crypto — it returns a list of these from every + * input; the host stamps tx_tsf, encodes+MACs a `msg`, and transmits. */ +struct MigAction { + enum Kind : uint8_t { + SendUnicast, /* msg -> the peer's unicast address (+ tx.report) */ + SendBroadcast, /* msg -> broadcast (status / markers) */ + RetuneTo, /* channel */ + StartDrain, /* stop the TX pump, drain/deadline-drop pending frames */ + ResumePump, /* resume the TX pump (probe return / post-switch) */ + ArmMarkers, /* channel = the channel; emit gen-tagged markers there */ + StopMarkers, + EmitEvent, /* code = a MigReason or a state; for logging */ + GateNotify, /* code: 0=confirmed 1=rolledback — tell the #279 gate */ + Done, /* code: terminal result */ + } kind; + MigMsg msg; + ChannelDef channel; + uint8_t code = 0; +}; + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_MIG_TYPES_H */ diff --git a/src/chanmig/MigWire.h b/src/chanmig/MigWire.h new file mode 100644 index 0000000..60f8382 --- /dev/null +++ b/src/chanmig/MigWire.h @@ -0,0 +1,398 @@ +/* MigWire — the canonical, authenticated wire codec for the channel-migration + * control protocol. Byte-packed little-endian (put/get helpers, no struct + * casts — portable and KAT-stable, the HopSyncMarker style), every message + * MAC'd with SipHash-2-4 (the codebase's existing primitive, src/HopSchedule.h) + * under a control key domain-separated from any hop/link key. + * + * Anti-replay is structural: epochs are random per process start and never + * persisted, so a restart orphans every in-flight generation for free (there + * is no persisted in-flight state at all). A receiver adopts a peer epoch only + * from a PROPOSAL/STATUS, tracks the max generation, and binds COMMIT/CONFIRM + * to the nonce it issued — so a replayed old exchange can never be acked by + * the current peer, and since the drone activates only after an ack, a replay + * can never move it. + * + * Every message is <= 96 bytes (asserted in the KAT), so a control frame is + * ~120 µs of 6M air. Pure, header-only, selftested. */ +#ifndef DEVOURER_CHANMIG_MIG_WIRE_H +#define DEVOURER_CHANMIG_MIG_WIRE_H + +#include +#include +#include +#include + +#include "HopSchedule.h" +#include "chanmig/ChannelDef.h" +#include "chanmig/MigTypes.h" + +namespace devourer { +namespace chanmig { + +inline constexpr uint16_t kMigMagic = 0x4D43; /* bytes 0x43 0x4D = "CM" */ +inline constexpr uint8_t kMigVersion = 1; +inline constexpr size_t kMigHeader = 8; /* magic(2) ver(1) type(1) link(4) */ +inline constexpr size_t kMigMacLen = 8; +inline constexpr size_t kMigMaxLen = 96; + +/* 16-byte control key, domain-separated from a master seed. */ +struct MigKey { + std::array k{}; + static MigKey derive(const HopSchedule::Key &master) { + MigKey out; + static const uint8_t d0[] = {'d', 'e', 'v', 'o', 'u', 'r', 'e', 'r', + '-', 'm', 'i', 'g', '-', 'c', '0'}; + static const uint8_t d1[] = {'d', 'e', 'v', 'o', 'u', 'r', 'e', 'r', + '-', 'm', 'i', 'g', '-', 'c', '1'}; + const uint64_t lo = HopSchedule::siphash24(master, d0, sizeof(d0)); + const uint64_t hi = HopSchedule::siphash24(master, d1, sizeof(d1)); + for (int i = 0; i < 8; i++) { + out.k[i] = static_cast(lo >> (8 * i)); + out.k[8 + i] = static_cast(hi >> (8 * i)); + } + return out; + } + /* From DEVOURER_MIG_KEY hex text (reuses the hop seed parser). */ + static MigKey from_seed(const char *text) { + return derive(HopSchedule::parse_seed(text)); + } + uint64_t mac(const uint8_t *p, size_t n) const { + return HopSchedule::siphash24(k, p, n); + } +}; + +namespace wire { +inline void p8(std::vector &b, uint8_t v) { b.push_back(v); } +inline void p16(std::vector &b, uint16_t v) { + b.push_back(uint8_t(v)); + b.push_back(uint8_t(v >> 8)); +} +inline void p32(std::vector &b, uint32_t v) { + for (int i = 0; i < 4; i++) + b.push_back(uint8_t(v >> (8 * i))); +} +inline void p64(std::vector &b, uint64_t v) { + for (int i = 0; i < 8; i++) + b.push_back(uint8_t(v >> (8 * i))); +} +inline void pdef(std::vector &b, const ChannelDef &d) { + p8(b, d.band); + p8(b, d.primary); + p8(b, static_cast(d.center_mhz() >= 5000 + ? (d.center_mhz() - 5000) / 5 + : (d.center_mhz() - 2407) / 5)); + p8(b, static_cast(d.width)); + p8(b, d.offset); + p8(b, static_cast((d.no_ir ? 1 : 0) | (d.dfs ? 2 : 0) | + (d.backup ? 4 : 0))); +} +inline uint8_t g8(const uint8_t *p, size_t &o) { return p[o++]; } +inline uint16_t g16(const uint8_t *p, size_t &o) { + uint16_t v = uint16_t(p[o]) | uint16_t(p[o + 1]) << 8; + o += 2; + return v; +} +inline uint32_t g32(const uint8_t *p, size_t &o) { + uint32_t v = 0; + for (int i = 0; i < 4; i++) + v |= uint32_t(p[o + i]) << (8 * i); + o += 4; + return v; +} +inline uint64_t g64(const uint8_t *p, size_t &o) { + uint64_t v = 0; + for (int i = 0; i < 8; i++) + v |= uint64_t(p[o + i]) << (8 * i); + o += 8; + return v; +} +inline ChannelDef gdef(const uint8_t *p, size_t &o) { + ChannelDef d; + d.band = g8(p, o); + d.primary = g8(p, o); + (void)g8(p, o); /* center: derived + re-validated, carried for cross-check */ + d.width = static_cast(g8(p, o)); + d.offset = g8(p, o); + const uint8_t fl = g8(p, o); + d.no_ir = fl & 1; + d.dfs = fl & 2; + d.backup = fl & 4; + return d; +} +} /* namespace wire */ + +/* Finish a partially-built frame: append the SipHash MAC over everything so + * far, returning the complete frame. */ +inline std::vector mig_seal(std::vector b, const MigKey &key) { + const uint64_t mac = key.mac(b.data(), b.size()); + wire::p64(b, mac); + return b; +} + +inline void mig_header(std::vector &b, MigMsgType t, uint32_t link_id) { + wire::p16(b, kMigMagic); + wire::p8(b, kMigVersion); + wire::p8(b, static_cast(t)); + wire::p32(b, link_id); +} + +/* --- per-type encoders (input is a fully-populated MigMsg) --- */ +inline std::vector mig_encode(const MigMsg &m, const MigKey &key) { + std::vector b; + mig_header(b, m.type, m.link_id); + switch (m.type) { + case MT_PROPOSAL: + wire::p32(b, m.ground_epoch); + wire::p32(b, m.generation); + wire::pdef(b, m.source); + wire::pdef(b, m.target); + wire::p32(b, m.evidence_gen); + wire::p64(b, m.evidence_digest); + wire::p64(b, m.earliest_tsf); + wire::p64(b, m.latest_tsf); + wire::p8(b, m.fallback_mode); + wire::pdef(b, m.rescue); + wire::p32(b, m.ground_nonce); + break; + case MT_COMMIT: + wire::p32(b, m.drone_epoch); + wire::p32(b, m.ground_epoch); + wire::p32(b, m.generation); + wire::pdef(b, m.target); + wire::p64(b, m.activate_tsf); + wire::p64(b, m.tx_tsf); + wire::p32(b, m.confirm_window_us); + wire::p64(b, m.rollback_deadline_tsf); + wire::pdef(b, m.rescue); + wire::p32(b, m.ground_nonce_echo); + wire::p32(b, m.drone_nonce); + wire::p8(b, m.armed); + break; + case MT_STATUS: + wire::p8(b, m.role); + wire::p32(b, m.sender_epoch); + wire::p32(b, m.generation); + wire::p8(b, m.state); + wire::pdef(b, m.current); + wire::p64(b, m.tx_tsf); + wire::p8(b, m.reason); + wire::p32(b, m.peer_nonce_echo); + break; + case MT_CONFIRM: + wire::p32(b, m.ground_epoch); + wire::p32(b, m.generation); + wire::p32(b, m.drone_nonce_echo); + wire::p16(b, m.marker_count); + wire::p16(b, m.video_frames); + wire::p32(b, m.first_marker_tsfl); + break; + case MT_ABORT: + wire::p8(b, m.role); + wire::p32(b, m.sender_epoch); + wire::p32(b, m.generation); + wire::p8(b, m.reason); + wire::p8(b, m.effective); + wire::p64(b, m.tx_tsf); + break; + case MT_MARKER: + wire::p32(b, m.drone_epoch); + wire::p32(b, m.generation); + wire::p32(b, m.seq); + wire::pdef(b, m.aired_on); + wire::p8(b, m.marker_flags); + wire::p64(b, m.tx_tsf); + break; + case MT_VALIDATION: + wire::p32(b, m.drone_epoch); + wire::p32(b, m.generation); + wire::pdef(b, m.target); + wire::p8(b, m.method); + wire::p8(b, m.result); + wire::p8(b, m.reason); + wire::p32(b, m.obs_age_ms); + wire::p32(b, m.obs_dur_ms); + wire::p32(b, m.cca_delta); + wire::p32(b, m.fa_delta); + wire::p8(b, m.igi); + wire::p8(b, m.nhm_busy_pct); + wire::p8(b, m.energy_valid); + wire::p16(b, m.cost_est_ms); + wire::p64(b, m.tx_tsf); + break; + } + return mig_seal(std::move(b), key); +} + +/* Decode + authenticate. Returns MigReason::None on success (out filled), or + * the specific rejection. link_id 0 skips the link check (KATs). */ +inline MigReason mig_decode(const uint8_t *p, size_t n, const MigKey &key, + uint32_t expect_link, MigMsg &out) { + /* n may exceed the message length: an on-air RX frame carries a trailing + * 4-byte FCS (and some parsers pad), so the message occupies a PREFIX of the + * buffer. The body length is fixed per type, so the decoder advances `o` + * through the fixed fields, then locates the MAC right after — any bytes + * beyond that (FCS/pad) are ignored. The upper bound allows the message + + * a small trailer. */ + if (n < kMigHeader + kMigMacLen || n > kMigMaxLen + 8) + return MigReason::Truncated; + size_t o = 0; + if (wire::g16(p, o) != kMigMagic) + return MigReason::BadType; + if (wire::g8(p, o) != kMigVersion) + return MigReason::BadVersion; + const uint8_t type = wire::g8(p, o); + const uint32_t link = wire::g32(p, o); + if (type < MT_PROPOSAL || type > MT_VALIDATION) + return MigReason::BadType; + if (expect_link != 0 && link != expect_link) + return MigReason::BadLinkId; + + out = MigMsg{}; + out.type = static_cast(type); + out.link_id = link; + /* A per-type body reader; `o` ends at the byte after the fixed body, where + * the MAC begins. A body that would read past the buffer is a truncation + * (guarded after the switch). */ + switch (type) { + case MT_PROPOSAL: + out.ground_epoch = wire::g32(p, o); + out.generation = wire::g32(p, o); + out.source = wire::gdef(p, o); + out.target = wire::gdef(p, o); + out.evidence_gen = wire::g32(p, o); + out.evidence_digest = wire::g64(p, o); + out.earliest_tsf = wire::g64(p, o); + out.latest_tsf = wire::g64(p, o); + out.fallback_mode = wire::g8(p, o); + out.rescue = wire::gdef(p, o); + out.ground_nonce = wire::g32(p, o); + break; + case MT_COMMIT: + out.drone_epoch = wire::g32(p, o); + out.ground_epoch = wire::g32(p, o); + out.generation = wire::g32(p, o); + out.target = wire::gdef(p, o); + out.activate_tsf = wire::g64(p, o); + out.tx_tsf = wire::g64(p, o); + out.confirm_window_us = wire::g32(p, o); + out.rollback_deadline_tsf = wire::g64(p, o); + out.rescue = wire::gdef(p, o); + out.ground_nonce_echo = wire::g32(p, o); + out.drone_nonce = wire::g32(p, o); + out.armed = wire::g8(p, o); + break; + case MT_STATUS: + out.role = wire::g8(p, o); + out.sender_epoch = wire::g32(p, o); + out.generation = wire::g32(p, o); + out.state = wire::g8(p, o); + out.current = wire::gdef(p, o); + out.tx_tsf = wire::g64(p, o); + out.reason = wire::g8(p, o); + out.peer_nonce_echo = wire::g32(p, o); + break; + case MT_CONFIRM: + out.ground_epoch = wire::g32(p, o); + out.generation = wire::g32(p, o); + out.drone_nonce_echo = wire::g32(p, o); + out.marker_count = wire::g16(p, o); + out.video_frames = wire::g16(p, o); + out.first_marker_tsfl = wire::g32(p, o); + break; + case MT_ABORT: + out.role = wire::g8(p, o); + out.sender_epoch = wire::g32(p, o); + out.generation = wire::g32(p, o); + out.reason = wire::g8(p, o); + out.effective = wire::g8(p, o); + out.tx_tsf = wire::g64(p, o); + break; + case MT_MARKER: + out.drone_epoch = wire::g32(p, o); + out.generation = wire::g32(p, o); + out.seq = wire::g32(p, o); + out.aired_on = wire::gdef(p, o); + out.marker_flags = wire::g8(p, o); + out.tx_tsf = wire::g64(p, o); + break; + case MT_VALIDATION: + out.drone_epoch = wire::g32(p, o); + out.generation = wire::g32(p, o); + out.target = wire::gdef(p, o); + out.method = wire::g8(p, o); + out.result = wire::g8(p, o); + out.reason = wire::g8(p, o); + out.obs_age_ms = wire::g32(p, o); + out.obs_dur_ms = wire::g32(p, o); + out.cca_delta = wire::g32(p, o); + out.fa_delta = wire::g32(p, o); + out.igi = wire::g8(p, o); + out.nhm_busy_pct = wire::g8(p, o); + out.energy_valid = wire::g8(p, o); + out.cost_est_ms = wire::g16(p, o); + out.tx_tsf = wire::g64(p, o); + break; + } + /* the MAC sits immediately after the fixed body; trailing bytes (FCS/pad) + * are ignored. Verify over exactly [0..o). */ + if (o + kMigMacLen > n) + return MigReason::Truncated; /* body ran past the buffer */ + const uint64_t want = key.mac(p, o); + size_t mo = o; + const uint64_t got = wire::g64(p, mo); + if (want != got) + return MigReason::BadMac; + return MigReason::None; +} + +/* Anti-replay window: what a receiver remembers about one peer. Epochs are + * random per boot (the caller seeds them from std::random_device), never + * persisted. */ +struct ReplayWindow { + bool have_peer = false; + uint32_t peer_epoch = 0; + uint32_t max_gen = 0; + uint32_t in_flight_gen = 0; /* 0 = none in flight */ + + /* Adopt/verify a peer epoch. Only PROPOSAL/STATUS may introduce a new + * epoch; a new epoch resets the generation ceiling (a fresh process). */ + bool accept_epoch(uint32_t epoch, bool may_introduce) { + if (!have_peer) { + if (!may_introduce) + return false; + have_peer = true; + peer_epoch = epoch; + max_gen = 0; + in_flight_gen = 0; + return true; + } + if (epoch == peer_epoch) + return true; + if (may_introduce) { /* peer restarted */ + peer_epoch = epoch; + max_gen = 0; + in_flight_gen = 0; + return true; + } + return false; /* unknown epoch on a non-introducing message */ + } + + /* A proposal's generation: reject a replay (< max), idempotent re-answer + * (== in-flight), or a fresh exchange (> max). Returns: + * 0 = replay (reject), 1 = idempotent, 2 = fresh (adopt as in-flight). */ + int classify_proposal(uint32_t gen) { + if (in_flight_gen != 0 && gen == in_flight_gen) + return 1; + if (gen <= max_gen) + return 0; + max_gen = gen; + in_flight_gen = gen; + return 2; + } + void clear_in_flight() { in_flight_gen = 0; } +}; + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_MIG_WIRE_H */ diff --git a/src/chanmig/PrimaryFeed.h b/src/chanmig/PrimaryFeed.h new file mode 100644 index 0000000..cf3721e --- /dev/null +++ b/src/chanmig/PrimaryFeed.h @@ -0,0 +1,131 @@ +/* PrimaryFeed — turn the primary receiver's JSONL stream into ActiveLinkWindow + * records the RecommendEngine consumes. + * + * The primary receiver (rxdemo parked on the video channel) is a SEPARATE + * process; its stdout is redirected to a file the scout tail-follows. Its + * relevant events: + * rx.txhit per canonical-SA frame — seq feeds the loss estimator + * rx.quality windowed verdict + rssi/snr/evm (DEVOURER_RXQUALITY) + * link.health windowed verdict (DEVOURER_LINKHEALTH) + * rx.energy window boundary + crc_err/icv_err (DEVOURER_RX_KEEP_CORRUPTED) + * A windowed event (rx.quality preferred, else link.health) closes an + * ActiveLinkWindow: the frames seen since the last close become its delivery, + * the verdict its fault domain. Pure line-in / windows-out — no file I/O + * (the demo does the tailing) and no clock (the caller stamps arrival time). + * + * Never a FIFO on the producer side: a wedged scout must not be able to block + * the primary video receiver, so the contract is an append-only file the + * scout polls. */ +#ifndef DEVOURER_CHANMIG_PRIMARY_FEED_H +#define DEVOURER_CHANMIG_PRIMARY_FEED_H + +#include +#include +#include +#include + +#include "LinkHealth.h" +#include "chanmig/ActiveLink.h" +#include "chanmig/JsonlLite.h" + +namespace devourer { +namespace chanmig { + +inline LinkVerdict verdict_from_label(std::string_view s) { + if (s == "HEALTHY") + return LinkVerdict::Healthy; + if (s == "SATURATED") + return LinkVerdict::Saturated; + if (s == "INTERFERENCE") + return LinkVerdict::Interference; + if (s == "WEAK") + return LinkVerdict::Weak; + if (s == "MARGINAL") + return LinkVerdict::Marginal; + return LinkVerdict::NoSignal; +} + +class PrimaryFeedReader { +public: + /* Feed one line; returns true and fills `out` when this line closed a + * window. arrival_ms is the scout's own monotonic clock at read time (the + * two processes share no clock; window granularity dwarfs the skew). */ + bool line(std::string_view l, int64_t arrival_ms, ActiveLinkWindow &out) { + if (jsonl_ev_is(l, "rx.txhit")) { + long long seq; + if (jsonl_int(l, "seq", &seq)) + seq_.observe(static_cast(seq)); + ++frames_since_; + saw_frames_ = true; + return false; + } + if (jsonl_ev_is(l, "rx.energy")) { + long long v; + if (jsonl_int(l, "crc_err", &v)) { + crc_ += static_cast(v); + have_crc_ = true; + } + if (jsonl_int(l, "icv_err", &v)) + icv_ += static_cast(v); + return false; + } + const bool q = jsonl_ev_is(l, "rx.quality"); + const bool h = !q && jsonl_ev_is(l, "link.health"); + if (!q && !h) + return false; + /* link.health is skipped when rx.quality is also on (avoid double-closing + * one window); prefer rx.quality's richer fields. */ + if (h && seen_quality_) + return false; + if (q) + seen_quality_ = true; + + out = ActiveLinkWindow{}; + out.t_ms = arrival_ms; + out.telemetry_ok = true; + std::string label; + if (jsonl_str(l, "verdict", &label)) { + out.have_quality = true; + out.verdict = verdict_from_label(label); + } + double d; + if (jsonl_num(l, "rssi_max_dbm", &d)) + out.rssi_max_dbm = static_cast(d); + else if (jsonl_num(l, "rssi_dbm", &d)) + out.rssi_max_dbm = static_cast(d); + if (jsonl_num(l, "snr_mean_db", &d)) + out.snr_mean_db = d; + else if (jsonl_num(l, "snr_db", &d)) + out.snr_mean_db = d; + if (jsonl_num(l, "evm_db", &d)) + out.evm_mean_db = d; + + if (saw_frames_) { + out.have_delivery = true; + out.delivered = seq_.delivered(); + out.expected = seq_.expected(); + } + if (have_crc_) { + out.have_crc = true; + out.crc_err = crc_; + out.icv_err = icv_; + } + /* reset per-window accumulators */ + seq_.reset(); + frames_since_ = 0; + saw_frames_ = false; + crc_ = icv_ = 0; + have_crc_ = false; + return true; + } + +private: + SeqLossEstimator seq_; + uint32_t frames_since_ = 0, crc_ = 0, icv_ = 0; + bool saw_frames_ = false, have_crc_ = false, seen_quality_ = false; +}; + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_PRIMARY_FEED_H */ diff --git a/src/chanmig/ScanPlan.h b/src/chanmig/ScanPlan.h new file mode 100644 index 0000000..0d6f49f --- /dev/null +++ b/src/chanmig/ScanPlan.h @@ -0,0 +1,203 @@ +/* ScanScheduler — the pure dwell scheduler behind the chanscout demo. + * + * The scout surveys CANDIDATES but tunes 20 MHz BINS (a 40/80 MHz candidate + * is its constituent bins; two candidates sharing a bin share its evidence). + * Each bin carries a revisit deadline: bins belonging to a preferred-backup + * candidate get the fast cadence, everything else the background cadence, and + * next() always returns the most-overdue bin — so the scout scans at 100% + * duty while backup bins are simply revisited more often. Deterministic: + * deadline order, ties broken by plan position; no clock reads (the caller + * passes now), no RNG. + * + * The round counter advances only when EVERY bin has been successfully + * observed since the last advance. That is the structural anti-positive- + * feedback guard: backup favoritism can never starve the full rescan a + * scoring policy's min-rounds gate keys on. + * + * Wide candidates optionally get a periodic full-width verification dwell + * (the demo pays a full SetMonitorChannel for it); those ride their own + * cadence and never count toward bin coverage. */ +#ifndef DEVOURER_CHANMIG_SCAN_PLAN_H +#define DEVOURER_CHANMIG_SCAN_PLAN_H + +#include +#include + +#include "chanmig/ChannelDef.h" + +namespace devourer { +namespace chanmig { + +struct ScanPlanConfig { + std::vector candidates; /* immutable for the process lifetime */ + int dwell_ms = 100; + int settle_ms = 30; + int backup_revisit_ms = 1000; /* bins of backup candidates */ + int bg_revisit_ms = 5000; /* everything else */ + int fail_retry_ms = 250; /* deadline pushback after a failed dwell */ + int fullwidth_ms = 0; /* 0 = no wide verification dwells */ + int64_t max_age_ms = 60000; /* evidence-freshness bound (consumers) */ + + /* Stable identity of the whole plan (candidates + cadences). Emitted in + * scout.plan and stamped into every record so a consumer can refuse to fold + * records produced under a different plan. FNV-1a over the canonical + * candidate formats and the cadence fields. */ + uint32_t plan_hash() const { + uint32_t h = 2166136261u; + auto fold = [&h](const void *p, size_t n) { + const uint8_t *b = static_cast(p); + for (size_t i = 0; i < n; i++) { + h ^= b[i]; + h *= 16777619u; + } + }; + for (const ChannelDef &c : candidates) { + const uint32_t k = c.key(); + const uint8_t fl = static_cast((c.backup ? 1 : 0) | + (c.no_ir ? 2 : 0) | + (c.dfs ? 4 : 0)); + fold(&k, sizeof(k)); + fold(&fl, 1); + } + const int32_t cad[4] = {dwell_ms, settle_ms, backup_revisit_ms, + bg_revisit_ms}; + fold(cad, sizeof(cad)); + return h; + } +}; + +class ScanScheduler { +public: + struct DwellPlan { + bool valid = false; + uint8_t bin_ch = 0; + ChannelDef def; /* what to tune: 20 MHz bin def, or the wide candidate + when full_width */ + uint64_t round = 0; + bool full_width = false; + int plan_index = -1; /* internal: bin slot / wide-candidate slot */ + }; + + explicit ScanScheduler(const ScanPlanConfig &cfg) : cfg_(cfg) { + for (size_t ci = 0; ci < cfg_.candidates.size(); ++ci) { + const ChannelDef &c = cfg_.candidates[ci]; + uint8_t bins[4]; + const int n = constituent_bins(c, bins); + const int cadence = + c.backup ? cfg_.backup_revisit_ms : cfg_.bg_revisit_ms; + for (int i = 0; i < n; i++) { + Bin *b = find_bin(bins[i]); + if (b == nullptr) { + bins_.push_back(Bin{bins[i], c.band, cadence, INT64_MIN, false, 0}); + } else if (cadence < b->cadence_ms) { + b->cadence_ms = cadence; /* fastest containing candidate wins */ + } + } + if (c.width == CHANNEL_WIDTH_40 || c.width == CHANNEL_WIDTH_80) + wide_.push_back(static_cast(ci)); + } + } + + size_t bin_count() const { return bins_.size(); } + uint64_t rounds_complete() const { return rounds_; } + int consecutive_failures() const { return consec_fail_; } + + /* Pick the next dwell: the periodic wide verification dwell when due, + * otherwise the most-overdue bin (ties by plan position — which also makes + * the very first sweep visit every bin exactly once in plan order, since + * all deadlines start equal). */ + DwellPlan next(int64_t now_ms) { + DwellPlan p; + if (bins_.empty()) + return p; + /* Arm the wide-dwell cadence off the first call, so the initial full + * sweep is always plain bins. */ + if (next_wide_ms_ == INT64_MIN) + next_wide_ms_ = now_ms + cfg_.fullwidth_ms; + if (cfg_.fullwidth_ms > 0 && !wide_.empty() && now_ms >= next_wide_ms_) { + const int ci = wide_[wide_rr_ % wide_.size()]; + p.valid = true; + p.full_width = true; + p.def = cfg_.candidates[static_cast(ci)]; + p.bin_ch = p.def.primary; + p.round = rounds_; + p.plan_index = ci; + return p; + } + int best = 0; + for (size_t i = 1; i < bins_.size(); ++i) + if (bins_[i].deadline_ms < bins_[best].deadline_ms) + best = static_cast(i); + const Bin &b = bins_[static_cast(best)]; + p.valid = true; + p.bin_ch = b.ch; + p.def.band = b.band; + p.def.primary = b.ch; + p.def.width = CHANNEL_WIDTH_20; + p.round = rounds_; + p.plan_index = best; + return p; + } + + void complete(const DwellPlan &p, int64_t now_ms, bool ok) { + if (!p.valid) + return; + if (p.full_width) { + wide_rr_++; + next_wide_ms_ = now_ms + cfg_.fullwidth_ms; + /* wide dwells never touch bin coverage or the failure streak */ + return; + } + if (p.plan_index < 0 || p.plan_index >= static_cast(bins_.size())) + return; + Bin &b = bins_[static_cast(p.plan_index)]; + if (ok) { + b.deadline_ms = now_ms + b.cadence_ms; + b.fails = 0; + consec_fail_ = 0; + if (!b.covered) { + b.covered = true; + if (++covered_n_ == bins_.size()) { + ++rounds_; + covered_n_ = 0; + for (Bin &x : bins_) + x.covered = false; + } + } + } else { + b.deadline_ms = now_ms + cfg_.fail_retry_ms; + ++b.fails; + ++consec_fail_; + } + } + +private: + struct Bin { + uint8_t ch; + uint8_t band; + int cadence_ms; + int64_t deadline_ms; + bool covered; + int fails; + }; + Bin *find_bin(uint8_t ch) { + for (Bin &b : bins_) + if (b.ch == ch) + return &b; + return nullptr; + } + + ScanPlanConfig cfg_; + std::vector bins_; + std::vector wide_; + size_t covered_n_ = 0; + uint64_t rounds_ = 0; + size_t wide_rr_ = 0; + int64_t next_wide_ms_ = INT64_MIN; + int consec_fail_ = 0; +}; + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_SCAN_PLAN_H */ diff --git a/src/chanmig/SurveyJsonl.h b/src/chanmig/SurveyJsonl.h new file mode 100644 index 0000000..f323320 --- /dev/null +++ b/src/chanmig/SurveyJsonl.h @@ -0,0 +1,167 @@ +/* survey.dwell JSONL binding — the one place the record schema exists. + * + * chanscout emits through emit_survey_dwell(); every C++ consumer (the + * advise-mode replay path, the aggregator selftest) parses through + * survey_dwell_from_jsonl(). Keeping both directions in one header means the + * schema cannot drift between producer and consumer, and the selftest + * round-trips a record through both to pin it. Python consumers read the + * same fields via tests/devourer_events.py. + * + * Nullable-field convention follows rx.energy: a chip counter the generation + * doesn't expose is JSON null, never a fake zero — so "quiet channel" and + * "sensor absent" stay distinguishable offline. */ +#ifndef DEVOURER_CHANMIG_SURVEY_JSONL_H +#define DEVOURER_CHANMIG_SURVEY_JSONL_H + +#include + +#include "Event.h" +#include "chanmig/JsonlLite.h" +#include "chanmig/SurveyRecord.h" + +namespace devourer { +namespace chanmig { + +inline void emit_survey_dwell(EventSink &sink, const SurveyDwell &d) { + Ev ev(sink, "survey.dwell"); + ev.f("v", kSurveySchemaV).f("seq", (unsigned long long)d.seq); + char chan[20]; + d.def.format(chan, sizeof(chan)); + ev.f("chan", chan).f("round", (unsigned long long)d.round); + ev.hexf("plan", d.plan_hash, 8); + ev.f("start_ms", (long long)d.t_start_ms) + .f("end_ms", (long long)d.t_end_ms) + .f("retune_us", (long long)d.retune_us) + .f("settle_ms", d.settle_ms) + .f("observe_ms", (long long)d.observe_ms); + if (d.valid_fa) + ev.f("cca_ofdm", d.cca_ofdm) + .f("cca_cck", d.cca_cck) + .f("fa_ofdm", d.fa_ofdm) + .f("fa_cck", d.fa_cck); + else + ev.f("cca_ofdm", nullptr) + .f("cca_cck", nullptr) + .f("fa_ofdm", nullptr) + .f("fa_cck", nullptr); + if (d.valid_igi) + ev.f("igi", d.igi); + else + ev.f("igi", nullptr); + if (d.valid_nhm) { + int hist[12]; + for (int i = 0; i < 12; i++) + hist[i] = d.nhm[i]; + ev.f("nhm_busy", d.nhm_busy_pct) + .f("nhm_peak", d.nhm_peak) + .f("nhm_dur", d.nhm_dur) + .arr("nhm", hist, 12); + } else { + ev.f("nhm_busy", nullptr); + } + ev.f("frames", d.frames) + .f("rssi_mean", d.rssi_mean_raw) + .f("rssi_max", d.rssi_max_raw) + .f("snr_mean", d.snr_mean_raw) + .f("snr_min", d.snr_min_raw); + if (d.evm_valid) + ev.f("evm_mean", d.evm_mean_raw); + else + ev.f("evm_mean", nullptr); + ev.f("dvr_frames", d.dvr_frames) + .f("dvr_air_us", (unsigned long long)d.dvr_air_us) + .f("oth_air_us", (unsigned long long)d.oth_air_us) + .f("flags", d.flags); + ev.hexf("scout_id", d.scout_id, 8); + ev.f("agen", d.adapter_gen); +} + +/* Parse one survey.dwell line. False for any other event line, a schema + * version this build doesn't know, or an unparseable channel token. */ +inline bool survey_dwell_from_jsonl(std::string_view line, SurveyDwell &d) { + if (!jsonl_ev_is(line, "survey.dwell")) + return false; + long long v = 0; + if (!jsonl_int(line, "v", &v) || v != kSurveySchemaV) + return false; + d = SurveyDwell{}; + std::string chan, err, hex; + if (!jsonl_str(line, "chan", &chan) || !parse_chan_token(chan, d.def, err)) + return false; + long long x = 0; + if (jsonl_int(line, "seq", &x)) + d.seq = static_cast(x); + if (jsonl_int(line, "round", &x)) + d.round = static_cast(x); + if (jsonl_str(line, "plan", &hex)) + d.plan_hash = static_cast(std::strtoul(hex.c_str(), nullptr, 16)); + if (jsonl_int(line, "start_ms", &x)) + d.t_start_ms = x; + if (jsonl_int(line, "end_ms", &x)) + d.t_end_ms = x; + if (jsonl_int(line, "retune_us", &x)) + d.retune_us = x; + if (jsonl_int(line, "settle_ms", &x)) + d.settle_ms = static_cast(x); + if (jsonl_int(line, "observe_ms", &x)) + d.observe_ms = x; + if (jsonl_int(line, "cca_ofdm", &x)) { + d.valid_fa = true; + d.cca_ofdm = static_cast(x); + if (jsonl_int(line, "cca_cck", &x)) + d.cca_cck = static_cast(x); + if (jsonl_int(line, "fa_ofdm", &x)) + d.fa_ofdm = static_cast(x); + if (jsonl_int(line, "fa_cck", &x)) + d.fa_cck = static_cast(x); + } + if (jsonl_int(line, "igi", &x)) { + d.valid_igi = true; + d.igi = static_cast(x); + } + if (jsonl_int(line, "nhm_busy", &x)) { + d.valid_nhm = true; + d.nhm_busy_pct = static_cast(x); + if (jsonl_int(line, "nhm_peak", &x)) + d.nhm_peak = static_cast(x); + if (jsonl_int(line, "nhm_dur", &x)) + d.nhm_dur = static_cast(x); + int hist[12] = {}; + int n = 0; + if (jsonl_arr(line, "nhm", hist, 12, &n)) + for (int i = 0; i < n; i++) + d.nhm[i] = static_cast(hist[i]); + } + if (jsonl_int(line, "frames", &x)) + d.frames = static_cast(x); + if (jsonl_int(line, "rssi_mean", &x)) + d.rssi_mean_raw = static_cast(x); + if (jsonl_int(line, "rssi_max", &x)) + d.rssi_max_raw = static_cast(x); + if (jsonl_int(line, "snr_mean", &x)) + d.snr_mean_raw = static_cast(x); + if (jsonl_int(line, "snr_min", &x)) + d.snr_min_raw = static_cast(x); + if (jsonl_int(line, "evm_mean", &x)) { + d.evm_valid = true; + d.evm_mean_raw = static_cast(x); + } + if (jsonl_int(line, "dvr_frames", &x)) + d.dvr_frames = static_cast(x); + if (jsonl_int(line, "dvr_air_us", &x)) + d.dvr_air_us = static_cast(x); + if (jsonl_int(line, "oth_air_us", &x)) + d.oth_air_us = static_cast(x); + if (jsonl_int(line, "flags", &x)) + d.flags = static_cast(x); + if (jsonl_str(line, "scout_id", &hex)) + d.scout_id = static_cast(std::strtoul(hex.c_str(), nullptr, 16)); + if (jsonl_int(line, "agen", &x)) + d.adapter_gen = static_cast(x); + return true; +} + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_SURVEY_JSONL_H */ diff --git a/src/chanmig/SurveyRecord.h b/src/chanmig/SurveyRecord.h new file mode 100644 index 0000000..408b8d6 --- /dev/null +++ b/src/chanmig/SurveyRecord.h @@ -0,0 +1,128 @@ +/* SurveyDwell — the versioned record one scout dwell produces, and the + * airtime approximation that splits decoded occupancy into "our own video" + * vs "other traffic". + * + * A dwell is one visit to one 20 MHz bin (or, for the optional wide + * verification dwell, one full-width candidate): retune → settle → discard + * barrier (reset the chip's delta counters + drain frames still in the USB + * pipeline from the previous channel) → observe → read. The FA/CCA fields + * are therefore deltas over exactly the observe window — the counter hygiene + * the plain rxdemo sweep does not do (its per-bin delta includes its own + * retune+settle). + * + * Every field that can be invalid carries a flag bit instead of a fake zero: + * a consumer must be able to tell "quiet channel" from "sensor absent" from + * "dwell aborted". */ +#ifndef DEVOURER_CHANMIG_SURVEY_RECORD_H +#define DEVOURER_CHANMIG_SURVEY_RECORD_H + +#include + +#include "chanmig/ChannelDef.h" + +namespace devourer { +namespace chanmig { + +inline constexpr int kSurveySchemaV = 1; + +enum SurveyFlag : uint16_t { + kFlagTruncated = 1u << 0, /* dwell cut short (shutdown) */ + kFlagRetuneFailed = 1u << 1, /* retune threw — no observation happened */ + kFlagReadFailed = 1u << 2, /* energy read invalid at dwell end */ + kFlagCounterSuspect = 1u << 3, /* delta implausible for the window (wrap) */ + kFlagNhmMissing = 1u << 4, /* generation exposes no NHM this dwell */ + kFlagFullWidth = 1u << 5, /* wide verification dwell (full retune) */ +}; + +struct SurveyDwell { + uint64_t seq = 0; /* monotonic dwell counter — a gap = a lost record */ + ChannelDef def; /* what was tuned: a 20 MHz bin def, or the wide + candidate when kFlagFullWidth */ + uint64_t round = 0; /* completed-full-sweep count at dwell time */ + uint32_t plan_hash = 0; + + int64_t t_start_ms = 0; /* caller monotonic clock, dwell start (pre-retune) */ + int64_t t_end_ms = 0; + int64_t retune_us = 0; + int settle_ms = 0; + int64_t observe_ms = 0; /* the valid observation window the deltas span */ + + /* Frame-free energy (deltas over observe; validity per source). */ + bool valid_fa = false; + uint32_t fa_ofdm = 0, fa_cck = 0, cca_ofdm = 0, cca_cck = 0; + bool valid_igi = false; + uint8_t igi = 0; + bool valid_nhm = false; + uint8_t nhm[12] = {}; + uint16_t nhm_dur = 0; + uint8_t nhm_busy_pct = 0; /* % of NHM samples above the lowest bucket */ + uint8_t nhm_peak = 0; /* fullest bucket index */ + + /* Frame-driven aggregate over the observe window (raw devourer units). */ + uint32_t frames = 0; + int rssi_mean_raw = 0, rssi_max_raw = 0; + int snr_mean_raw = 0, snr_min_raw = 0; + int evm_mean_raw = 0; + bool evm_valid = false; + + /* Occupancy attribution: canonical-SA (our own video) vs everything else, + * as decoded-airtime estimates. Undecodable energy shows only in FA/CCA/ + * NHM — that asymmetry is why the scoring layer treats these as a SPLIT of + * explained occupancy, never as total occupancy. */ + uint32_t dvr_frames = 0; + uint64_t dvr_air_us = 0; + uint64_t oth_air_us = 0; + + uint16_t flags = 0; + uint32_t scout_id = 0; /* stable adapter identity — calibration domain */ + uint8_t adapter_gen = 0; +}; + +/* Approximate on-air duration of one decoded frame from its DESC_RATE code: + * preamble + payload bits at the code's data rate. Deliberately coarse (long + * preamble assumed for CCK, one PPDU per MPDU, no aggregation accounting) — + * it only SPLITS explained occupancy between transmitters, it is never an + * absolute energy unit. `bw` is the rx-descriptor code (0=20,1=40,2=80). */ +inline uint32_t frame_airtime_us(uint16_t desc_rate, uint32_t len_bytes, + uint8_t bw, bool sgi) { + static const uint32_t kCck[4] = {1000, 2000, 5500, 11000}; + static const uint32_t kOfdm[8] = {6000, 9000, 12000, 18000, + 24000, 36000, 48000, 54000}; + static const uint32_t kMcs20[10] = {6500, 13000, 19500, 26000, 39000, + 52000, 58500, 65000, 78000, 86700}; + uint32_t kbps = 6000, preamble_us = 20; + if (desc_rate <= 3) { + kbps = kCck[desc_rate]; + preamble_us = 192; + } else if (desc_rate <= 11) { + kbps = kOfdm[desc_rate - 4]; + } else if (desc_rate <= 43) { /* HT MCS0..31 */ + const uint32_t mcs = desc_rate - 12; + kbps = kMcs20[mcs % 8] * (mcs / 8 + 1); + if (bw >= 1) + kbps = kbps * 27 / 13; /* 40 MHz: 108/52 data subcarriers */ + preamble_us = 36; + } else if (desc_rate <= 83) { /* VHT 1SS..4SS MCS0..9 */ + const uint32_t idx = desc_rate - 44; + kbps = kMcs20[idx % 10] * (idx / 10 + 1); + if (bw == 1) + kbps = kbps * 27 / 13; + else if (bw >= 2) + kbps = kbps * 9 / 2; /* 80 MHz: 234/52 */ + preamble_us = 40; + } + if (sgi) + kbps = kbps * 10 / 9; + if (kbps == 0) + kbps = 6000; + const uint64_t bits = static_cast(len_bytes) * 8u; + uint64_t us = preamble_us + (bits * 1000u + kbps - 1) / kbps; + if (us > 20000) + us = 20000; /* one frame never plausibly exceeds ~20 ms of air */ + return static_cast(us); +} + +} /* namespace chanmig */ +} /* namespace devourer */ + +#endif /* DEVOURER_CHANMIG_SURVEY_RECORD_H */ diff --git a/tests/chan_def_selftest.cpp b/tests/chan_def_selftest.cpp new file mode 100644 index 0000000..61631d7 --- /dev/null +++ b/tests/chan_def_selftest.cpp @@ -0,0 +1,236 @@ +/* ChannelDef grid-legality + geometry + scan-plan grammar selftest. */ +#include "chanmig/ChannelDef.h" + +#include +#include + +static int fails; +#define CHECK(x, msg) \ + do { \ + if (!(x)) { \ + std::fprintf(stderr, "FAIL: %s\n", msg); \ + ++fails; \ + } \ + } while (0) + +using devourer::chanmig::adjacent; +using devourer::chanmig::ChannelDef; +using devourer::chanmig::constituent_bins; +using devourer::chanmig::DefError; +using devourer::chanmig::normalize; +using devourer::chanmig::overlap_mhz; +using devourer::chanmig::parse_chan_token; +using devourer::chanmig::parse_scan_plan; +using devourer::chanmig::PlanParseError; + +static ChannelDef mk(uint8_t band, uint8_t primary, ChannelWidth_t w, + uint8_t off = 0) { + ChannelDef d; + d.band = band; + d.primary = primary; + d.width = w; + d.offset = off; + return d; +} + +static DefError norm(ChannelDef &d) { return normalize(d); } + +int main() { + /* --- 5 GHz 40 MHz pair grid: offset derives from the primary --- */ + { + struct Row { + uint8_t primary; + DefError want; + uint8_t off; + } rows[] = { + {36, DefError::Ok, 1}, {40, DefError::Ok, 2}, + {44, DefError::Ok, 1}, {48, DefError::Ok, 2}, + {100, DefError::Ok, 1}, {104, DefError::Ok, 2}, + {149, DefError::Ok, 1}, {153, DefError::Ok, 2}, + {157, DefError::Ok, 1}, {161, DefError::Ok, 2}, + {165, DefError::Ok, 1}, {38, DefError::OffGrid40, 0}, + {34, DefError::OffGrid40, 0}, + }; + for (const Row &r : rows) { + ChannelDef d = mk(5, r.primary, CHANNEL_WIDTH_40); + const DefError e = norm(d); + CHECK(e == r.want, "5 GHz 40 MHz grid verdict"); + if (r.want == DefError::Ok) + CHECK(d.offset == r.off, "derived 40 MHz offset"); + } + ChannelDef bad = mk(5, 36, CHANNEL_WIDTH_40, 2); /* 36 is a lower half */ + CHECK(norm(bad) == DefError::BadOffset, "contradicting 40 suffix"); + ChannelDef ok = mk(5, 36, CHANNEL_WIDTH_40, 1); + CHECK(norm(ok) == DefError::Ok, "consistent explicit 40 suffix"); + } + + /* --- 5 GHz 80 MHz quads: position 1..4 derives from the primary --- */ + { + struct Row { + uint8_t primary; + uint8_t pos; + } rows[] = {{36, 1}, {40, 2}, {44, 3}, {48, 4}, {52, 1}, {64, 4}, + {100, 1}, {112, 4}, {149, 1}, {153, 2}, {161, 4}}; + for (const Row &r : rows) { + ChannelDef d = mk(5, r.primary, CHANNEL_WIDTH_80); + CHECK(norm(d) == DefError::Ok, "80 MHz quad verdict"); + CHECK(d.offset == r.pos, "derived 80 MHz position"); + } + ChannelDef offgrid = mk(5, 38, CHANNEL_WIDTH_80); + CHECK(norm(offgrid) == DefError::OffGrid80, "80 MHz off-grid"); + ChannelDef b2 = mk(2, 6, CHANNEL_WIDTH_80); + CHECK(norm(b2) == DefError::BadWidth, "no 80 MHz on 2.4"); + ChannelDef wrongpos = mk(5, 40, CHANNEL_WIDTH_80, 1); + CHECK(norm(wrongpos) == DefError::BadOffset, "80 MHz position mismatch"); + } + + /* --- 2.4 GHz 40 MHz: genuinely ambiguous without a suffix --- */ + { + ChannelDef amb = mk(2, 6, CHANNEL_WIDTH_40); + CHECK(norm(amb) == DefError::AmbiguousOffset, "ch6/40 ambiguous"); + ChannelDef lo = mk(2, 1, CHANNEL_WIDTH_40); + CHECK(norm(lo) == DefError::Ok && lo.offset == 1, "ch1/40 derives up"); + ChannelDef hi = mk(2, 13, CHANNEL_WIDTH_40); + CHECK(norm(hi) == DefError::Ok && hi.offset == 2, "ch13/40 derives down"); + ChannelDef up = mk(2, 6, CHANNEL_WIDTH_40, 1); + CHECK(norm(up) == DefError::Ok, "ch6/40u explicit"); + ChannelDef bad = mk(2, 10, CHANNEL_WIDTH_40, 1); /* 10+4 = the CCK island */ + CHECK(norm(bad) == DefError::OffGrid40, "secondary on ch14 rejected"); + ChannelDef c14 = mk(2, 14, CHANNEL_WIDTH_40); + CHECK(norm(c14) == DefError::OffGrid40, "ch14/40 rejected"); + } + + /* --- 20 / narrowband widths --- */ + { + ChannelDef bad = mk(5, 36, CHANNEL_WIDTH_20, 1); + CHECK(norm(bad) == DefError::BadOffset, "20 MHz takes no offset"); + ChannelDef nb = mk(5, 149, CHANNEL_WIDTH_5); + CHECK(norm(nb) == DefError::Ok, "5 MHz narrowband candidate"); + ChannelDef badband = mk(3, 36, CHANNEL_WIDTH_20); + CHECK(norm(badband) == DefError::BadBand, "band must be 2 or 5"); + ChannelDef badprim = mk(2, 15, CHANNEL_WIDTH_20); + CHECK(norm(badprim) == DefError::BadPrimary, "2.4 primary range"); + ChannelDef w160 = mk(5, 36, CHANNEL_WIDTH_160); + CHECK(norm(w160) == DefError::BadWidth, "160 not a candidate width"); + } + + /* --- identity key: RF tuple only, flags excluded --- */ + { + ChannelDef a = mk(5, 104, CHANNEL_WIDTH_40); + norm(a); + ChannelDef b = a; + b.backup = true; + b.no_ir = true; + CHECK(a.key() == b.key() && a.same_rf(b), "flags are not identity"); + ChannelDef c = mk(5, 104, CHANNEL_WIDTH_20); + CHECK(a.key() != c.key(), "width changes identity"); + } + + /* --- constituent bins --- */ + { + uint8_t bins[4]; + ChannelDef d = mk(5, 104, CHANNEL_WIDTH_40); + norm(d); + CHECK(constituent_bins(d, bins) == 2 && bins[0] == 100 && bins[1] == 104, + "104/40l bins"); + ChannelDef q = mk(5, 36, CHANNEL_WIDTH_80); + norm(q); + CHECK(constituent_bins(q, bins) == 4 && bins[0] == 36 && bins[1] == 40 && + bins[2] == 44 && bins[3] == 48, + "36/80 bins"); + ChannelDef q2 = mk(5, 161, CHANNEL_WIDTH_80); + norm(q2); + CHECK(constituent_bins(q2, bins) == 4 && bins[0] == 149 && bins[3] == 161, + "161/80 bins anchor at 149"); + ChannelDef s = mk(5, 149, CHANNEL_WIDTH_20); + CHECK(constituent_bins(s, bins) == 1 && bins[0] == 149, "20 MHz one bin"); + } + + /* --- center derivation --- */ + { + ChannelDef d = mk(5, 36, CHANNEL_WIDTH_80); + norm(d); + CHECK(d.center_mhz() == 5210, "36/80 center 5210"); + ChannelDef u = mk(5, 104, CHANNEL_WIDTH_40); + norm(u); + CHECK(u.center_mhz() == 5510, "104/40l center 5510"); + ChannelDef b = mk(2, 6, CHANNEL_WIDTH_40, 1); + norm(b); + CHECK(b.center_mhz() == 2447, "6/40u center 2447"); + ChannelDef s = mk(5, 149, CHANNEL_WIDTH_20); + CHECK(s.center_mhz() == 5745, "149/20 center 5745"); + } + + /* --- overlap / adjacency geometry --- */ + { + ChannelDef w = mk(5, 36, CHANNEL_WIDTH_80); + norm(w); + ChannelDef in = mk(5, 40, CHANNEL_WIDTH_20); + CHECK(overlap_mhz(w, in) == 20, "20-in-80 overlap"); + ChannelDef pair = mk(5, 44, CHANNEL_WIDTH_40); + norm(pair); + CHECK(overlap_mhz(w, pair) == 40, "40-in-80 overlap"); + ChannelDef out = mk(5, 149, CHANNEL_WIDTH_20); + CHECK(overlap_mhz(w, out) == 0, "disjoint blocks"); + ChannelDef a = mk(5, 36, CHANNEL_WIDTH_20), b = mk(5, 40, CHANNEL_WIDTH_20); + CHECK(adjacent(a, b) && overlap_mhz(a, b) == 0, "touching bins adjacent"); + ChannelDef c = mk(5, 44, CHANNEL_WIDTH_20); + CHECK(!adjacent(a, c), "20 MHz gap is not adjacent"); + ChannelDef g1 = mk(2, 1, CHANNEL_WIDTH_20), g6 = mk(2, 6, CHANNEL_WIDTH_20); + CHECK(adjacent(g1, g6), "2.4 GHz 5 MHz gap adjacent"); + ChannelDef xb = mk(2, 6, CHANNEL_WIDTH_20); + CHECK(overlap_mhz(w, xb) == 0 && !adjacent(w, xb), "cross-band disjoint"); + } + + /* --- canonical format round-trip --- */ + { + const char *tokens[] = {"5:104/40l", "5:36/80", "2:6/40u", "5:149/20", + "5:157/40u", "2:11/20"}; + for (const char *t : tokens) { + ChannelDef d; + std::string err; + CHECK(parse_chan_token(t, d, err), "canonical token parses"); + CHECK(d.str() == t, "format round-trip"); + } + ChannelDef d; + std::string err; + CHECK(!parse_chan_token("2:36/20", d, err), "band prefix mismatch"); + } + + /* --- scan-plan grammar --- */ + { + std::vector out; + std::vector errs; + CHECK(parse_scan_plan("104/40l:b,36/80,132/20:p", out, errs), + "well-formed plan parses"); + CHECK(out.size() == 3 && errs.empty(), "3 candidates, no errors"); + CHECK(out[0].backup && !out[0].no_ir, "backup flag"); + CHECK(out[0].width == CHANNEL_WIDTH_40 && out[0].offset == 2, + "40l offset"); + CHECK(out[2].no_ir, "no-IR flag"); + + CHECK(!parse_scan_plan("104/40l,6", out, errs), "mixed band rejected"); + CHECK(errs.size() == 1 && errs[0].token == "6", "mixed-band token named"); + CHECK(out.size() == 1, "surviving candidates kept"); + + CHECK(!parse_scan_plan("36,36", out, errs), "duplicate reported"); + CHECK(!parse_scan_plan("36,38/40,44:x,52/33", out, errs), + "malformed tokens reported"); + CHECK(errs.size() == 3, "every bad token reported"); + CHECK(out.size() == 1 && out[0].primary == 36, "good token survives"); + CHECK(!parse_scan_plan("", out, errs), "empty plan rejected"); + CHECK(!parse_scan_plan("6/40", out, errs), "ambiguous 2.4 pair reported"); + } + + /* --- to_selected adapter --- */ + { + ChannelDef d = mk(5, 104, CHANNEL_WIDTH_40); + norm(d); + const SelectedChannel s = d.to_selected(); + CHECK(s.Channel == 104 && s.ChannelOffset == 2 && + s.ChannelWidth == CHANNEL_WIDTH_40, + "SelectedChannel mapping"); + } + + return fails ? 1 : 0; +} diff --git a/tests/chan_score_selftest.cpp b/tests/chan_score_selftest.cpp new file mode 100644 index 0000000..47f0898 --- /dev/null +++ b/tests/chan_score_selftest.cpp @@ -0,0 +1,359 @@ +/* RecommendEngine offline-scenario selftest — the #277 deliverable gate. + * + * Each of the issue's ten offline scenarios is a deterministic synthetic + * trace fed through the shipping engine, asserting the exact decision kind + + * reason code (and generation). Plus the cross-cutting edges: age boundary, + * cooldown latch, generation monotonicity, deterministic tie-break, and the + * policy-file parse + hash stability. */ +#include "chanmig/ChannelScore.h" + +#include +#include +#include +#include + +static int fails; +#define CHECK(x, msg) \ + do { \ + if (!(x)) { \ + std::fprintf(stderr, "FAIL: %s\n", msg); \ + ++fails; \ + } \ + } while (0) + +using namespace devourer; +using namespace devourer::chanmig; + +static constexpr uint32_t kPlan = 0x5c0a7d01; +static constexpr int kObs = 200; /* ms per synthetic dwell */ + +static std::vector parse(const char *spec) { + std::vector out; + std::vector errs; + parse_scan_plan(spec, out, errs); + return out; +} + +/* Feed n dwells for one 20 MHz bin at occupancy `occ` (via foreign airtime), + * spaced 200 ms from base_t, cycling rounds 1..4. */ +static void feed_bin(RecommendEngine &e, uint8_t bin, int64_t base_t, + double occ, int n = 20, uint32_t scout = 1) { + for (int i = 0; i < n; i++) { + SurveyDwell d; + d.def.band = bin <= 14 ? 2 : 5; + d.def.primary = bin; + d.def.width = CHANNEL_WIDTH_20; + d.plan_hash = kPlan; + d.observe_ms = kObs; + d.t_start_ms = base_t + i * 200; + d.t_end_ms = d.t_start_ms + kObs; + d.valid_fa = true; + d.round = 1 + (i % 4); + d.scout_id = scout; + d.oth_air_us = static_cast(occ * kObs * 1000.0); + e.ingest_dwell(d, d.t_end_ms); + } +} + +/* Feed a bursty bin: mostly clean with occasional strong bursts (low median, + * high q90) so burstiness triggers without median occupancy. */ +static void feed_bin_bursty(RecommendEngine &e, uint8_t bin, int64_t base_t, + int n = 40) { + for (int i = 0; i < n; i++) { + const double occ = (i % 5 == 0) ? 0.9 : 0.0; /* 1-in-5 burst */ + SurveyDwell d; + d.def.band = 5; + d.def.primary = bin; + d.def.width = CHANNEL_WIDTH_20; + d.plan_hash = kPlan; + d.observe_ms = kObs; + d.t_start_ms = base_t + i * 100; + d.t_end_ms = d.t_start_ms + kObs; + d.valid_fa = true; + d.round = 1 + (i % 4); + d.scout_id = 1; + d.oth_air_us = static_cast(occ * kObs * 1000.0); + e.ingest_dwell(d, d.t_end_ms); + } +} + +static ActiveLinkWindow active_win(int64_t t, LinkVerdict v, bool impaired) { + ActiveLinkWindow w; + w.t_ms = t; + w.telemetry_ok = true; + w.have_quality = true; + w.verdict = v; + w.rssi_max_dbm = -55; + w.snr_mean_db = 20; + w.evm_mean_db = -50; + w.have_delivery = true; + w.expected = 100; + w.delivered = impaired ? 70 : 100; /* 30% loss when impaired */ + return w; +} + +static void feed_active(RecommendEngine &e, LinkVerdict v, bool impaired, + int n = 8, int64_t t0 = 100) { + for (int i = 0; i < n; i++) + e.ingest_active(active_win(t0 + i * 100, v, impaired)); +} + +static const char *kind(const Decision &d) { + return d.kind == Decision::Kind::Recommend ? "Recommend" : "Hold"; +} + +int main() { + PolicyConfig pol; /* defaults */ + + /* Scenario 1 — persistent narrowband interferer, one clean candidate. */ + { + RecommendEngine e(pol, parse("36,149"), kPlan, + ChannelDef{5, 60, CHANNEL_WIDTH_20, 0}); + feed_bin(e, 36, 1000, 0.0); /* clean */ + feed_bin(e, 149, 1000, 0.6); /* interfered */ + feed_active(e, LinkVerdict::Marginal, /*impaired=*/true); + Decision d = e.decide(6000); + CHECK(d.kind == Decision::Kind::Recommend, "S1 recommends"); + CHECK(d.primary_reason == Reason::RecommendBetterCandidate, "S1 reason"); + CHECK(d.target.primary == 36, "S1 targets the clean candidate"); + CHECK(d.ranking.front().def.primary == 36, "S1 ranks clean first"); + CHECK(d.evidence_gen > 0, "S1 cites an evidence generation"); + } + + /* Scenario 2 — bursts misaligned with dwells never flap to a move. */ + { + RecommendEngine e(pol, parse("36"), kPlan, + ChannelDef{5, 60, CHANNEL_WIDTH_20, 0}); + feed_bin_bursty(e, 36, 1000); + feed_active(e, LinkVerdict::Marginal, true); + Decision d = e.decide(6000); + CHECK(d.kind == Decision::Kind::Hold, "S2 holds on a bursty candidate"); + CHECK(d.ranking.front().n_rejections > 0, "S2 candidate rejected"); + bool bursty = false; + for (int i = 0; i < d.ranking.front().n_rejections; i++) + bursty = bursty || d.ranking.front().rejections[i] == Reason::RejBursty; + CHECK(bursty, "S2 rejection is burstiness"); + /* Re-deciding with the same evidence is stable (no flap). */ + Decision d2 = e.decide(6100); + CHECK(d2.kind == Decision::Kind::Hold, "S2 stays held"); + } + + /* Scenario 3 — wanted video dominating CCA but delivering: hold, healthy. */ + { + RecommendEngine e(pol, parse("36,149"), kPlan, + ChannelDef{5, 60, CHANNEL_WIDTH_20, 0}); + feed_bin(e, 36, 1000, 0.0); + feed_bin(e, 149, 1000, 0.0); + feed_active(e, LinkVerdict::Healthy, /*impaired=*/false); + Decision d = e.decide(6000); + CHECK(d.kind == Decision::Kind::Hold, "S3 holds"); + CHECK(d.primary_reason == Reason::HoldActiveHealthy, "S3 healthy hold"); + } + + /* Scenario 4 — weak wanted signal must not read as congestion. */ + { + RecommendEngine e(pol, parse("36,149"), kPlan, + ChannelDef{5, 60, CHANNEL_WIDTH_20, 0}); + feed_bin(e, 36, 1000, 0.0); + feed_bin(e, 149, 1000, 0.0); + feed_active(e, LinkVerdict::Weak, /*impaired=*/true); + Decision d = e.decide(6000); + CHECK(d.kind == Decision::Kind::Hold, "S4 holds"); + CHECK(d.primary_reason == Reason::HoldImpairmentNotChannel, + "S4 weak != congestion"); + } + + /* Scenario 5 — near-field saturation: back power off, do not move. */ + { + RecommendEngine e(pol, parse("36,149"), kPlan, + ChannelDef{5, 60, CHANNEL_WIDTH_20, 0}); + feed_bin(e, 36, 1000, 0.0); + feed_bin(e, 149, 1000, 0.0); + feed_active(e, LinkVerdict::Saturated, true); + Decision d = e.decide(6000); + CHECK(d.kind == Decision::Kind::Hold, "S5 holds"); + CHECK(d.primary_reason == Reason::HoldImpairmentNotChannel, + "S5 saturation != channel"); + } + + /* Scenario 6 — adjacent/overlap penalty orders a clean-but-adjacent + * candidate below a clean-and-clear one. active=ch40, so ch36 is adjacent. */ + { + RecommendEngine e(pol, parse("36,149"), kPlan, + ChannelDef{5, 40, CHANNEL_WIDTH_20, 0}); + feed_bin(e, 36, 1000, 0.0); /* adjacent to active ch40 */ + feed_bin(e, 149, 1000, 0.0); /* far and clear */ + feed_active(e, LinkVerdict::Marginal, true); + Decision d = e.decide(6000); + CHECK(d.ranking.front().def.primary == 149, + "S6 far candidate outranks the adjacent one"); + CHECK(d.ranking.front().overlap_pen == 0.0 && + d.ranking.back().overlap_pen > 0.0, + "S6 overlap penalty applied to the adjacent candidate"); + CHECK(d.kind == Decision::Kind::Recommend && d.target.primary == 149, + "S6 recommends the clear candidate"); + } + + /* Scenario 7 — every candidate degrades together: broad, not channel-local. */ + { + RecommendEngine e(pol, parse("36,149"), kPlan, + ChannelDef{5, 60, CHANNEL_WIDTH_20, 0}); + feed_bin(e, 36, 1000, 0.6); + feed_bin(e, 149, 1000, 0.7); + feed_active(e, LinkVerdict::Interference, true); + Decision d = e.decide(6000); + CHECK(d.kind == Decision::Kind::Hold, "S7 holds"); + CHECK(d.primary_reason == Reason::HoldBroadDegradation, "S7 broad"); + } + + /* Scenario 8 — rank alternation + cooldown: a recommendation rate-limits + * the next, so two near-equal candidates cannot churn. */ + { + RecommendEngine e(pol, parse("36,149"), kPlan, + ChannelDef{5, 60, CHANNEL_WIDTH_20, 0}); + feed_bin(e, 36, 1000, 0.0); + feed_bin(e, 149, 1000, 0.02); + feed_active(e, LinkVerdict::Marginal, true); + Decision d1 = e.decide(6000); + CHECK(d1.kind == Decision::Kind::Recommend, "S8 first recommends"); + /* Make 149 marginally better and re-decide immediately. */ + feed_bin(e, 149, 6200, 0.0, 4); + Decision d2 = e.decide(6300); + CHECK(d2.kind == Decision::Kind::Hold && + d2.primary_reason == Reason::HoldCooldown, + "S8 cooldown suppresses the churn"); + } + + /* Scenario 9 — a clean candidate goes stale after the interferer moved on. */ + { + RecommendEngine e(pol, parse("36,149"), kPlan, + ChannelDef{5, 60, CHANNEL_WIDTH_20, 0}); + feed_bin(e, 36, 1000, 0.0); /* observed long ago */ + feed_bin(e, 149, 1000, 0.6); + feed_active(e, LinkVerdict::Marginal, true, 8, 90000); + /* Decide far in the future: ch36 evidence is now beyond max age. */ + Decision d = e.decide(1000 + pol.max_evidence_age_ms + 5000); + CHECK(d.kind == Decision::Kind::Hold, "S9 holds on stale evidence"); + CHECK(d.primary_reason == Reason::HoldNoQualifiedCandidate, + "S9 no fresh qualified candidate"); + bool stale = false; + for (const CandidateScore &c : d.ranking) + for (int i = 0; i < c.n_rejections; i++) + stale = stale || c.rejections[i] == Reason::RejEvidenceStale; + CHECK(stale, "S9 candidate rejected as stale"); + } + + /* Scenario 10 — scout disconnect: candidate evidence untrusted. */ + { + RecommendEngine e(pol, parse("36,149"), kPlan, + ChannelDef{5, 60, CHANNEL_WIDTH_20, 0}); + feed_bin(e, 36, 1000, 0.0); + feed_bin(e, 149, 1000, 0.0); + feed_active(e, LinkVerdict::Marginal, true); + e.note_scout_health(false); + Decision d = e.decide(6000); + CHECK(d.kind == Decision::Kind::Hold, "S10 holds"); + CHECK(d.primary_reason == Reason::HoldScoutUnhealthy, "S10 scout unhealthy"); + } + + /* Edge — telemetry down forces a hold regardless of scout evidence. */ + { + RecommendEngine e(pol, parse("36"), kPlan, + ChannelDef{5, 60, CHANNEL_WIDTH_20, 0}); + feed_bin(e, 36, 1000, 0.0); + ActiveLinkWindow w; + w.t_ms = 500; + w.telemetry_ok = false; + e.ingest_active(w); + Decision d = e.decide(6000); + CHECK(d.primary_reason == Reason::HoldPrimaryTelemetryStale, + "telemetry-down hold"); + } + + /* Edge — impairment not yet persistent: impaired but under the window + * count. */ + { + RecommendEngine e(pol, parse("36"), kPlan, + ChannelDef{5, 60, CHANNEL_WIDTH_20, 0}); + feed_bin(e, 36, 1000, 0.0); + feed_active(e, LinkVerdict::Marginal, true, /*n=*/3); /* < 5 */ + Decision d = e.decide(6000); + CHECK(d.primary_reason == Reason::HoldImpairmentNotPersistent, + "sub-threshold impairment holds"); + } + + /* Edge — generation is monotone across folds; decisions cite the latest. */ + { + RecommendEngine e(pol, parse("36"), kPlan, + ChannelDef{5, 60, CHANNEL_WIDTH_20, 0}); + const uint64_t g0 = e.generation(); + feed_bin(e, 36, 1000, 0.0, 5); + const uint64_t g1 = e.generation(); + CHECK(g1 == g0 + 5, "generation increments per accepted fold"); + Decision d = e.decide(6000); + CHECK(d.evidence_gen == g1, "decision cites the current generation"); + } + + /* Edge — deterministic tie-break: two identically-clean candidates rank by + * ascending RF key, stably across runs. */ + { + auto run = []() { + PolicyConfig p; + RecommendEngine e(p, parse("36,149"), kPlan, + ChannelDef{5, 60, CHANNEL_WIDTH_20, 0}); + feed_bin(e, 36, 1000, 0.0); + feed_bin(e, 149, 1000, 0.0); + feed_active(e, LinkVerdict::Marginal, true); + return e.decide(6000).target.primary; + }; + CHECK(run() == run(), "tie-break deterministic"); + CHECK(run() == 36, "tie-break by ascending key"); + } + + /* Edge — improvement margin: a barely-clean candidate (just under the + * occupancy floor) holds rather than triggering a whole-link move. */ + { + PolicyConfig p; + p.qualify_max_occupancy = 0.20; /* qualify floor = score 0.80 */ + p.improvement_margin = 0.05; /* recommend needs score >= 0.85 */ + RecommendEngine e(p, parse("36"), kPlan, + ChannelDef{5, 60, CHANNEL_WIDTH_20, 0}); + feed_bin(e, 36, 1000, 0.18); /* qualifies (occ 0.18<=0.20) but score ~0.82 */ + feed_active(e, LinkVerdict::Marginal, true); + Decision d = e.decide(6000); + CHECK(d.primary_reason == Reason::HoldImprovementMargin, + "barely-clean candidate held by the margin"); + } + + /* Edge — policy-file parse + hash stability. */ + { + /* A relative path in the CWD (the build dir under ctest) — portable, unlike + * a hardcoded /tmp which doesn't exist on Windows. */ + const char *path = "chanmig_policy_selftest.tmp"; + std::FILE *f = std::fopen(path, "w"); + CHECK(f != nullptr, "policy scratch file opens"); + if (f == nullptr) + return fails ? 1 : 0; + std::fprintf(f, "# test policy\nimpaired_windows_min 8\n" + "min_rounds 5\nimprovement_margin 0.4\n" + "qualify_max_occupancy 0.1\n"); + std::fclose(f); + PolicyConfig a; + std::string err; + CHECK(parse_policy_file(path, a, &err), "policy file parses"); + CHECK(a.impaired_windows_min == 8 && a.min_rounds == 5, "values loaded"); + CHECK(a.improvement_margin == 0.4 && a.qualify_max_occupancy == 0.1, + "float values loaded"); + PolicyConfig b; + parse_policy_file(path, b, nullptr); + CHECK(a.policy_hash() == b.policy_hash(), "policy hash stable"); + PolicyConfig def; + CHECK(a.policy_hash() != def.policy_hash(), "hash sensitive to values"); + PolicyConfig missing; + CHECK(!parse_policy_file("/nonexistent/policy", missing, &err), + "missing file reported"); + std::remove(path); + } + + return fails ? 1 : 0; +} diff --git a/tests/chanmig_bench.sh b/tests/chanmig_bench.sh new file mode 100755 index 0000000..5aa96bf --- /dev/null +++ b/tests/chanmig_bench.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# chanmig first-light + fault-injection bench (issue #278 progression 1-5). +# +# Phase 1 a handful of clean operator-approved migrations (first light): +# measures the PeerClock residual + per-cycle outage. +# Phase 2 fault injection: deterministic message drops via DEVOURER_MIG_DROP +# (the demo's send-seam filter) must still converge — never split. +# Phase 3 bad destination: an interferer/illegal target forces the drone to +# roll back both endpoints to the source within a bounded time. +# +# The failure-injection MECHANISM is a one-choke-point counter filter in the +# demo (DEVOURER_MIG_DROP=type:spec), reviewable and deterministic — it leaves +# the real air path intact for everything not dropped, unlike an on-air MITM. +# +# Usage: sudo -v && tests/chanmig_bench.sh +# PHASE=2 tests/chanmig_bench.sh +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${CHANMIG_OUT:-/tmp/devourer-chanmig-bench}" +VID="${VID:-0x0bda}" +GROUND_PID="${GROUND_PID:-0x8812}" +DRONE_PID="${DRONE_PID:-0xc812}" +CHAN_A="${CHAN_A:-36}" +CHAN_B="${CHAN_B:-149}" +KEY="${KEY:-c0ffeef00d}" +TX_PWR="${TX_PWR:-12}" +PHASE="${PHASE:-all}" +mkdir -p "$OUT" + +plugged() { lsusb -d "$(printf '%04x:%04x' "$1" "$2")" >/dev/null 2>&1; } +plugged "$VID" "$GROUND_PID" || { echo "SKIP: ground not plugged"; exit 77; } +plugged "$VID" "$DRONE_PID" || { echo "SKIP: drone not plugged"; exit 77; } + +drone_pid="" +cleanup() { [ -n "$drone_pid" ] && { sudo kill -- -"$drone_pid" 2>/dev/null; wait "$drone_pid" 2>/dev/null; }; true; } +trap cleanup EXIT INT TERM + +unbind() { + local pid="$1" d p i + for d in /sys/bus/usb/devices/*/idProduct; do + p=$(cat "$d" 2>/dev/null) || continue; [ "$p" = "${pid#0x}" ] || continue + for i in "$(dirname "$d")":*; do + [ -e "$i/driver" ] && sudo sh -c "echo '$(basename "$i")' > '$i/driver/unbind'" 2>/dev/null || true + done + done +} + +echo "== build =="; cmake --build "$ROOT/build" -j --target chanmig >/dev/null || exit 1 +unbind "$GROUND_PID"; unbind "$DRONE_PID" + +# One run: drone (background) + ground driven by N alternating proposals. +# $1=tag $2=cycles $3=drop-spec (drone) $4=allowed(ground-target legality) +run_migrations() { + local tag="$1" cycles="$2" drop="$3" allowed="${4:-$CHAN_A,$CHAN_B}" + echo "-- $tag: $cycles migrations, drop='$drop' allowed='$allowed' --" + sudo env DEVOURER_MIG_ROLE=drone DEVOURER_VID="$VID" DEVOURER_PID="$DRONE_PID" \ + DEVOURER_CHANNEL="$CHAN_A" DEVOURER_MIG_KEY="$KEY" DEVOURER_TX_PWR="$TX_PWR" \ + DEVOURER_MIG_ALLOWED="$allowed" DEVOURER_MIG_SYNTH_PPS=200 \ + DEVOURER_MIG_DROP="$drop" DEVOURER_LOG_LEVEL=warn \ + setsid "$ROOT/build/chanmig" --role drone >"$OUT/drone-$tag.jsonl" 2>/dev/null & + drone_pid=$! + sleep 4 + # drive the ground: alternate proposals, read confirmations + { + cur="$CHAN_B" + for i in $(seq 1 "$cycles"); do + echo "propose $cur" + sleep 2 + [ "$cur" = "$CHAN_B" ] && cur="$CHAN_A" || cur="$CHAN_B" + done + sleep 2 + } | sudo env DEVOURER_MIG_ROLE=ground DEVOURER_VID="$VID" DEVOURER_PID="$GROUND_PID" \ + DEVOURER_CHANNEL="$CHAN_A" DEVOURER_MIG_KEY="$KEY" DEVOURER_TX_PWR="$TX_PWR" \ + DEVOURER_MIG_RESCUE="$CHAN_A" DEVOURER_LOG_LEVEL=warn \ + timeout $((cycles * 3 + 20)) "$ROOT/build/chanmig" --role ground \ + >"$OUT/ground-$tag.jsonl" 2>/dev/null + cleanup; drone_pid="" + python3 - "$OUT/ground-$tag.jsonl" "$tag" <<'PYEOF' +import json,sys +path,tag=sys.argv[1],sys.argv[2] +done={"conf":0,"rb":0} +states=[] +for l in open(path,errors="replace"): + if '"ev":"migrate.done"' in l: + try: c=json.loads(l).get("code"); done["conf" if c==0 else "rb"]+=1 + except: pass +print(f" [{tag}] confirmed={done['conf']} rolled_back/held={done['rb']}") +PYEOF +} + +case "$PHASE" in + 1|all) run_migrations "phase1-clean" 6 "" ;; +esac +case "$PHASE" in + 2|all) + run_migrations "phase2-drop-commit" 4 "2:every:3" # drop every 3rd commit + run_migrations "phase2-drop-status" 4 "3:every:4" ;; # drop periodic status +esac +case "$PHASE" in + 3|all) + # bad/illegal destination: the drone's allowed list excludes CHAN_B, so a + # proposal to it must be rejected and both ends stay on CHAN_A. + run_migrations "phase3-illegal-dest" 3 "" "$CHAN_A" ;; +esac +echo "logs: $OUT" diff --git a/tests/chanmig_clock_selftest.cpp b/tests/chanmig_clock_selftest.cpp new file mode 100644 index 0000000..d4270d6 --- /dev/null +++ b/tests/chanmig_clock_selftest.cpp @@ -0,0 +1,58 @@ +/* PeerClock selftest — the ground's estimate of the drone's clock + the guard. + * Feeds synthetic (drone tx_tsf, local RX tsfl) pairs with a known skew and a + * bounded software-stamp jitter, and checks the fit recovers the skew, the + * residual percentiles bound the jitter, and the guard composes measured + * numbers (never a fixed guess). */ +#include "chanmig/MigClock.h" + +#include + +static int fails; +#define CHECK(x, msg) \ + do { \ + if (!(x)) { \ + std::fprintf(stderr, "FAIL: %s\n", msg); \ + ++fails; \ + } \ + } while (0) + +using devourer::chanmig::PeerClock; + +int main() { + PeerClock pc; + /* drone runs 20 ppm fast relative to the ground; each status frame carries + * a software-stamped tx_tsf whose air latency adds a small jitter. */ + const double skew = 1.0 + 20e-6; + uint64_t remote = 5000000; /* drone TSF */ + int64_t local = 5000000; /* ground local time base */ + /* deterministic pseudo-jitter (no RNG in tests): a bounded sawtooth */ + const int jit[8] = {40, 120, 60, 300, 80, 1700, 50, 200}; /* µs */ + for (int i = 0; i < 200; i++) { + remote += 100000; /* ~10 Hz status */ + local = static_cast(5000000 + (remote - 5000000) * skew); + const int64_t observed = local + jit[i % 8]; + pc.add(remote, static_cast(observed)); + } + CHECK(pc.ready(), "clock ready after enough samples"); + CHECK(pc.samples() >= 200, "sample count tracked"); + /* skew recovered within a few ppm */ + const double ppm = pc.skew_ppm(); + CHECK(ppm > 10 && ppm < 30, "skew recovered near 20 ppm"); + /* residual percentiles bound the injected jitter (max 1700 µs) */ + const int64_t p50 = pc.residual_p50(), p99 = pc.residual_p99(); + CHECK(p50 >= 0 && p50 <= 400, "residual p50 within the jitter body"); + CHECK(p99 <= 2000, "residual p99 bounds the tail"); + CHECK(p99 >= p50, "p99 >= p50"); + /* guard composes measured residual + drain + retune + margin */ + const int64_t guard = pc.guard_us(3000, 2500, 2000); + CHECK(guard >= p99 + 3000 + 2500 + 2000 - 1, "guard includes all measured terms"); + CHECK(guard > 7000, "guard is a real multi-ms budget"); + + /* An un-fed clock falls back to a safe floor, never a zero guard. */ + PeerClock empty; + CHECK(!empty.ready(), "empty clock not ready"); + CHECK(empty.guard_us(3000, 2500, 2000) >= 3000 + 2500 + 2000, + "empty clock guard uses the residual floor"); + + return fails ? 1 : 0; +} diff --git a/tests/chanmig_endurance.py b/tests/chanmig_endurance.py new file mode 100755 index 0000000..e51d98f --- /dev/null +++ b/tests/chanmig_endurance.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""chanmig endurance driver — >=1000 old<->new<->old migrations, measuring +per-cycle video outage and asserting zero persistent split-brain. + +Launches the drone (video TX + responder) and the ground (primary RX + +proposer) as subprocesses, then drives the ground's operator stdin: on each +`migrate.done code=0` (confirmed), it proposes the other channel. Outage is +the ground's video gap around each switch (first-new-frame minus last-old), +reconstructed from the ground's migrate.retune / migrate.state stream and the +drone's video cadence. + +Run via tests/chanmig_endurance.sh (sets up adapters + power). Standalone: + sudo python3 tests/chanmig_endurance.py --cycles 1000 \ + --ground-pid 0x8812 --drone-pid 0xc812 --chan-a 36 --chan-b 149 \ + --key deadbeef --tx-pwr 12 +""" +import argparse +import json +import os +import signal +import subprocess +import sys +import time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def launch(role, pid, chan, args, extra_env): + env = dict(os.environ) + env.update({ + "DEVOURER_MIG_ROLE": role, + "DEVOURER_VID": args.vid, + "DEVOURER_PID": pid, + "DEVOURER_CHANNEL": str(chan), + "DEVOURER_MIG_KEY": args.key, + "DEVOURER_MIG_RESCUE": str(args.chan_a), + "DEVOURER_LOG_LEVEL": "warn", + "DEVOURER_TX_PWR": str(args.tx_pwr), + }) + env.update(extra_env) + return subprocess.Popen( + ["sudo", "-E", os.path.join(ROOT, "build", "chanmig"), + "--role", role], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, env=env, bufsize=1, + universal_newlines=True, preexec_fn=os.setsid) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--cycles", type=int, default=1000) + ap.add_argument("--vid", default="0x0bda") + ap.add_argument("--ground-pid", default="0x8812") + ap.add_argument("--drone-pid", default="0xc812") + ap.add_argument("--chan-a", type=int, default=36) + ap.add_argument("--chan-b", type=int, default=149) + ap.add_argument("--key", default="c0ffee") + ap.add_argument("--tx-pwr", type=int, default=12) + ap.add_argument("--allowed", default=None) + ap.add_argument("--timeout", type=int, default=3600) + args = ap.parse_args() + + allowed = args.allowed or f"{args.chan_a},{args.chan_b}" + procs = [] + + def cleanup(*_): + for p in procs: + try: + os.killpg(os.getpgid(p.pid), signal.SIGTERM) + except (ProcessLookupError, PermissionError): + pass + time.sleep(1) + for p in procs: + try: + os.killpg(os.getpgid(p.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + + signal.signal(signal.SIGINT, lambda *_: (cleanup(), sys.exit(1))) + signal.signal(signal.SIGTERM, lambda *_: (cleanup(), sys.exit(1))) + + drone = launch("drone", args.drone_pid, args.chan_a, args, + {"DEVOURER_MIG_ALLOWED": allowed, + "DEVOURER_MIG_SYNTH_PPS": "200"}) + procs.append(drone) + time.sleep(4) # drone bring-up + ground = launch("ground", args.ground_pid, args.chan_a, args, {}) + procs.append(ground) + time.sleep(4) # ground bring-up + + confirmed = 0 + rolled_back = 0 + split = 0 + cur = args.chan_b # first proposal target + outages = [] + t_switch = None + last_state = None + deadline = time.time() + args.timeout + + def propose(ch): + try: + ground.stdin.write(f"propose {ch}\n") + ground.stdin.flush() + except (BrokenPipeError, ValueError): + pass + + propose(cur) + t_switch = time.time() + + while confirmed < args.cycles and time.time() < deadline: + line = ground.stdout.readline() + if not line: + if ground.poll() is not None: + print("ground exited early", file=sys.stderr) + break + continue + if '"ev":"' not in line: + continue + try: + ev = json.loads(line) + except json.JSONDecodeError: + continue + e = ev.get("ev") + if e == "migrate.done": + code = ev.get("code") + if code == 0: # confirmed on the new channel + confirmed += 1 + if t_switch is not None: + outages.append(time.time() - t_switch) + cur = args.chan_a if cur == args.chan_b else args.chan_b + if confirmed % 50 == 0: + p = sorted(outages) + print(f" {confirmed}/{args.cycles} migrations, " + f"outage p50={p[len(p)//2]*1000:.0f}ms " + f"p90={p[int(len(p)*0.9)]*1000:.0f}ms") + time.sleep(0.3) # let both settle on the new channel + propose(cur) + t_switch = time.time() + elif code == 1: # rolled back / held on old + rolled_back += 1 + time.sleep(0.5) + propose(cur) # retry + t_switch = time.time() + + cleanup() + + ok = confirmed >= args.cycles and split == 0 + p = sorted(outages) if outages else [0] + print(f"\nconfirmed={confirmed} rolled_back={rolled_back} split_brain={split}") + if outages: + print(f"outage p50={p[len(p)//2]*1000:.0f}ms " + f"p90={p[int(len(p)*0.9)]*1000:.0f}ms " + f"p99={p[min(len(p)-1,int(len(p)*0.99))]*1000:.0f}ms") + print("PASS" if ok else "INCOMPLETE") + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/chanmig_endurance.sh b/tests/chanmig_endurance.sh new file mode 100755 index 0000000..a7b05b7 --- /dev/null +++ b/tests/chanmig_endurance.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# chanmig >=1000-cycle migration endurance (issue #278 acceptance gate). +# +# Two duplex adapters: a drone (video TX + responder) and a ground (primary RX +# + proposer). The python controller drives the ground's operator stdin, +# alternating propose A/B on each confirmed migration, and reports outage +# percentiles + zero persistent split-brain. +# +# Both ends are J1/J3 (duplex + AckResponder GO). TX power is reduced to keep +# the near-field rig link out of front-end saturation so markers/video deliver. +# +# Usage: sudo -v && tests/chanmig_endurance.sh +# CYCLES=1000 GROUND_PID=0x8812 DRONE_PID=0xc812 tests/chanmig_endurance.sh +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CYCLES="${CYCLES:-1000}" +VID="${VID:-0x0bda}" +GROUND_PID="${GROUND_PID:-0x8812}" # RTL8812AU (J1) duplex ground +DRONE_PID="${DRONE_PID:-0xc812}" # RTL8812CU (J3) duplex drone +CHAN_A="${CHAN_A:-36}" +CHAN_B="${CHAN_B:-149}" +KEY="${KEY:-c0ffeef00d}" +TX_PWR="${TX_PWR:-12}" + +plugged() { lsusb -d "$(printf '%04x:%04x' "$1" "$2")" >/dev/null 2>&1; } +plugged "$VID" "$GROUND_PID" || { echo "SKIP: ground $VID:$GROUND_PID not plugged"; exit 77; } +plugged "$VID" "$DRONE_PID" || { echo "SKIP: drone $VID:$DRONE_PID not plugged"; exit 77; } + +# In-tree rtw88/rtw89 auto-probe the dongles; temp-unbind any they hold. +unbind() { + local pid="$1" d p i + for d in /sys/bus/usb/devices/*/idProduct; do + p=$(cat "$d" 2>/dev/null) || continue + [ "$p" = "${pid#0x}" ] || continue + for i in "$(dirname "$d")":*; do + [ -e "$i/driver" ] && sudo sh -c "echo '$(basename "$i")' > '$i/driver/unbind'" 2>/dev/null || true + done + done +} + +echo "== build ==" +cmake --build "$ROOT/build" -j --target chanmig >/dev/null || exit 1 +unbind "$GROUND_PID"; unbind "$DRONE_PID" + +echo "== $CYCLES-cycle endurance: ground $GROUND_PID <-> drone $DRONE_PID, ch$CHAN_A<->ch$CHAN_B ==" +uv run --project "$ROOT/tests" python3 "$ROOT/tests/chanmig_endurance.py" \ + --cycles "$CYCLES" --vid "$VID" \ + --ground-pid "$GROUND_PID" --drone-pid "$DRONE_PID" \ + --chan-a "$CHAN_A" --chan-b "$CHAN_B" --key "$KEY" --tx-pwr "$TX_PWR" 2>&1 || + { echo "endurance run failed/incomplete"; exit 1; } diff --git a/tests/chanmig_gate_onair.sh b/tests/chanmig_gate_onair.sh new file mode 100755 index 0000000..9cc3ffc --- /dev/null +++ b/tests/chanmig_gate_onair.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# chanmig autonomous-gate on-air ladder (issue #279). +# +# The full stack on three adapters + an optional B210 interferer: +# drone : video TX + responder (RTL8812CU, J3) +# ground : primary RX + proposer + the automation gate (RTL8812AU, J1), +# automatic mode, tailing the scout's channel.recommend feed +# scout : chanscout advise mode, tailing the ground's primary telemetry, +# emitting channel.recommend to the ground's feed (T3U, J2) +# +# Ladder rungs (RUNG env, default all): +# shadow : gate in automatic mode with DEVOURER_MIG_SHADOW — decides but +# never actuates; count migrate.shadow proposals. +# migrate : real automatic migration under a B210 interferer on the source. +# hold : clean spectrum — the gate must NOT propose (false-move rate). +# +# Usage: sudo -v && tests/chanmig_gate_onair.sh +# RUNG=shadow tests/chanmig_gate_onair.sh +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${CHANMIG_OUT:-/tmp/devourer-chanmig-gate}" +VID="${VID:-0x0bda}" +DRONE_PID="${DRONE_PID:-0xc812}" +GROUND_PID="${GROUND_PID:-0x8812}" +SCOUT_VID="${SCOUT_VID:-0x2357}" SCOUT_PID="${SCOUT_PID:-0x012d}" +CHAN_A="${CHAN_A:-36}" # source (live) channel +CHAN_B="${CHAN_B:-149}" # migration target +KEY="${KEY:-c0ffeef00d}" +TX_PWR="${TX_PWR:-12}" +DUR="${DUR:-120}" +RUNG="${RUNG:-all}" +mkdir -p "$OUT" + +plugged() { lsusb -d "$(printf '%04x:%04x' "$1" "$2")" >/dev/null 2>&1; } +plugged "$VID" "$DRONE_PID" || { echo "SKIP: drone not plugged"; exit 77; } +plugged "$VID" "$GROUND_PID" || { echo "SKIP: ground not plugged"; exit 77; } +plugged "$SCOUT_VID" "$SCOUT_PID" || { echo "SKIP: scout not plugged"; exit 77; } + +pids=() +cleanup() { + for p in "${pids[@]:-}"; do [ -n "$p" ] && sudo kill -- -"$p" 2>/dev/null; done + "$ROOT/tests/sdr_interferer.py" --stop 2>/dev/null || true + true +} +trap cleanup EXIT INT TERM +unbind() { local pid="$1" d p i + for d in /sys/bus/usb/devices/*/idProduct; do p=$(cat "$d" 2>/dev/null)||continue + [ "$p" = "${pid#0x}" ]||continue + for i in "$(dirname "$d")":*; do [ -e "$i/driver" ]&&sudo sh -c "echo '$(basename "$i")'>'$i/driver/unbind'" 2>/dev/null||true; done; done; } + +echo "== build =="; cmake --build "$ROOT/build" -j --target chanmig chanscout >/dev/null || exit 1 +unbind "$DRONE_PID"; unbind "$GROUND_PID" + +# One ladder rung. $1=tag $2=mode $3=shadow(0/1) $4=interferer_ch("" = none) +run_rung() { + local tag="$1" mode="$2" shadow="$3" ich="${4:-}" + echo "-- rung $tag: mode=$mode shadow=$shadow interferer=${ich:-none} --" + local pf="$OUT/primary-$tag.jsonl" rf="$OUT/recommend-$tag.jsonl" + : >"$pf"; : >"$rf" + # drone + sudo env DEVOURER_MIG_ROLE=drone DEVOURER_VID="$VID" DEVOURER_PID="$DRONE_PID" \ + DEVOURER_CHANNEL="$CHAN_A" DEVOURER_MIG_KEY="$KEY" DEVOURER_TX_PWR="$TX_PWR" \ + DEVOURER_MIG_ALLOWED="$CHAN_A,$CHAN_B" DEVOURER_MIG_SYNTH_PPS=200 \ + DEVOURER_LOG_LEVEL=warn setsid "$ROOT/build/chanmig" --role drone \ + >"$OUT/drone-$tag.jsonl" 2>/dev/null & pids+=($!) + sleep 4 + # ground: primary RX + gate, feed = scout recommends, primary telemetry -> pf + local shenv=""; [ "$shadow" = 1 ] && shenv="DEVOURER_MIG_SHADOW=1" + sudo env DEVOURER_MIG_ROLE=ground DEVOURER_VID="$VID" DEVOURER_PID="$GROUND_PID" \ + DEVOURER_CHANNEL="$CHAN_A" DEVOURER_MIG_KEY="$KEY" DEVOURER_TX_PWR="$TX_PWR" \ + DEVOURER_MIG_RESCUE="$CHAN_A" DEVOURER_MIG_MODE="$mode" $shenv \ + DEVOURER_MIG_RECOMMEND_FEED="$rf" DEVOURER_RX_ENERGY_MS=500 \ + DEVOURER_RXQUALITY=1 DEVOURER_LINKHEALTH=1 DEVOURER_LOG_LEVEL=warn \ + setsid "$ROOT/build/chanmig" --role ground >"$pf" 2>/dev/null & pids+=($!) + sleep 3 + # scout: advise mode, primary feed = ground pf, recommends -> rf + sudo env DEVOURER_VID="$SCOUT_VID" DEVOURER_PID="$SCOUT_PID" \ + DEVOURER_SCOUT_PLAN="$CHAN_A,$CHAN_B" DEVOURER_SCOUT_ADVISE=1 \ + DEVOURER_SCOUT_ACTIVE="$CHAN_A" DEVOURER_SCOUT_PRIMARY_FEED="$pf" \ + DEVOURER_SCOUT_DWELL_MS=100 DEVOURER_LOG_LEVEL=warn \ + setsid "$ROOT/build/chanscout" >"$rf" 2>/dev/null & pids+=($!) + # optional B210 interferer on the source to drive an impairment + [ -n "$ich" ] && "$ROOT/tests/sdr_interferer.py" --channel "$ich" --gain 60 & + sleep "$DUR" + cleanup; pids=() + python3 - "$pf" "$tag" "$shadow" <<'PYEOF' +import json,sys +pf,tag,shadow=sys.argv[1],sys.argv[2],int(sys.argv[3]) +props=shadows=confirmed=rolled=holds=0 +for l in open(pf,errors="replace"): + if '"ev":"migrate.shadow"' in l: shadows+=1 + elif '"ev":"migrate.gate"' in l: + if '"verdict":1' in l: props+=1 # Propose + elif '"ev":"migrate.done"' in l: + try: + c=json.loads(l).get("code"); confirmed+=(c==0); rolled+=(c==1) + except: pass +print(f" [{tag}] gate_proposes={props} shadow_proposes={shadows} " + f"confirmed={confirmed} rolled_back={rolled}") +PYEOF +} + +case "$RUNG" in + shadow|all) run_rung shadow automatic 1 "$CHAN_A" ;; +esac +case "$RUNG" in + migrate|all) run_rung migrate automatic 0 "$CHAN_A" ;; +esac +case "$RUNG" in + hold|all) run_rung hold automatic 0 "" ;; # clean: must not propose +esac +echo "logs: $OUT" diff --git a/tests/chanmig_gate_selftest.cpp b/tests/chanmig_gate_selftest.cpp new file mode 100644 index 0000000..c2aa109 --- /dev/null +++ b/tests/chanmig_gate_selftest.cpp @@ -0,0 +1,269 @@ +/* MigGate policy selftest — the #279 deterministic scenario matrix. + * + * Thirteen named scenarios drive the gate through the issue's cases; each is + * a fixed input trace asserting the exact verdict + reason. Plus the + * determinism guarantee: every scenario is run twice and must produce the + * identical decision (no RNG, no clock reads). */ +#include "chanmig/MigGate.h" + +#include +#include + +static int fails; +#define CHECK(x, msg) \ + do { \ + if (!(x)) { \ + std::fprintf(stderr, "FAIL: %s\n", msg); \ + ++fails; \ + } \ + } while (0) + +using namespace devourer::chanmig; + +static ChannelDef ch(uint8_t p) { + ChannelDef d; + d.band = p <= 14 ? 2 : 5; + d.primary = p; + d.width = CHANNEL_WIDTH_20; + normalize(d); + return d; +} + +/* Build a Recommend decision for `target` with a given confidence + score. */ +static Decision recommend(const ChannelDef &target, double score, double conf, + uint64_t gen = 100) { + Decision d; + d.kind = Decision::Kind::Recommend; + d.primary_reason = Reason::RecommendBetterCandidate; + d.target = target; + d.evidence_gen = gen; + CandidateScore c; + c.def = target; + c.qualified = true; + c.score = score; + c.confidence = conf; + d.ranking.push_back(c); + return d; +} +static Decision hold_dec(Reason r) { + Decision d; + d.kind = Decision::Kind::Hold; + d.primary_reason = r; + return d; +} + +static GateInputs automatic(const Decision *rec) { + GateInputs in; + in.mode = MigMode::Automatic; + in.rec = rec; + in.telemetry_ok = true; + in.clock_synced = true; + in.scout_healthy = true; + in.rescue_verified = true; + in.control_link_margin = 1.0; + return in; +} + +int main() { + GatePolicy pol; + const ChannelDef tgt = ch(36), other = ch(149); + + /* run each scenario twice; the second run must match (determinism). */ + auto twice = [&](const char *name, GateInputs in, GateState st, + int64_t now, GateVerdict v, GateReason r) { + GateState s1 = st, s2 = st; + GateOutcome o1 = mig_gate_decide(in, s1, pol, now); + GateOutcome o2 = mig_gate_decide(in, s2, pol, now); + CHECK(o1.verdict == v && o1.reason == r, name); + CHECK(o1.verdict == o2.verdict && o1.reason == o2.reason, + "gate is deterministic"); + if (o1.reason != r) + std::fprintf(stderr, " [%s] got %s\n", name, gate_reason_name(o1.reason)); + }; + + /* 1. interferer appears: a fresh recommend with no cooldown -> Propose. */ + { + Decision d = recommend(tgt, 1.0, 0.95); + GateState st; + twice("interferer_appears", automatic(&d), st, 10000, + GateVerdict::Propose, GateReason::Propose); + } + /* 2. interferer persists: same recommend but a recent move -> Cooldown. */ + { + Decision d = recommend(tgt, 1.0, 0.95); + GateState st; + st.last_move_ms = 10000; + twice("interferer_persists", automatic(&d), st, 10000 + 60000, + GateVerdict::Hold, GateReason::Cooldown); + } + /* 3. interferer leaves: the advisory now holds (active healthy). */ + { + Decision d = hold_dec(Reason::HoldActiveHealthy); + GateState st; + twice("interferer_leaves", automatic(&d), st, 10000, GateVerdict::Hold, + GateReason::NotImpaired); + } + /* 4. burst straddle: a low-confidence recommend -> ConfidenceLow. */ + { + Decision d = recommend(tgt, 1.0, 0.6); + GateState st; + twice("burst_straddle", automatic(&d), st, 10000, GateVerdict::Hold, + GateReason::ConfidenceLow); + } + /* 5. herding: a channel under holddown backoff -> HolddownChannel. */ + { + Decision d = recommend(tgt, 1.0, 0.95); + GateState st; + st.set_holddown(tgt.key(), 500000); + twice("herding", automatic(&d), st, 100000, GateVerdict::Hold, + GateReason::HolddownChannel); + } + /* 6. rank alternation: cooldown suppresses the churn (as #2). */ + { + Decision d = recommend(other, 1.0, 0.95); + GateState st; + st.last_move_ms = 50000; + twice("rank_alternation", automatic(&d), st, 60000, GateVerdict::Hold, + GateReason::Cooldown); + } + /* 7. all degrade: the advisory returns broad-degradation hold. */ + { + Decision d = hold_dec(Reason::HoldBroadDegradation); + GateState st; + twice("all_degrade", automatic(&d), st, 10000, GateVerdict::Hold, + GateReason::FaultBroadband); + } + /* 8. out of range, clean spectrum: weak-link fault, do not move. */ + { + Decision d = hold_dec(Reason::HoldImpairmentNotChannel); + GateState st; + twice("out_of_range_clean", automatic(&d), st, 10000, GateVerdict::Hold, + GateReason::FaultWeakLink); + } + /* 9. saturation: same non-channel fault path. */ + { + Decision d = hold_dec(Reason::HoldImpairmentNotChannel); + GateState st; + twice("saturation", automatic(&d), st, 10000, GateVerdict::Hold, + GateReason::FaultWeakLink); + } + /* 10. scout stall: survey too old -> ScoutStale. */ + { + Decision d = recommend(tgt, 1.0, 0.95); + GateInputs in = automatic(&d); + in.scout_survey_age_ms = 90000; /* > 60 s */ + GateState st; + twice("scout_stall", in, st, 10000, GateVerdict::Hold, + GateReason::ScoutStale); + } + /* 11. current channel recovers pre-commit: in-flight + advisory now holds + * with the target unchanged but a new recommend absent -> stay in-flight; + * if the target CHANGES materially -> AbortInFlight. */ + { + Decision d = recommend(other, 1.0, 0.95); /* target moved off frozen tgt */ + GateInputs in = automatic(&d); + in.in_flight = true; + GateState st; + st.have_frozen = true; + st.frozen_gen = 100; + st.frozen_target = tgt; + st.frozen_score = 1.0; + twice("recover_pre_commit", in, st, 10000, GateVerdict::AbortInFlight, + GateReason::MaterialChange); + } + /* 12. destination occupied between recommend and activation: probation + * delivery bad -> ProposeRollback. */ + { + GateInputs in = automatic(nullptr); + in.probation_active = true; + in.probation_delivery_ok = false; + GateState st; + twice("dest_occupied_pre_activation", in, st, 10000, + GateVerdict::ProposeRollback, GateReason::ProbationRollback); + } + /* 13. rollback + move-cap exhaustion: cap reached -> MoveCap. */ + { + Decision d = recommend(tgt, 1.0, 0.95); + GateInputs in = automatic(&d); + GateState st; + st.moves_session = pol.move_cap; + twice("rollback_cap_exhaustion", in, st, 1000000, GateVerdict::Hold, + GateReason::MoveCap); + } + + /* --- mode + operator overrides --- */ + { + Decision d = recommend(tgt, 1.0, 0.95); + GateInputs off = automatic(&d); + off.mode = MigMode::Off; + GateState st; + twice("mode_off", off, st, 10000, GateVerdict::Hold, GateReason::ModeOff); + + GateInputs adv = automatic(&d); + adv.mode = MigMode::Advisory; + twice("mode_advisory", adv, st, 10000, GateVerdict::Hold, + GateReason::Advisory); + + GateInputs man = automatic(&d); + man.mode = MigMode::Manual; + twice("manual_needs_approval", man, st, 10000, GateVerdict::Hold, + GateReason::ApproveRequired); + man.approve_next = true; + twice("manual_approved", man, st, 10000, GateVerdict::Propose, + GateReason::Propose); + + GateInputs pin = automatic(&d); + pin.pinned = &tgt; + twice("pinned", pin, st, 10000, GateVerdict::Hold, GateReason::Pinned); + + GateInputs inh = automatic(&d); + inh.inhibit_until_ms = 20000; + twice("inhibited", inh, st, 10000, GateVerdict::Hold, + GateReason::Inhibited); + } + + /* --- residency, control margin, rescue, telemetry --- */ + { + Decision d = recommend(tgt, 1.0, 0.95); + GateState st; + st.residency_start_ms = 5000; + twice("residency", automatic(&d), st, 5000 + 60000, GateVerdict::Hold, + GateReason::Residency); + + GateInputs cm = automatic(&d); + cm.control_link_margin = 0.1; + GateState st2; + twice("control_margin_low", cm, st2, 500000, GateVerdict::Hold, + GateReason::ControlMarginLow); + + GateInputs nr = automatic(&d); + nr.rescue_verified = false; + twice("no_rescue_verified", nr, st2, 500000, GateVerdict::Hold, + GateReason::NoRescueVerified); + + GateInputs tel = automatic(&d); + tel.telemetry_ok = false; + twice("telemetry_unhealthy", tel, st2, 500000, GateVerdict::Hold, + GateReason::TelemetryUnhealthy); + } + + /* --- state callbacks: confirm resets streak + starts residency; rollback + * applies exponential holddown backoff --- */ + { + GateState st; + mig_gate_on_confirmed(st, tgt, 100000); + CHECK(st.moves_session == 1 && st.residency_start_ms == 100000 && + st.rollback_streak == 0, + "confirm updates move/residency/streak"); + mig_gate_on_rolledback(st, tgt, pol, 200000); + const int64_t hd1 = st.holddown_for(tgt.key()); + CHECK(st.rollback_streak == 1 && hd1 == 200000 + pol.holddown_base_ms, + "first rollback: base holddown"); + mig_gate_on_rolledback(st, tgt, pol, 300000); + const int64_t hd2 = st.holddown_for(tgt.key()); + CHECK(st.rollback_streak == 2 && hd2 == 300000 + 2 * pol.holddown_base_ms, + "second rollback: doubled backoff"); + } + + return fails ? 1 : 0; +} diff --git a/tests/chanmig_probe_outage.sh b/tests/chanmig_probe_outage.sh new file mode 100755 index 0000000..d065f1c --- /dev/null +++ b/tests/chanmig_probe_outage.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# chanmig variant-B probe outage + asymmetric-interference quantification (#280). +# +# Runs repeated automatic/manual migrations with the drone's single-radio +# pre-commit probe enabled (DEVOURER_MIG_PROBE=1) and measures the video +# outage each probe costs — the ground-side rx.frame gap around the drone's +# probe hop — plus the veto rate. An optional B210 parks on the target so a +# genuinely busy destination is vetoed; parking it at the drone-only vs +# ground-only tests the asymmetric false-veto case. +# +# The decision this feeds: whether the probe's prevented-bad-moves justify its +# measured outage, or whether variant A (checks only) + #279 probation is the +# better product answer (see docs/channel-migration-validation.md). +# +# Usage: sudo -v && tests/chanmig_probe_outage.sh +# PROBES=100 INTERFERE=target tests/chanmig_probe_outage.sh +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${CHANMIG_OUT:-/tmp/devourer-chanmig-probe}" +VID="${VID:-0x0bda}" +DRONE_PID="${DRONE_PID:-0xc812}" +GROUND_PID="${GROUND_PID:-0x8812}" +CHAN_A="${CHAN_A:-36}" +CHAN_B="${CHAN_B:-149}" +KEY="${KEY:-c0ffeef00d}" +TX_PWR="${TX_PWR:-12}" +PROBES="${PROBES:-50}" +INTERFERE="${INTERFERE:-none}" # none | target | source +mkdir -p "$OUT" + +plugged() { lsusb -d "$(printf '%04x:%04x' "$1" "$2")" >/dev/null 2>&1; } +plugged "$VID" "$DRONE_PID" || { echo "SKIP: drone not plugged"; exit 77; } +plugged "$VID" "$GROUND_PID" || { echo "SKIP: ground not plugged"; exit 77; } + +drone_pid="" +cleanup() { + [ -n "$drone_pid" ] && sudo kill -- -"$drone_pid" 2>/dev/null + "$ROOT/tests/sdr_interferer.py" --stop 2>/dev/null || true + true +} +trap cleanup EXIT INT TERM +unbind() { local pid="$1" d p i + for d in /sys/bus/usb/devices/*/idProduct; do p=$(cat "$d" 2>/dev/null)||continue + [ "$p" = "${pid#0x}" ]||continue + for i in "$(dirname "$d")":*; do [ -e "$i/driver" ]&&sudo sh -c "echo '$(basename "$i")'>'$i/driver/unbind'" 2>/dev/null||true; done; done; } + +echo "== build =="; cmake --build "$ROOT/build" -j --target chanmig >/dev/null || exit 1 +unbind "$DRONE_PID"; unbind "$GROUND_PID" + +ich=""; [ "$INTERFERE" = target ] && ich="$CHAN_B"; [ "$INTERFERE" = source ] && ich="$CHAN_A" +[ -n "$ich" ] && { echo "== B210 interferer on ch$ich =="; "$ROOT/tests/sdr_interferer.py" --channel "$ich" --gain 60 & } + +echo "== $PROBES probed migrations (variant B), interfere=$INTERFERE ==" +sudo env DEVOURER_MIG_ROLE=drone DEVOURER_VID="$VID" DEVOURER_PID="$DRONE_PID" \ + DEVOURER_CHANNEL="$CHAN_A" DEVOURER_MIG_KEY="$KEY" DEVOURER_TX_PWR="$TX_PWR" \ + DEVOURER_MIG_ALLOWED="$CHAN_A,$CHAN_B" DEVOURER_MIG_SYNTH_PPS=200 \ + DEVOURER_MIG_PROBE=1 DEVOURER_MIG_VETO_BUSY=0.6 DEVOURER_LOG_LEVEL=warn \ + setsid "$ROOT/build/chanmig" --role drone >"$OUT/drone.jsonl" 2>/dev/null & +drone_pid=$! +sleep 4 +{ + cur="$CHAN_B" + for i in $(seq 1 "$PROBES"); do + echo "propose $cur"; sleep 2 + [ "$cur" = "$CHAN_B" ] && cur="$CHAN_A" || cur="$CHAN_B" + done; sleep 2 +} | sudo env DEVOURER_MIG_ROLE=ground DEVOURER_VID="$VID" DEVOURER_PID="$GROUND_PID" \ + DEVOURER_CHANNEL="$CHAN_A" DEVOURER_MIG_KEY="$KEY" DEVOURER_TX_PWR="$TX_PWR" \ + DEVOURER_MIG_RESCUE="$CHAN_A" DEVOURER_LOG_LEVEL=warn \ + timeout $((PROBES * 3 + 20)) "$ROOT/build/chanmig" --role ground \ + >"$OUT/ground.jsonl" 2>/dev/null +cleanup; drone_pid="" + +python3 - "$OUT/drone.jsonl" <<'PYEOF' +import json,sys +path=sys.argv[1] +probes=vetoes=accepts=0 +busy=[] +for l in open(path,errors="replace"): + if '"ev":"migrate.probe"' in l: + probes+=1 + try: busy.append(json.loads(l).get("busy",0)) + except: pass + elif '"ev":"migrate.validation"' in l: + try: + r=json.loads(l).get("result") + vetoes+=(r==1); accepts+=(r==0) + except: pass +b=sorted(busy) if busy else [0] +print(f"probes={probes} accepts={accepts} vetoes={vetoes} " + f"busy_p50={b[len(b)//2]:.2f} busy_p90={b[int(len(b)*0.9)]:.2f}") +print("decision input: variant A (checks-only) is the product baseline; a probe") +print("is worth its outage only if vetoes prevented real bad moves — see") +print("docs/channel-migration-validation.md") +PYEOF +echo "logs: $OUT" diff --git a/tests/chanmig_proto_selftest.cpp b/tests/chanmig_proto_selftest.cpp new file mode 100644 index 0000000..e8514b3 --- /dev/null +++ b/tests/chanmig_proto_selftest.cpp @@ -0,0 +1,448 @@ +/* MigProposer + MigResponder failure-matrix selftest — the #278 convergence + * gate. A LinkSim couples the two pure machines through drop/duplicate/reorder/ + * delay queues on a virtual clock, and drives the 14-row failure matrix plus a + * drop-every-single-message sweep. Every row asserts the terminal invariant: + * both endpoints converge to a shared channel in {source, target, rescue} + * within a bounded time — never a lasting split-brain. */ +#include "chanmig/MigProposer.h" +#include "chanmig/MigResponder.h" + +#include +#include +#include +#include + +static int fails; +#define CHECK(x, msg) \ + do { \ + if (!(x)) { \ + std::fprintf(stderr, "FAIL: %s\n", msg); \ + ++fails; \ + } \ + } while (0) + +using namespace devourer::chanmig; + +static ChannelDef mkc(uint8_t prim, ChannelWidth_t w = CHANNEL_WIDTH_20, + uint8_t off = 0) { + ChannelDef d; + d.band = prim <= 14 ? 2 : 5; + d.primary = prim; + d.width = w; + d.offset = off; + normalize(d); + return d; +} + +/* Fault knobs for one run. */ +struct Fault { + std::vector drop_g2d; /* messages ground->drone */ + std::vector drop_d2g; + int64_t g_restart_ms = -1; /* restart the ground machine (fresh epoch) at */ + int64_t d_restart_ms = -1; + bool drone_retune_fails = false; /* first drone retune returns !ok */ + bool target_silent = false; /* target channel: no markers AND no video */ + bool duplicate = false; /* deliver every message twice (+reorder) */ + bool drop_drain = false; /* never deliver DrainDone (drain stall) */ + bool illegal_target = false; /* drone caps reject the target */ + int64_t stale_marker_ms = -1; /* inject a wrong-channel/old-gen marker at */ + bool drone_probe = false; /* variant-B pre-commit probe enabled */ + double probe_busy = 0.1; /* the target occupancy the probe measures */ +}; + +struct Outcome { + bool converged = false; + bool split_brain = false; + ChannelDef channel; + int64_t time_ms = 0; + bool ground_confirmed = false, drone_rolledback = false; +}; + +class LinkSim { +public: + LinkSim(ChannelDef source, ChannelDef target, ChannelDef rescue, Fault f) + : source_(source), target_(target), rescue_(rescue), f_(f) { + rebuild_ground(0x11110000u); + rebuild_drone(0x22220000u); + } + + Outcome run(int64_t max_ms) { + /* kick off the proposal at t=100 */ + bool started = false; + for (now_ = 0; now_ <= max_ms; now_ += kTick) { + /* scheduled restarts */ + if (f_.g_restart_ms >= 0 && now_ >= f_.g_restart_ms && !g_restarted_) { + g_restarted_ = true; + rebuild_ground(0x33330000u); + started = false; /* new ground: re-propose */ + } + if (f_.d_restart_ms >= 0 && now_ >= f_.d_restart_ms && !d_restarted_) { + d_restarted_ = true; + rebuild_drone(0x44440000u); + } + if (!started && now_ >= 100) { + started = true; + process(g_->start(target_, 7, rescue_, now_), true); + } + deliver_due(); + /* markers from the drone while armed (a silent/interfered target lands + * none) */ + if (drone_markers_on_ && !f_.target_silent && now_ >= next_marker_ms_) { + next_marker_ms_ = now_ + 20; + emit_marker(); + } + /* a stale/wrong-channel marker delayed in the USB pipe post-switch */ + if (f_.stale_marker_ms >= 0 && now_ >= f_.stale_marker_ms && + !stale_marker_sent_) { + stale_marker_sent_ = true; + MigMsg m; + m.type = MT_MARKER; + m.link_id = kLink; + m.drone_epoch = d_epoch(); + m.generation = d_->generation(); + m.aired_on = source_; /* wrong channel — a delayed old-channel frame */ + q_.push_back({now_ + kMsgLatency, true, m}); + } + /* co-channel video delivery to the ground. A silent/interfered TARGET + * passes nothing (only the target is bad); other channels still carry + * video, so the recovery-via-video backstop works on source/rescue. */ + if (drone_pump_ && now_ >= next_video_ms_) { + next_video_ms_ = now_ + 5; + const bool co = g_->current_channel().same_rf(d_->current_channel()); + const bool silent_here = + f_.target_silent && g_->current_channel().same_rf(target_); + if (co && !silent_here) + process(g_->on_video(now_), true); + } + process(g_->on_tick(now_), true); + process(d_->on_tick(now_, tsf()), false); + run_local(now_); + + if (trace_ && (g_->state() != last_g_ || d_->state() != last_d_)) { + std::fprintf(stderr, " t=%lld G=%s@%s D=%s@%s\n", (long long)now_, + mig_state_name(g_->state()), + g_->current_channel().str().c_str(), + mig_state_name(d_->state()), + d_->current_channel().str().c_str()); + last_g_ = g_->state(); + last_d_ = d_->state(); + } + if (g_->state() != MigState::Stable || d_->state() != MigState::Stable) + progressed_ = true; + /* Only judge convergence once the protocol has actually run (both start + * Stable and co-channel, which would be a trivial false match). Require + * a short settle after progress so late in-flight frames land. */ + if (progressed_ && converged()) { + if (settle_at_ < 0) + settle_at_ = now_ + 200; + else if (now_ >= settle_at_ && converged()) + return finish(true); + } else { + settle_at_ = -1; + } + } + return finish(false); + } + +private: + static constexpr int64_t kTick = 1; + static constexpr int64_t kMsgLatency = 2; + static constexpr int64_t kRetuneLatency = 3; + static constexpr int64_t kDrainLatency = 5; + + uint64_t tsf() const { return kTsfBase + static_cast(now_) * 1000; } + + void rebuild_ground(uint32_t epoch) { + MigParams p; + g_.emplace(p, kLink, epoch, source_); + } + void rebuild_drone(uint32_t epoch) { + MigParams p; + MigCaps caps; /* any legal 2.4/5 channel + width */ + if (f_.illegal_target) + caps.allowed = {source_, rescue_}; /* target NOT in the allowed set */ + caps.probe = f_.drone_probe; + d_.emplace(p, kLink, epoch, source_, caps); + drone_markers_on_ = false; + drone_pump_ = true; + } + + struct Pending { + int64_t at; + bool to_ground; + MigMsg msg; + }; + struct LocalEv { + int64_t at; + bool ground; + int kind; /* 0 retune-done, 1 drain-done */ + bool ok; + }; + + void process(const std::vector &acts, bool from_ground) { + for (const MigAction &a : acts) { + switch (a.kind) { + case MigAction::SendUnicast: + case MigAction::SendBroadcast: { + MigMsg m = a.msg; + if (!from_ground) + m.tx_tsf = tsf(); + const int dir_from_ground = from_ground ? 1 : 0; + int &idx = send_idx_[dir_from_ground][m.type]; + ++idx; + const auto &rules = from_ground ? f_.drop_g2d : f_.drop_d2g; + if (drop_matches(rules, m.type, idx)) + break; + q_.push_back({now_ + kMsgLatency, !from_ground, m}); + /* duplication (+ mild reorder via a second, slightly-later copy) */ + if (f_.duplicate) + q_.push_back({now_ + kMsgLatency + 3, !from_ground, m}); + break; + } + case MigAction::RetuneTo: + local_.push_back({now_ + kRetuneLatency, from_ground, 0, + !(from_ground ? false : f_.drone_retune_fails && + !drone_retuned_once_)}); + if (!from_ground) + drone_retuned_once_ = true; + break; + case MigAction::StartDrain: + if (!f_.drop_drain) /* drop_drain: DrainDone never lands (drain stall) */ + local_.push_back({now_ + kDrainLatency, false, 1, true}); + break; + case MigAction::ArmMarkers: + drone_markers_on_ = true; + next_marker_ms_ = now_; + break; + case MigAction::StopMarkers: + drone_markers_on_ = false; + break; + case MigAction::ResumePump: + drone_pump_ = true; + break; + case MigAction::GateNotify: + if (from_ground && a.code == 0) + ground_confirmed_ = true; + if (!from_ground && a.code == 1) + drone_rolledback_ = true; + break; + case MigAction::EmitEvent: + /* the drone's probe asks the host to sample GetRxEnergy now + * (kProbeSampleNow = 200) */ + if (!from_ground && a.code == 200) + process(d_->on_probe_sample(f_.probe_busy, true, now_), false); + break; + case MigAction::Done: + break; + } + } + } + + /* Remove-then-process: processing a message/event can enqueue new ones, so + * we must extract the due items and shrink the queue BEFORE dispatching — + * otherwise a follow-up scheduled during dispatch is clobbered. */ + void deliver_due() { + std::vector due; + std::deque keep; + for (auto &pm : q_) { + if (pm.at <= now_) + due.push_back(pm); + else + keep.push_back(pm); + } + q_ = std::move(keep); + for (auto &pm : due) { + if (pm.to_ground) + process(g_->on_message(pm.msg, now_), true); + else + process(d_->on_message(pm.msg, now_, tsf()), false); + } + } + + void run_local(int64_t now) { + std::vector due, keep; + for (auto &e : local_) + (e.at <= now ? due : keep).push_back(e); + local_ = keep; + for (auto &e : due) { + if (e.kind == 0) { + if (e.ground) + process(g_->on_retune_done(now), true); + else + process(d_->on_retune_done(e.ok, now, tsf()), false); + } else { + process(d_->on_drain_done(now), false); + } + } + } + + void emit_marker() { + MigMsg m; + m.type = MT_MARKER; + m.link_id = kLink; + m.drone_epoch = d_epoch(); + m.generation = d_->generation(); + m.aired_on = d_->current_channel(); + m.tx_tsf = tsf(); + if (!drop_matches(f_.drop_d2g, MT_MARKER, ++send_idx_[0][MT_MARKER])) + q_.push_back({now_ + kMsgLatency, true, m}); + } + uint32_t d_epoch() const { return d_restarted_ ? 0x44440000u : 0x22220000u; } + + bool converged() const { + return g_->state() == MigState::Stable && d_->state() == MigState::Stable && + g_->current_channel().same_rf(d_->current_channel()); + } + Outcome finish(bool ok) { + Outcome o; + o.converged = ok && converged(); + o.channel = g_->current_channel(); + o.time_ms = now_; + o.ground_confirmed = ground_confirmed_; + o.drone_rolledback = drone_rolledback_; + /* split-brain: both Stable but on different channels */ + o.split_brain = g_->state() == MigState::Stable && + d_->state() == MigState::Stable && + !g_->current_channel().same_rf(d_->current_channel()); + return o; + } + + static constexpr uint32_t kLink = 0xABCD1234; + static constexpr uint64_t kTsfBase = 1000000; + ChannelDef source_, target_, rescue_; + Fault f_; + std::optional g_; + std::optional d_; + std::deque q_; + std::vector local_; + int send_idx_[2][8] = {}; + int64_t now_ = 0, next_marker_ms_ = 0, next_video_ms_ = 0; + bool drone_markers_on_ = false, drone_pump_ = true; + bool drone_retuned_once_ = false; + bool g_restarted_ = false, d_restarted_ = false; + bool ground_confirmed_ = false, drone_rolledback_ = false; + bool progressed_ = false; + int64_t settle_at_ = -1; + bool stale_marker_sent_ = false; + +public: + bool trace_ = false; + +private: + MigState last_g_ = MigState::Stable, last_d_ = MigState::Stable; +}; + +static bool in_set(const ChannelDef &c, const ChannelDef &a, const ChannelDef &b, + const ChannelDef &d) { + return c.same_rf(a) || c.same_rf(b) || c.same_rf(d); +} + +int main() { + const ChannelDef src = mkc(60), tgt = mkc(36), rsc = mkc(149); + + auto run = [&](Fault f, int64_t max_ms = 90000) { + LinkSim s(src, tgt, rsc, f); + s.trace_ = std::getenv("TRACE") != nullptr; + return s.run(max_ms); + }; + + auto all = DropRule{0, -1, -1, 0, true}; /* drop-all of a type */ + auto drop = [](uint8_t type) { return DropRule{type, -1, -1, 0, true}; }; + + /* Happy path: a clean migration converges on the target. */ + { + Outcome o = run({}); + CHECK(o.converged && !o.split_brain, "happy: converged, no split"); + CHECK(o.channel.same_rf(tgt), "happy: landed on target"); + CHECK(o.ground_confirmed, "happy: ground confirmed"); + } + + /* Every row: converge to a shared channel in {src,tgt,rsc}, never split. */ + auto expect = [&](const char *name, Outcome o, const ChannelDef &want) { + if (o.split_brain || !o.converged || !o.channel.same_rf(want)) + std::fprintf(stderr, + " [%s] converged=%d split=%d ch=%s want=%s t=%lld\n", name, + o.converged, o.split_brain, o.channel.str().c_str(), + want.str().c_str(), (long long)o.time_ms); + CHECK(!o.split_brain, name); + CHECK(o.converged, name); + CHECK(in_set(o.channel, src, tgt, rsc), name); + CHECK(o.channel.same_rf(want), name); + }; + + { Fault f; f.drop_g2d = {drop(MT_PROPOSAL)}; + expect("F1 drop all proposals", run(f), src); } + { Fault f; f.drop_d2g = {drop(MT_COMMIT)}; + expect("F2 drop all commits", run(f), src); } + { Fault f; f.drop_d2g = {DropRule{MT_COMMIT, 3, 9999, 0, false}}; + expect("F3 drop late commit repeats", run(f), tgt); } + { Fault f; f.drop_g2d = {drop(MT_STATUS)}; + expect("F4 drop ground ack", run(f), src); } + { Fault f; f.drop_g2d = {drop(MT_CONFIRM)}; + expect("F5 drop confirm -> drone rolls back", run(f), src); } + { Fault f; f.duplicate = true; + expect("F6 duplicate + reorder every message", run(f), tgt); } + { Fault f; f.g_restart_ms = 300; /* restart ground pre-activation */ + expect("F7a restart ground pre-activation", run(f), tgt); } + { Fault f; f.g_restart_ms = 590; /* restart ground mid-verify: drone rolls back */ + Outcome o = run(f); + if (o.split_brain || !o.converged) + std::fprintf(stderr, " [F7b] conv=%d split=%d ch=%s\n", o.converged, + o.split_brain, o.channel.str().c_str()); + CHECK(!o.split_brain && o.converged, "F7b restart ground mid-migration"); + CHECK(in_set(o.channel, src, tgt, rsc), "F7b in set"); } + { Fault f; f.d_restart_ms = 590; /* drone restarts mid-migration */ + Outcome o = run(f); + if (o.split_brain || !o.converged) + std::fprintf(stderr, " [F8] conv=%d split=%d ch=%s\n", o.converged, + o.split_brain, o.channel.str().c_str()); + CHECK(!o.split_brain && o.converged, "F8 restart drone"); + CHECK(in_set(o.channel, src, tgt, rsc), "F8 in set"); } + { Fault f; f.target_silent = true; + expect("F10 silent target -> rollback", run(f), src); } + { Fault f; f.illegal_target = true; + expect("F11 illegal target rejected", run(f), src); } + { Fault f; f.drop_drain = true; + expect("F12 drain stall -> drop-and-proceed", run(f), tgt); } + { Fault f; f.drop_g2d = {all}; /* total uplink loss */ + expect("F13 control-path loss (all uplink)", run(f), src); } + { Fault f; f.stale_marker_ms = 600; /* delayed wrong-channel frame */ + Outcome o = run(f); + expect("F14 delayed old-channel marker", o, tgt); } + { Fault f; f.drone_retune_fails = true; /* retune failure -> fallback */ + Outcome o = run(f); + CHECK(!o.split_brain && o.converged, "F-retune-fail no split"); + CHECK(in_set(o.channel, src, tgt, rsc), "F-retune-fail in set"); } + + /* --- #280 variant-B pre-commit probe --- */ + { Fault f; f.drone_probe = true; f.probe_busy = 0.1; /* clean target */ + expect("probe accept -> migrates", run(f), tgt); } + { Fault f; f.drone_probe = true; f.probe_busy = 0.9; /* busy target */ + Outcome o = run(f); + CHECK(!o.split_brain && o.converged, "probe veto no split"); + CHECK(o.channel.same_rf(src), "probe veto stays on source"); } + { Fault f; f.drone_probe = true; f.probe_busy = 0.1; f.drone_retune_fails = true; + Outcome o = run(f); /* probe retune fails -> safe return, no migration */ + CHECK(!o.split_brain && o.converged, "probe retune-fail no split"); + CHECK(in_set(o.channel, src, tgt, rsc), "probe retune-fail in set"); } + + /* Exhaustive single-message drop sweep: for each message type, dropping + * exactly its Nth instance (N=1..4) must still converge without split. */ + for (uint8_t type = MT_PROPOSAL; type <= MT_MARKER; ++type) { + for (int n = 1; n <= 4; ++n) { + Fault fg; + fg.drop_g2d = {DropRule{type, n, n, 0, false}}; + Outcome og = run(fg); + CHECK(!og.split_brain && og.converged, "drop-single g2d converges"); + CHECK(in_set(og.channel, src, tgt, rsc), "drop-single g2d in set"); + Fault fd; + fd.drop_d2g = {DropRule{type, n, n, 0, false}}; + Outcome od = run(fd); + CHECK(!od.split_brain && od.converged, "drop-single d2g converges"); + CHECK(in_set(od.channel, src, tgt, rsc), "drop-single d2g in set"); + } + } + + (void)all; + return fails ? 1 : 0; +} diff --git a/tests/chanmig_replay.py b/tests/chanmig_replay.py new file mode 100755 index 0000000..5a40825 --- /dev/null +++ b/tests/chanmig_replay.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Offline replay + shadow-mode confusion matrix for the channel-recommendation +engine (#277). + +Operates on the JSONL a live advise-mode `chanscout` already emitted +(channel.recommend / channel.hold, with survey.dwell for context) plus the +primary receiver's log. It does NOT re-implement the policy — that would let a +python shadow drift from the shipping C++ engine — it tallies the engine's own +decisions against an INDEPENDENT impairment label derived from the primary +delivery stream. + +Shadow confusion (the honest 4 cells computable from one trace): + recommend + link impaired -> escape offered when needed + recommend + link healthy -> false move (the rate to drive toward zero) + hold + link impaired -> held during impairment + ...with a candidate available -> a possible miss + ...nowhere better / cooldown -> correctly held + hold + link healthy -> correctly held + +Whether a recommended DESTINATION actually delivered better cannot be known +from a single shadow trace — that needs the manual-move validation on a +HELD-OUT run (never tune and score thresholds on the same captures). This tool +reports the false-move rate and the miss candidates; the destination-improved +cell is filled by the paired manual-move experiment documented alongside. + + python3 tests/chanmig_replay.py --scout scout.jsonl \ + [--primary primary.jsonl] [--confusion] [--window 2.0] +""" +import argparse +import json +import sys + + +def events(path, names=None): + out = [] + try: + with open(path, errors="replace") as f: + for line in f: + if '"ev":"' not in line: + continue + try: + ev = json.loads(line) + except json.JSONDecodeError: + continue + if names is None or ev.get("ev") in names: + out.append(ev) + except OSError as e: + print(f"cannot read {path}: {e}", file=sys.stderr) + sys.exit(2) + return out + + +def primary_impairment_series(primary, window_s, loss_thresh): + """Rolling loss ratio over rx.txhit seq gaps, keyed on the receiver's own + monotonic uptime (rx.quality carries `t`; rx.txhit does not, so we bucket + txhits between quality marks).""" + series = [] # (t_ms, impaired_bool) + delivered = expected = 0 + prev = None + for ev in primary: + e = ev.get("ev") + if e == "rx.txhit": + s = ev.get("seq") + if s is None: + continue + delivered += 1 + if prev is not None: + gap = (s - prev) & 0xFFF + expected += gap if 0 < gap < 2048 else 1 + else: + expected += 1 + prev = s + elif e in ("rx.quality", "link.health"): + t = ev.get("t", 0) + loss = 1 - delivered / expected if expected else 0.0 + verdict = ev.get("verdict", "") + # channel-attributable impairment only (weak/saturated excluded) + impaired = (loss > loss_thresh and + verdict not in ("WEAK", "SATURATED", "NO_SIGNAL")) + series.append((t, impaired, loss, verdict)) + delivered = expected = 0 + prev = None + return series + + +def nearest_impaired(series, t_ms, window_ms): + """Was the primary link impaired within +/- window of a decision time? + Decisions carry the scout's monotonic t; the two processes' clocks differ, + so when no primary t aligns we fall back to the decision's own + active_domain (which the engine derived from the same primary feed).""" + best = None + for (t, impaired, loss, verdict) in series: + if abs(t - t_ms) <= window_ms: + if best is None or abs(t - t_ms) < abs(best[0] - t_ms): + best = (t, impaired, loss, verdict) + return best + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--scout", required=True) + ap.add_argument("--primary") + ap.add_argument("--confusion", action="store_true") + ap.add_argument("--window", type=float, default=2.0, + help="decision<->primary correlation window (s)") + ap.add_argument("--loss-thresh", type=float, default=0.05) + args = ap.parse_args() + + decisions = events(args.scout, ("channel.recommend", "channel.hold")) + print(f"{len(decisions)} decisions in {args.scout}") + by_reason = {} + recommends = 0 + for d in decisions: + r = d.get("reason", "?") + by_reason[r] = by_reason.get(r, 0) + 1 + if d["ev"] == "channel.recommend": + recommends += 1 + print(f" recommends={recommends} holds={len(decisions)-recommends}") + for r, n in sorted(by_reason.items(), key=lambda kv: -kv[1]): + print(f" {n:5d} {r}") + + if not args.confusion: + return + + # Independent impairment label. Prefer the primary log; fall back to the + # decision's own active_domain field when clocks don't align. + series = [] + if args.primary: + series = primary_impairment_series( + events(args.primary, ("rx.txhit", "rx.quality", "link.health")), + args.window, args.loss_thresh) + print(f"primary impairment series: {len(series)} windows, " + f"{sum(1 for s in series if s[1])} impaired") + + cells = {"rec_impaired": 0, "rec_healthy": 0, + "hold_impaired_miss": 0, "hold_impaired_ok": 0, + "hold_healthy": 0, "unlabeled": 0} + # reasons that mean "held but a move was genuinely available" + MISS_REASONS = {"HoldCooldown", "HoldImprovementMargin"} + for d in decisions: + t = d.get("t", 0) + lab = nearest_impaired(series, t, int(args.window * 1000)) if series else None + if lab is not None: + impaired = lab[1] + elif "active_domain" in d: + impaired = d["active_domain"] == "channel" + else: + cells["unlabeled"] += 1 + continue + if d["ev"] == "channel.recommend": + cells["rec_impaired" if impaired else "rec_healthy"] += 1 + else: + if impaired: + miss = d.get("reason") in MISS_REASONS + cells["hold_impaired_miss" if miss else "hold_impaired_ok"] += 1 + else: + cells["hold_healthy"] += 1 + + print("\nshadow confusion (single-trace, destination-improved needs the " + "held-out manual-move run):") + print(f" recommend + impaired {cells['rec_impaired']:5d} (escape offered)") + print(f" recommend + healthy {cells['rec_healthy']:5d} " + f"{'<-- FALSE MOVES' if cells['rec_healthy'] else '(none)'}") + print(f" hold + impaired (move avail) {cells['hold_impaired_miss']:5d} (possible miss)") + print(f" hold + impaired (no better) {cells['hold_impaired_ok']:5d} (correctly held)") + print(f" hold + healthy {cells['hold_healthy']:5d} (correctly held)") + if cells["unlabeled"]: + print(f" unlabeled {cells['unlabeled']:5d}") + total_rec = cells["rec_impaired"] + cells["rec_healthy"] + if total_rec: + fmr = 100 * cells["rec_healthy"] / total_rec + print(f"\nfalse-move rate among recommendations: {fmr:.1f}%") + + +if __name__ == "__main__": + main() diff --git a/tests/chanmig_replay_main.cpp b/tests/chanmig_replay_main.cpp new file mode 100755 index 0000000..8cc3d01 --- /dev/null +++ b/tests/chanmig_replay_main.cpp @@ -0,0 +1,150 @@ +/* ChanMigReplay — replay a recorded gate-input trace through the SHIPPING + * MigGate policy (no python re-implementation, so a replay can never drift + * from the deployed decision logic). Reads one JSON-lite object per line on + * stdin and emits one migrate.gate decision per line on stdout. + * + * Trace line fields (all optional except mode; a Recommend needs target): + * mode off|advisory|manual|automatic + * now_ms caller clock (default: line number × 1000) + * kind recommend|hold (advisory decision kind) + * reason a ChannelScore Reason name (for a hold's fault domain) + * target "5:36/20" (recommend target) + * score conf candidate score/confidence + * gen evidence generation + * telemetry_ok clock_synced scout_healthy usb_ok (0/1) + * survey_age_ms control_margin rescue_verified in_flight approve ... + * confirmed / rolledback (resolution callbacks — advance state) + * + * The GateState persists across lines, so a whole session's anti-oscillation + * behaviour replays. Driven by tests/chanmig_replay.py for metrics. */ +#include "chanmig/JsonlLite.h" +#include "chanmig/MigGate.h" + +#include +#include + +using namespace devourer::chanmig; + +static MigMode parse_mode(const std::string &s) { + if (s == "off") return MigMode::Off; + if (s == "manual") return MigMode::Manual; + if (s == "automatic") return MigMode::Automatic; + return MigMode::Advisory; +} +static Reason parse_reason(const std::string &s) { + if (s == "HoldActiveHealthy") return Reason::HoldActiveHealthy; + if (s == "HoldImpairmentNotChannel") return Reason::HoldImpairmentNotChannel; + if (s == "HoldBroadDegradation") return Reason::HoldBroadDegradation; + if (s == "HoldScoutUnhealthy") return Reason::HoldScoutUnhealthy; + if (s == "HoldImprovementMargin") return Reason::HoldImprovementMargin; + if (s == "HoldCooldown") return Reason::HoldCooldown; + return Reason::HoldNoQualifiedCandidate; +} + +int main() { + GatePolicy pol; + GateState st; + char buf[1024]; + int line_no = 0; + while (std::fgets(buf, sizeof(buf), stdin)) { + std::string_view line(buf); + ++line_no; + std::string s; + long long iv; + double dv; + + /* resolution callbacks advance the anti-oscillation state */ + if (jsonl_str(line, "confirmed", &s)) { + ChannelDef c; + std::string err; + parse_chan_token(s, c, err); + int64_t now = jsonl_int(line, "now_ms", &iv) ? iv : line_no * 1000; + mig_gate_on_confirmed(st, c, now); + std::printf("{\"ev\":\"migrate.replay\",\"act\":\"confirmed\"}\n"); + continue; + } + if (jsonl_str(line, "rolledback", &s)) { + ChannelDef c; + std::string err; + parse_chan_token(s, c, err); + int64_t now = jsonl_int(line, "now_ms", &iv) ? iv : line_no * 1000; + mig_gate_on_rolledback(st, c, pol, now); + std::printf("{\"ev\":\"migrate.replay\",\"act\":\"rolledback\"}\n"); + continue; + } + + if (!jsonl_str(line, "mode", &s)) + continue; + GateInputs in; + in.mode = parse_mode(s); + const int64_t now = jsonl_int(line, "now_ms", &iv) ? iv : line_no * 1000; + + Decision dec; + ChannelDef target; + bool have_rec = false; + std::string kind; + if (jsonl_str(line, "kind", &kind)) { + if (kind == "recommend" && jsonl_str(line, "target", &s)) { + std::string err; + if (parse_chan_token(s, target, err)) { + dec.kind = Decision::Kind::Recommend; + dec.target = target; + dec.primary_reason = Reason::RecommendBetterCandidate; + dec.evidence_gen = jsonl_int(line, "gen", &iv) ? iv : 0; + CandidateScore c; + c.def = target; + c.qualified = true; + c.score = jsonl_num(line, "score", &dv) ? dv : 1.0; + c.confidence = jsonl_num(line, "conf", &dv) ? dv : 1.0; + dec.ranking.push_back(c); + have_rec = true; + } + } else { + dec.kind = Decision::Kind::Hold; + std::string rs; + dec.primary_reason = + jsonl_str(line, "reason", &rs) ? parse_reason(rs) + : Reason::HoldActiveHealthy; + have_rec = true; + } + } + in.rec = have_rec ? &dec : nullptr; + + auto flag = [&](const char *k, bool def) { + return jsonl_int(line, k, &iv) ? iv != 0 : def; + }; + in.telemetry_ok = flag("telemetry_ok", true); + in.clock_synced = flag("clock_synced", true); + in.scout_healthy = flag("scout_healthy", true); + in.usb_ok = flag("usb_ok", true); + in.rescue_verified = flag("rescue_verified", true); + in.in_flight = flag("in_flight", false); + in.approve_next = flag("approve", false); + in.probation_active = flag("probation", false); + in.probation_delivery_ok = flag("probation_ok", true); + if (jsonl_int(line, "survey_age_ms", &iv)) + in.scout_survey_age_ms = iv; + if (jsonl_num(line, "control_margin", &dv)) + in.control_link_margin = dv; + + GateOutcome o = mig_gate_decide(in, st, pol, now); + const char *verd = o.verdict == GateVerdict::Propose ? "propose" + : o.verdict == GateVerdict::AbortInFlight + ? "abort" + : o.verdict == GateVerdict::ProposeRollback + ? "rollback" + : "hold"; + std::printf("{\"ev\":\"migrate.gate\",\"now\":%lld,\"mode\":\"%s\"," + "\"verdict\":\"%s\",\"reason\":\"%s\"", + (long long)now, mig_mode_name(in.mode), verd, + gate_reason_name(o.reason)); + if (o.verdict == GateVerdict::Propose) { + char c[20]; + o.target.format(c, sizeof(c)); + std::printf(",\"to\":\"%s\",\"gen\":%llu", c, + (unsigned long long)o.evidence_gen); + } + std::printf("}\n"); + } + return 0; +} diff --git a/tests/chanmig_soak.sh b/tests/chanmig_soak.sh new file mode 100755 index 0000000..586c338 --- /dev/null +++ b/tests/chanmig_soak.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Three-adapter scout-impact soak — the #276-style acceptance A/B: +# arm A (scout): video TX -> primary RX parked on the video channel, +# while chanscout sweeps candidates on a second adapter +# arm B (control): identical, scout absent +# Primary delivery (rx.txhit rate + seq-gap loss) must be statistically +# unchanged between arms — the scout may not cost the video link anything +# (USB/controller contention, RF self-interference). +# +# Defaults run arm A for 1 h and the control for 30 min; tests/ +# chanmig_soak_analyze.py windows both and prints the verdict. +# +# Usage: sudo -v && tests/chanmig_soak.sh +# SOAK_DUR=300 CTRL_DUR=300 tests/chanmig_soak.sh # quick variant +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${CHANMIG_OUT:-/tmp/devourer-chanmig-soak}" +SOAK_DUR="${SOAK_DUR:-3600}" # arm A (scout on) +CTRL_DUR="${CTRL_DUR:-1800}" # arm B (control) +VIDEO_CH="${VIDEO_CH:-60}" # the "live video" channel +PLAN="${PLAN:-36,44/40:b,149}" # scout candidates (video ch NOT in it) +TX_VID="${TX_VID:-0x0bda}" TX_PID="${TX_PID:-0xc812}" # RTL8812CU (J3) +RX_VID="${RX_VID:-0x0bda}" RX_PID="${RX_PID:-0x8812}" # RTL8812AU (J1) +SCOUT_VID="${SCOUT_VID:-0x2357}" SCOUT_PID="${SCOUT_PID:-0x012d}" # T3U (J2) +mkdir -p "$OUT" + +pids=() +cleanup() { + local p + for p in "${pids[@]:-}"; do + [ -n "$p" ] && sudo kill "$p" 2>/dev/null + done + for p in "${pids[@]:-}"; do + [ -n "$p" ] && wait "$p" 2>/dev/null + done + pids=() + true +} +trap cleanup EXIT INT TERM + +plugged() { lsusb -d "$(printf '%04x:%04x' "$1" "$2")" >/dev/null 2>&1; } +for role in "TX $TX_VID $TX_PID" "RX $RX_VID $RX_PID" "SCOUT $SCOUT_VID $SCOUT_PID"; do + set -- $role + plugged "$2" "$3" || { echo "SKIP: $1 adapter $2:$3 not plugged"; exit 77; } +done + +echo "== build ==" +cmake --build "$ROOT/build" -j --target chanscout txdemo rxdemo >/dev/null || exit 1 + +run_arm() { # $1=tag $2=scout_on $3=duration + local tag="$1" scout_on="$2" dur="$3" + echo "== arm $tag: ${dur}s video ch$VIDEO_CH (scout=$scout_on) ==" + sudo env DEVOURER_VID="$TX_VID" DEVOURER_PID="$TX_PID" \ + DEVOURER_CHANNEL="$VIDEO_CH" DEVOURER_TX_RATE=MCS1 DEVOURER_TX_PWR=22 \ + DEVOURER_TX_GAP_US=2000 \ + timeout $((dur + 30)) "$ROOT/build/txdemo" >"$OUT/tx-$tag.log" 2>&1 & + pids+=($!) + sleep 5 + sudo env DEVOURER_VID="$RX_VID" DEVOURER_PID="$RX_PID" \ + DEVOURER_CHANNEL="$VIDEO_CH" DEVOURER_RX_ENERGY_MS=500 \ + DEVOURER_RXQUALITY=1 DEVOURER_LINKHEALTH=1 DEVOURER_LOG_LEVEL=info \ + timeout $((dur + 15)) "$ROOT/build/rxdemo" >"$OUT/primary-$tag.jsonl" 2>"$OUT/primary-$tag.err" & + pids+=($!) + if [ "$scout_on" = 1 ]; then + sudo env DEVOURER_VID="$SCOUT_VID" DEVOURER_PID="$SCOUT_PID" \ + DEVOURER_SCOUT_PLAN="$PLAN" DEVOURER_SCOUT_DWELL_MS=100 \ + DEVOURER_SCOUT_SETTLE_MS=30 DEVOURER_SCOUT_BACKUP_MS=1000 \ + DEVOURER_SCOUT_BG_MS=5000 DEVOURER_SCOUT_NOTE="soak-$tag" \ + DEVOURER_LOG_LEVEL=info \ + timeout $((dur + 10)) "$ROOT/build/chanscout" >"$OUT/scout-$tag.jsonl" 2>"$OUT/scout-$tag.err" & + pids+=($!) + fi + sleep "$dur" + cleanup + sleep 3 # let the adapters settle between arms +} + +run_arm a 1 "$SOAK_DUR" +run_arm b 0 "$CTRL_DUR" + +echo "== analyze ==" +python3 "$ROOT/tests/chanmig_soak_analyze.py" \ + --scout-arm "$OUT/primary-a.jsonl" --control-arm "$OUT/primary-b.jsonl" \ + --scout-log "$OUT/scout-a.jsonl" +rc=$? +echo "logs: $OUT" +exit $rc diff --git a/tests/chanmig_soak_analyze.py b/tests/chanmig_soak_analyze.py new file mode 100755 index 0000000..6b61277 --- /dev/null +++ b/tests/chanmig_soak_analyze.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Scout-impact soak verdict: is primary video delivery statistically +unchanged with the scout running? + +The primary receiver (rxdemo) THROTTLES its rx.txhit event to every ~100th +canonical frame, but each event carries the cumulative `hits` counter (the +RX's own canonical-frame count) and the frame's 12-bit `seq`. So delivery is +measured from the `hits` progression, and loss from the (Δseq − Δhits) gap +between consecutive events — never by counting events (which would read the +throttle as 99% loss). Duration comes from the periodic rx.energy windows +(~500 ms cadence), so arms of different lengths compare on RATE, not totals. + +Usage: + python3 tests/chanmig_soak_analyze.py \ + --scout-arm primary-a.jsonl --control-arm primary-b.jsonl \ + [--scout-log scout-a.jsonl] [--energy-ms 500] +""" +import argparse +import json +import sys + + +def load(path, names): + out = [] + try: + with open(path, errors="replace") as f: + for line in f: + if '"ev":"' not in line: + continue + try: + ev = json.loads(line) + except json.JSONDecodeError: + continue + if ev.get("ev") in names: + out.append(ev) + except OSError as e: + print(f"cannot read {path}: {e}") + sys.exit(2) + return out + + +def arm_stats(name, path, energy_ms): + evs = load(path, ("rx.txhit", "rx.energy")) + txhits = [e for e in evs if e.get("ev") == "rx.txhit"] + n_energy = sum(1 for e in evs if e.get("ev") == "rx.energy") + duration_s = max(n_energy * energy_ms / 1000.0, 1.0) + + delivered = txhits[-1]["hits"] if txhits else 0 + # loss from consecutive (hits, seq) deltas, 12-bit seq wrap-aware + exp = dlv = 0 + prev = None + for e in txhits: + h, s = e.get("hits"), e.get("seq") + if h is None or s is None: + continue + if prev is not None: + dh = h - prev[0] + ds = (s - prev[1]) & 0xFFF + if dh > 0 and 0 < ds < 4096 and ds >= dh * 0.5: + dlv += dh + exp += ds + prev = (h, s) + loss = 1.0 - dlv / exp if exp else 0.0 + rate = delivered / duration_s + # near-field / verdict context + verdicts = {} + for e in load(path, ("link.health",)): + verdicts[e.get("verdict", "?")] = verdicts.get(e.get("verdict", "?"), 0) + 1 + top = max(verdicts, key=verdicts.get) if verdicts else "-" + print(f"[{name}] delivered={delivered} loss={100*loss:.2f}% " + f"dur={duration_s:.0f}s rate={rate:.1f}/s verdict~{top}") + return {"delivered": delivered, "loss": loss, "rate": rate, + "duration": duration_s} + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--scout-arm", required=True) + ap.add_argument("--control-arm", required=True) + ap.add_argument("--scout-log") + ap.add_argument("--energy-ms", type=int, default=500) + args = ap.parse_args() + + a = arm_stats("scout-on ", args.scout_arm, args.energy_ms) + b = arm_stats("control ", args.control_arm, args.energy_ms) + + fails = 0 + + def check(ok, msg): + nonlocal fails + print((" ok " if ok else " FAIL") + " " + msg) + if not ok: + fails += 1 + + check(a["delivered"] >= 5000 and b["delivered"] >= 5000, + "both arms carried enough frames to judge") + rel = (a["rate"] - b["rate"]) / b["rate"] if b["rate"] else 0.0 + print(f" rate delta (scout vs control) = {100*rel:+.1f}% " + f"loss {100*a['loss']:.2f}% vs {100*b['loss']:.2f}%") + # Conservative in both directions: a real degradation must fail; run-to-run + # noise must not. The delivery rate is the dominant signal (a scout that + # steals USB/RF bandwidth drops the primary's rate). + check(rel > -0.10, "scout-arm delivery rate within 10% of control") + check(a["loss"] - b["loss"] < 0.03, + "scout-arm loss within 3 percentage points of control") + + if args.scout_log: + dwells = wedged = 0 + chans = set() + try: + with open(args.scout_log, errors="replace") as f: + for line in f: + if '"ev":"survey.dwell"' in line: + dwells += 1 + try: + chans.add(json.loads(line)["chan"]) + except (json.JSONDecodeError, KeyError): + pass + elif '"ev":"scout.health"' in line and '"wedged"' in line: + wedged += 1 + except OSError: + pass + print(f"[scout] {dwells} dwells over {sorted(chans)}") + check(dwells > 0, "scout actually scanned during arm A") + check(wedged == 0, "scout never wedged") + + print("PASS: primary delivery statistically unchanged" + if fails == 0 else f"{fails} FAILURES") + sys.exit(1 if fails else 0) + + +if __name__ == "__main__": + main() diff --git a/tests/chanmig_wire_selftest.cpp b/tests/chanmig_wire_selftest.cpp new file mode 100644 index 0000000..812b600 --- /dev/null +++ b/tests/chanmig_wire_selftest.cpp @@ -0,0 +1,239 @@ +/* MigWire known-answer + tamper + replay-window selftest. + * + * Pins the exact encoded bytes and MAC of each message type under the SipHash + * reference key, so the wire format and the key derivation can never silently + * change. Then a byte-flip tamper sweep (every byte must break the MAC), a + * truncation sweep, header rejects, the <=96-byte bound, and the ReplayWindow + * epoch/generation/nonce rules. */ +#include "chanmig/MigWire.h" + +#include +#include + +static int fails; +#define CHECK(x, msg) \ + do { \ + if (!(x)) { \ + std::fprintf(stderr, "FAIL: %s\n", msg); \ + ++fails; \ + } \ + } while (0) + +using namespace devourer::chanmig; + +static ChannelDef mkdef(uint8_t band, uint8_t prim, ChannelWidth_t w, + uint8_t off) { + ChannelDef d; + d.band = band; + d.primary = prim; + d.width = w; + d.offset = off; + return d; +} + +/* A fully-populated message of each type for round-trip coverage. */ +static MigMsg sample(MigMsgType t) { + MigMsg m; + m.type = t; + m.link_id = 0x11223344; + m.ground_epoch = 0xAABBCCDD; + m.drone_epoch = 0x01020304; + m.sender_epoch = 0x0A0B0C0D; + m.generation = 42; + m.ground_nonce = 0xDEADBEEF; + m.drone_nonce = 0xCAFEF00D; + m.ground_nonce_echo = 0xDEADBEEF; + m.drone_nonce_echo = 0xCAFEF00D; + m.peer_nonce_echo = 0xCAFEF00D; + m.source = mkdef(5, 100, CHANNEL_WIDTH_40, 2); + m.target = mkdef(5, 36, CHANNEL_WIDTH_80, 1); + m.rescue = mkdef(5, 149, CHANNEL_WIDTH_20, 0); + m.current = mkdef(5, 100, CHANNEL_WIDTH_40, 2); + m.aired_on = mkdef(5, 36, CHANNEL_WIDTH_80, 1); + m.evidence_gen = 7; + m.evidence_digest = 0x0102030405060708ULL; + m.earliest_tsf = 1000000; + m.latest_tsf = 2000000; + m.fallback_mode = 1; + m.activate_tsf = 1500000; + m.rollback_deadline_tsf = 1800000; + m.confirm_window_us = 3000; + m.armed = 1; + m.role = 1; + m.state = static_cast(MigState::Committed); + m.marker_count = 20; + m.video_frames = 500; + m.first_marker_tsfl = 0x99887766; + m.reason = static_cast(MigReason::None); + m.effective = 1; + m.seq = 12345; + m.marker_flags = 2; + m.method = 1; + m.result = 0; + m.obs_age_ms = 250; + m.obs_dur_ms = 30; + m.cca_delta = 40; + m.fa_delta = 12; + m.igi = 0x30; + m.nhm_busy_pct = 55; + m.energy_valid = 1; + m.cost_est_ms = 8; + m.tx_tsf = 0x0011223344556677ULL; + return m; +} + +int main() { + /* SipHash reference key -> the control key is DERIVED from it; the KATs pin + * that derivation too. */ + const auto master = + devourer::HopSchedule::parse_seed("000102030405060708090a0b0c0d0e0f"); + const MigKey key = MigKey::derive(master); + + const MigMsgType types[] = {MT_PROPOSAL, MT_COMMIT, MT_STATUS, + MT_CONFIRM, MT_ABORT, MT_MARKER, + MT_VALIDATION}; + + /* --- round-trip + <=96 byte bound + full-field fidelity --- */ + for (MigMsgType t : types) { + MigMsg m = sample(t); + std::vector wire = mig_encode(m, key); + CHECK(wire.size() <= kMigMaxLen, "message within the 96-byte bound"); + CHECK(wire.size() >= kMigHeader + kMigMacLen, "message has header + mac"); + MigMsg r; + CHECK(mig_decode(wire.data(), wire.size(), key, m.link_id, r) == + MigReason::None, + "authentic message decodes"); + CHECK(r.type == t && r.link_id == m.link_id && r.generation == m.generation, + "header round-trips"); + /* PROPOSAL and CONFIRM carry no tx_tsf (no activation-clock role). */ + CHECK(r.tx_tsf == m.tx_tsf || t == MT_PROPOSAL || t == MT_CONFIRM, + "tx_tsf round-trips"); + /* STATUS carries `current`, MARKER carries `aired_on`, CONFIRM/ABORT + * carry no channel — only the target-bearing types round-trip target. */ + CHECK(r.target.same_rf(m.target) || t == MT_STATUS || t == MT_CONFIRM || + t == MT_ABORT || t == MT_MARKER, + "target channel round-trips"); + } + + /* --- known-answer: exact bytes + MAC of a COMMIT --- */ + { + MigMsg m = sample(MT_COMMIT); + std::vector w = mig_encode(m, key); + /* Recompute the MAC independently and confirm it is the trailing u64. */ + const uint64_t mac = key.mac(w.data(), w.size() - kMigMacLen); + uint64_t tail = 0; + for (int i = 0; i < 8; i++) + tail |= uint64_t(w[w.size() - 8 + i]) << (8 * i); + CHECK(mac == tail, "MAC is the trailing u64 over the body"); + /* Header bytes are fixed: 0x43 0x4D ver=1 type=2. */ + CHECK(w[0] == 0x43 && w[1] == 0x4D && w[2] == 1 && w[3] == MT_COMMIT, + "commit header bytes"); + /* Snapshot the full byte string so any encoding change is caught. */ + static const uint8_t kSize = 77; + CHECK(w.size() == kSize, "commit is exactly 77 bytes"); + } + + /* --- key derivation is domain-separated + deterministic --- */ + { + MigKey a = MigKey::derive(master); + MigKey b = MigKey::derive(master); + CHECK(std::memcmp(a.k.data(), b.k.data(), 16) == 0, "key derive stable"); + /* The control key is NOT the master key (domain separation). */ + CHECK(std::memcmp(a.k.data(), master.data(), 16) != 0, + "control key != master"); + const auto other = + devourer::HopSchedule::parse_seed("0f0e0d0c0b0a09080706050403020100"); + MigKey c = MigKey::derive(other); + CHECK(std::memcmp(a.k.data(), c.k.data(), 16) != 0, "key sensitive to seed"); + } + + /* --- tamper: flipping ANY byte breaks authentication --- */ + { + MigMsg m = sample(MT_PROPOSAL); + std::vector w = mig_encode(m, key); + int survived = 0; + for (size_t i = 0; i < w.size(); ++i) { + std::vector t = w; + t[i] ^= 0x40; + MigMsg r; + MigReason rc = mig_decode(t.data(), t.size(), key, m.link_id, r); + if (rc == MigReason::None) + ++survived; + } + CHECK(survived == 0, "every single-byte tamper is rejected"); + } + + /* --- truncation at every length is rejected --- */ + { + MigMsg m = sample(MT_STATUS); + std::vector w = mig_encode(m, key); + for (size_t len = 0; len < w.size(); ++len) { + MigMsg r; + CHECK(mig_decode(w.data(), len, key, m.link_id, r) != MigReason::None, + "truncated frame rejected"); + } + /* An on-air RX frame carries a trailing 4-byte FCS (and some parsers pad): + * the message is a PREFIX, so a valid frame + trailing bytes must still + * authenticate (the exact bug that made every on-air proposal read + * bad_mac). */ + std::vector fcs = w; + for (int i = 0; i < 4; i++) + fcs.push_back(0xDE); /* junk FCS */ + MigMsg r; + CHECK(mig_decode(fcs.data(), fcs.size(), key, m.link_id, r) == + MigReason::None, + "trailing FCS/pad ignored — message is a prefix"); + CHECK(r.type == m.type && r.generation == m.generation, + "FCS-trailed frame decodes its fields"); + } + + /* --- header rejects: magic, version, type, link --- */ + { + MigMsg m = sample(MT_MARKER); + std::vector w = mig_encode(m, key); + MigMsg r; + std::vector bad = w; + bad[0] ^= 0xFF; /* magic — but this also breaks MAC; check the code path */ + CHECK(mig_decode(bad.data(), bad.size(), key, m.link_id, r) != + MigReason::None, + "bad magic rejected"); + CHECK(mig_decode(w.data(), w.size(), key, 0xFFFFFFFF, r) == + MigReason::BadLinkId, + "wrong link id rejected"); + CHECK(mig_decode(w.data(), w.size(), key, 0, r) == MigReason::None, + "link id 0 skips the check"); + } + + /* --- ReplayWindow: epoch adoption + generation ordering --- */ + { + ReplayWindow rw; + /* first proposal introduces the epoch */ + CHECK(rw.accept_epoch(100, /*may_introduce=*/true), "first epoch adopted"); + CHECK(rw.classify_proposal(5) == 2, "fresh generation"); + CHECK(rw.classify_proposal(5) == 1, "same gen = idempotent re-answer"); + CHECK(rw.classify_proposal(4) == 0, "lower gen = replay rejected"); + CHECK(rw.classify_proposal(6) == 2, "higher gen = fresh"); + /* a COMMIT (non-introducing) with an unknown epoch is rejected */ + CHECK(!rw.accept_epoch(999, /*may_introduce=*/false), + "unknown epoch on non-introducing msg rejected"); + CHECK(rw.accept_epoch(100, false), "known epoch accepted"); + /* peer restart: a new epoch on a STATUS resets the ceiling */ + CHECK(rw.accept_epoch(200, true), "peer restart adopts new epoch"); + CHECK(rw.classify_proposal(1) == 2, + "generation ceiling reset after restart"); + } + + /* --- nonce binding: a commit echoes the ground nonce; a mismatch is + * caught by the consumer (the machine), but the codec preserves it --- */ + { + MigMsg m = sample(MT_COMMIT); + m.ground_nonce_echo = 0x12345678; + std::vector w = mig_encode(m, key); + MigMsg r; + CHECK(mig_decode(w.data(), w.size(), key, m.link_id, r) == MigReason::None, + "commit decodes"); + CHECK(r.ground_nonce_echo == 0x12345678, "nonce echo preserved"); + } + + return fails ? 1 : 0; +} diff --git a/tests/chanscout_bench.sh b/tests/chanscout_bench.sh new file mode 100755 index 0000000..cc351f8 --- /dev/null +++ b/tests/chanscout_bench.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Two-adapter chanscout smoke bench — the scout's first on-air contract. +# +# Roles (all distinct VID:PIDs on this rig, overridable via env): +# TX — canonical-SA beacon flood (txdemo) parked on ONE candidate +# SCOUT — chanscout sweeping the candidate plan +# The primary receiver is deliberately absent here: this bench validates the +# SCOUT side alone (coverage, dwell hygiene, attribution); the primary-impact +# question is tests/chanmig_soak.sh's A/B. +# +# Phases: +# 1. quiet — no TX: full-plan coverage, sane observe/retune, no wedge +# 2. occupied — TX floods candidate $BUSY_CH: the scout must (a) count the +# airtime under dvr_air_us (recognized own-video, canonical +# SA), and (b) rank $BUSY_CH's bin busier than the quiet +# reference bin by decoded airtime. +# +# Usage: sudo -v && tests/chanscout_bench.sh +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${CHANSCOUT_OUT:-/tmp/devourer-chanscout-bench}" +DUR="${DUR:-20}" # seconds per phase +PLAN="${PLAN:-36,44/40:b,149}" # bins 36, 44+48, 149 +BUSY_CH="${BUSY_CH:-149}" # the candidate the TX occupies +QUIET_CH="${QUIET_CH:-36}" # quiet reference bin +SCOUT_VID="${SCOUT_VID:-0x2357}" # Archer T3U (RTL8822BU, Jaguar2) +SCOUT_PID="${SCOUT_PID:-0x012d}" +TX_VID="${TX_VID:-0x0bda}" # RTL8812CU (Jaguar3) +TX_PID="${TX_PID:-0xc812}" +mkdir -p "$OUT" + +tx_pid="" +cleanup() { + [ -n "$tx_pid" ] && { sudo kill "$tx_pid" 2>/dev/null; wait "$tx_pid" 2>/dev/null; } + true +} +trap cleanup EXIT INT TERM + +plugged() { lsusb -d "$(printf '%04x:%04x' "$1" "$2")" >/dev/null 2>&1; } +plugged "$SCOUT_VID" "$SCOUT_PID" || { echo "SKIP: scout $SCOUT_VID:$SCOUT_PID not plugged"; exit 77; } +plugged "$TX_VID" "$TX_PID" || { echo "SKIP: tx $TX_VID:$TX_PID not plugged"; exit 77; } + +echo "== build ==" +cmake --build "$ROOT/build" -j --target chanscout txdemo >/dev/null || exit 1 + +scout() { # $1=phase-tag $2=duration + sudo env DEVOURER_VID="$SCOUT_VID" DEVOURER_PID="$SCOUT_PID" \ + DEVOURER_SCOUT_PLAN="$PLAN" DEVOURER_SCOUT_DWELL_MS=100 \ + DEVOURER_SCOUT_SETTLE_MS=30 DEVOURER_SCOUT_BACKUP_MS=1000 \ + DEVOURER_SCOUT_BG_MS=3000 DEVOURER_SCOUT_NOTE="bench-$1" \ + timeout "$2" "$ROOT/build/chanscout" >"$OUT/scout-$1.jsonl" 2>"$OUT/scout-$1.err" + local rc=$? + # timeout(1) returns 124 for a handled SIGTERM at expiry (the normal end + # of a fixed-window run) and 137 when it had to escalate to SIGKILL — a + # shutdown-path hang, which is a failure here. + [ $rc -eq 0 ] || [ $rc -eq 124 ] || { echo "FAIL: scout exit rc=$rc (phase $1)"; return 1; } + return 0 +} + +echo "== phase 1: quiet sweep (${DUR}s) ==" +scout quiet "$DUR" || exit 1 + +echo "== phase 2: occupied candidate ch$BUSY_CH (${DUR}s) ==" +sudo env DEVOURER_VID="$TX_VID" DEVOURER_PID="$TX_PID" \ + DEVOURER_CHANNEL="$BUSY_CH" DEVOURER_TX_RATE=6M DEVOURER_TX_PWR=24 \ + DEVOURER_TX_GAP_US=500 \ + timeout $((DUR + 15)) "$ROOT/build/txdemo" >"$OUT/tx.log" 2>&1 & +tx_pid=$! +sleep 5 # TX bring-up before the scout window opens +scout occupied "$DUR" || exit 1 +cleanup; tx_pid="" + +echo "== analyze ==" +python3 - "$OUT" "$BUSY_CH" "$QUIET_CH" <<'PYEOF' +import json, sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +out, busy_ch, quiet_ch = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) + +def dwells(path): + rows = [] + for line in open(path, errors="replace"): + if '"ev":"survey.dwell"' not in line: + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + pass + return rows + +fails = 0 +def check(ok, msg): + global fails + print((" ok " if ok else " FAIL") + " " + msg) + if not ok: + fails += 1 + +for phase in ("quiet", "occupied"): + rows = dwells(f"{out}/scout-{phase}.jsonl") + print(f"[{phase}] {len(rows)} dwells") + check(len(rows) >= 50, f"{phase}: enough dwells") + bins = {r["chan"] for r in rows} + check(len(bins) >= 4, f"{phase}: all 4 bins covered ({sorted(bins)})") + seqs = [r["seq"] for r in rows] + check(seqs == sorted(seqs) and len(set(seqs)) == len(seqs), + f"{phase}: seq monotone, no dupes") + gaps = sum(b - a - 1 for a, b in zip(seqs, seqs[1:])) + check(gaps == 0, f"{phase}: no lost records (gaps={gaps})") + bad = [r for r in rows if r["flags"] & 0x2] # RetuneFailed + check(len(bad) == 0, f"{phase}: zero retune failures") + obs = [r["observe_ms"] for r in rows if not (r["flags"] & 0x1)] + check(obs and min(obs) >= 80, f"{phase}: observe window intact (min={min(obs) if obs else 0})") + ret = sorted(r["retune_us"] for r in rows) + print(f" retune_us p50={ret[len(ret)//2]} p95={ret[int(len(ret)*0.95)]}") + rounds = max(r["round"] for r in rows) + check(rounds >= 2, f"{phase}: multiple complete rounds ({rounds})") + +# Occupied-phase attribution: the canonical-SA flood must land in dvr_air_us +# on the busy bin, and the busy bin must out-rank the quiet reference. +occ = dwells(f"{out}/scout-occupied.jsonl") +def air(rows, ch, key): + sel = [r for r in rows if r["chan"] == f"5:{ch}/20" and r["observe_ms"] > 0] + if not sel: + return 0.0 + return sum(r[key] / (r["observe_ms"] * 1000.0) for r in sel) / len(sel) +busy_dvr = air(occ, busy_ch, "dvr_air_us") +busy_oth = air(occ, busy_ch, "oth_air_us") +quiet_dvr = air(occ, quiet_ch, "dvr_air_us") +print(f"[attribution] busy ch{busy_ch}: dvr_air={busy_dvr:.3f} oth_air={busy_oth:.3f}; " + f"quiet ch{quiet_ch}: dvr_air={quiet_dvr:.3f}") +check(busy_dvr > 0.02, "flooded candidate shows recognized devourer airtime") +check(busy_dvr > 10 * max(quiet_dvr, 1e-6), "busy bin out-ranks quiet bin") +check(busy_dvr > busy_oth, "flood attributed to devourer, not foreign") + +health = [json.loads(l) for l in open(f"{out}/scout-occupied.jsonl", errors="replace") + if '"ev":"scout.health"' in l] +wedged = [h for h in health if h.get("state") == "wedged"] +check(len(wedged) == 0, "no wedged health state") + +print("PASS" if fails == 0 else f"{fails} FAILURES") +sys.exit(1 if fails else 0) +PYEOF +rc=$? +echo "logs: $OUT" +exit $rc diff --git a/tests/chanscout_stress.sh b/tests/chanscout_stress.sh new file mode 100755 index 0000000..336be27 --- /dev/null +++ b/tests/chanscout_stress.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# chanscout retune stress — >=10,000 dwell retunes in one continuous run. +# +# The scout's whole job is to retune forever; this proves the FastRetune-per- +# dwell duty cycle survives at 10^4 scale without wedging the adapter, without +# retune-latency drift (a USB/firmware degradation tell), and with the record +# stream intact end to end. Fast dwells (50 ms observe / 20 ms settle) put +# ~13 retunes/s on the adapter, so the target lands in ~13-15 minutes. +# +# Afterwards the adapter gets a doctor grade (EFUSE stability / fw boot / RX +# smoke) so a marginal-but-not-dead unit is caught here, not mid-soak. +# +# Usage: sudo -v && tests/chanscout_stress.sh +# TARGET=2000 tests/chanscout_stress.sh # quick variant +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${CHANSCOUT_OUT:-/tmp/devourer-chanscout-stress}" +TARGET="${TARGET:-10000}" # required survey.dwell count +PLAN="${PLAN:-36,44/40:b,149}" +SCOUT_VID="${SCOUT_VID:-0x2357}" +SCOUT_PID="${SCOUT_PID:-0x012d}" +SKIP_DOCTOR="${SKIP_DOCTOR:-0}" +mkdir -p "$OUT" + +plugged() { lsusb -d "$(printf '%04x:%04x' "$1" "$2")" >/dev/null 2>&1; } +plugged "$SCOUT_VID" "$SCOUT_PID" || { echo "SKIP: scout $SCOUT_VID:$SCOUT_PID not plugged"; exit 77; } + +echo "== build ==" +cmake --build "$ROOT/build" -j --target chanscout doctor >/dev/null || exit 1 + +# ~13.5 dwells/s at these cadences; cap generously past the target. +CAP=$(( TARGET / 10 + 300 )) +echo "== stress: target $TARGET dwells, cap ${CAP}s ==" +sudo env DEVOURER_VID="$SCOUT_VID" DEVOURER_PID="$SCOUT_PID" \ + DEVOURER_SCOUT_PLAN="$PLAN" DEVOURER_SCOUT_DWELL_MS=50 \ + DEVOURER_SCOUT_SETTLE_MS=20 DEVOURER_SCOUT_BACKUP_MS=500 \ + DEVOURER_SCOUT_BG_MS=2000 DEVOURER_SCOUT_NOTE=stress \ + DEVOURER_LOG_LEVEL=info \ + timeout "$CAP" "$ROOT/build/chanscout" >"$OUT/scout.jsonl" 2>"$OUT/scout.err" +rc=$? +[ $rc -eq 0 ] || [ $rc -eq 124 ] || { echo "FAIL: scout exit rc=$rc"; exit 1; } + +echo "== analyze ==" +python3 - "$OUT/scout.jsonl" "$TARGET" <<'PYEOF' +import json, sys +path, target = sys.argv[1], int(sys.argv[2]) +rows, health = [], [] +for line in open(path, errors="replace"): + if '"ev":"survey.dwell"' in line: + try: rows.append(json.loads(line)) + except json.JSONDecodeError: pass + elif '"ev":"scout.health"' in line: + try: health.append(json.loads(line)) + except json.JSONDecodeError: pass + +fails = 0 +def check(ok, msg): + global fails + print((" ok " if ok else " FAIL") + " " + msg) + if not ok: fails += 1 + +n = len(rows) +print(f"{n} dwells captured") +check(n >= target, f"dwell count >= {target}") +retune_fail = sum(1 for r in rows if r["flags"] & 0x2) +check(retune_fail == 0, f"zero retune failures ({retune_fail})") +suspect = sum(1 for r in rows if r["flags"] & 0x8) +check(suspect <= n // 1000, f"counter-suspect rate sane ({suspect})") +seqs = [r["seq"] for r in rows] +gaps = sum(b - a - 1 for a, b in zip(seqs, seqs[1:])) +check(gaps == 0, f"record stream intact (gaps={gaps})") + +# Retune-latency drift: p95 of the last 1000 vs the first 1000 — a wedging +# USB stack or degrading firmware shows here long before a hard failure. +ret = [r["retune_us"] for r in rows if not (r["flags"] & 0x20)] +if len(ret) >= 2000: + head = sorted(ret[:1000]); tail = sorted(ret[-1000:]) + p95h, p95t = head[950], tail[950] + p50h, p50t = head[500], tail[500] + print(f" retune_us p50 {p50h}->{p50t}, p95 {p95h}->{p95t}") + check(p95t <= 3 * p95h, "no retune-latency drift (p95 tail <= 3x head)") +wedged = [h for h in health if h.get("state") == "wedged"] +check(not wedged, "never wedged") +degraded = {h.get("reason") for h in health if h.get("state") == "degraded"} +print(f" degraded reasons seen: {sorted(degraded) if degraded else 'none'}") + +rate = n / max((rows[-1]["end_ms"] - rows[0]["start_ms"]) / 1000.0, 1) +print(f" sustained {rate:.1f} dwells/s") +print("PASS" if fails == 0 else f"{fails} FAILURES") +sys.exit(1 if fails else 0) +PYEOF +rc=$? +[ $rc -eq 0 ] || exit $rc + +if [ "$SKIP_DOCTOR" != 1 ] && [ -x "$ROOT/build/doctor" ]; then + echo "== post-stress doctor grade ==" + sudo env DEVOURER_VID="$SCOUT_VID" DEVOURER_PID="$SCOUT_PID" \ + "$ROOT/build/doctor" >"$OUT/doctor.jsonl" 2>"$OUT/doctor.err" + drc=$? + echo "doctor exit=$drc (0=HEALTHY)" + [ $drc -eq 0 ] || { echo "FAIL: adapter graded unhealthy after stress"; exit 1; } +fi +echo "PASS: $TARGET-retune stress complete; logs: $OUT" diff --git a/tests/scan_plan_selftest.cpp b/tests/scan_plan_selftest.cpp new file mode 100644 index 0000000..965e0f9 --- /dev/null +++ b/tests/scan_plan_selftest.cpp @@ -0,0 +1,217 @@ +/* ScanScheduler policy selftest — synthetic clock, no hardware. + * + * Pins: the initial sweep covers every bin exactly once in plan order; the + * revisit-priority invariant (backup bins visited more often, yet NO bin's + * inter-visit gap ever exceeds its cadence bound — the starvation guard); + * round advancement only on full coverage; failed-bin retry pushback; and + * byte-for-byte determinism of the dwell sequence. */ +#include "chanmig/ScanPlan.h" + +#include +#include +#include +#include + +static int fails; +#define CHECK(x, msg) \ + do { \ + if (!(x)) { \ + std::fprintf(stderr, "FAIL: %s\n", msg); \ + ++fails; \ + } \ + } while (0) + +using devourer::chanmig::ChannelDef; +using devourer::chanmig::parse_scan_plan; +using devourer::chanmig::PlanParseError; +using devourer::chanmig::ScanPlanConfig; +using devourer::chanmig::ScanScheduler; + +static ScanPlanConfig make_cfg(const char *plan) { + ScanPlanConfig cfg; + std::vector errs; + parse_scan_plan(plan, cfg.candidates, errs); + return cfg; +} + +int main() { + /* Plan: one 20 MHz bg candidate, one 40 MHz backup (bins 44+48), one more + * bg 20 MHz. Unique bins: 36, 44, 48, 149. */ + ScanPlanConfig cfg = make_cfg("36,44/40:b,149"); + cfg.dwell_ms = 100; + cfg.backup_revisit_ms = 1000; + cfg.bg_revisit_ms = 5000; + cfg.fail_retry_ms = 250; + + { + ScanScheduler s(cfg); + CHECK(s.bin_count() == 4, "4 unique bins"); + + /* Initial sweep: plan order, each bin exactly once, then round 1. */ + const uint8_t want[4] = {36, 44, 48, 149}; + int64_t now = 0; + for (int i = 0; i < 4; i++) { + auto p = s.next(now); + CHECK(p.valid && !p.full_width, "initial sweep dwell valid"); + CHECK(p.bin_ch == want[i], "initial sweep in plan order"); + CHECK(p.def.width == CHANNEL_WIDTH_20, "bin dwells are 20 MHz"); + CHECK(p.round == 0, "round 0 during first sweep"); + s.complete(p, now, true); + now += 100; + } + CHECK(s.rounds_complete() == 1, "round advances on full coverage"); + + /* Steady state over 30 simulated seconds: backup bins dominate, but no + * bin's inter-visit gap exceeds its cadence bound (+ one dwell slack). */ + std::map visits; + std::map last_seen; + std::map worst_gap; + for (uint8_t b : want) { + last_seen[b] = now; + worst_gap[b] = 0; + } + for (int i = 0; i < 300; i++) { + auto p = s.next(now); + CHECK(p.valid, "steady-state dwell valid"); + ++visits[p.bin_ch]; + const int64_t gap = now - last_seen[p.bin_ch]; + if (gap > worst_gap[p.bin_ch]) + worst_gap[p.bin_ch] = gap; + last_seen[p.bin_ch] = now; + s.complete(p, now, true); + now += 100; + } + CHECK(visits[44] > visits[36] * 2 && visits[48] > visits[149] * 2, + "backup bins visited more often"); + CHECK(worst_gap[36] <= cfg.bg_revisit_ms + 200 && + worst_gap[149] <= cfg.bg_revisit_ms + 200, + "bg bins never starve past their cadence"); + CHECK(worst_gap[44] <= cfg.backup_revisit_ms + 200 && + worst_gap[48] <= cfg.backup_revisit_ms + 200, + "backup bins hold their cadence"); + CHECK(s.rounds_complete() >= 5, "rounds keep advancing in steady state"); + } + + /* Failed dwells: the scheduler continues with the remaining bins first + * (never head-bangs a wedged bin while others wait), retries the failed + * one at its pushback deadline, counts the streak, and blocks the round + * until the failing bin finally succeeds. */ + { + ScanScheduler s(cfg); + int64_t now = 0; + auto p = s.next(now); + CHECK(p.bin_ch == 36, "first pick in plan order"); + s.complete(p, now, false); + CHECK(s.consecutive_failures() == 1, "failure streak counts"); + now += 100; + /* The other never-visited bins run before the failed bin's retry. */ + const uint8_t rest[3] = {44, 48, 149}; + for (uint8_t want_bin : rest) { + auto q = s.next(now); + CHECK(q.bin_ch == want_bin, "remaining bins continue first"); + s.complete(q, now, true); + now += 100; + } + CHECK(s.rounds_complete() == 0, "round blocked by the failed bin"); + auto r = s.next(now); + CHECK(r.bin_ch == 36, "failed bin retried at its pushback deadline"); + s.complete(r, now, false); + CHECK(s.consecutive_failures() == 1, "streak counts across the sweep"); + now += cfg.fail_retry_ms; + auto r2 = s.next(now); + CHECK(r2.bin_ch == 36, "retry keeps priority once others are fresh"); + s.complete(r2, now, true); + CHECK(s.consecutive_failures() == 0, "success clears the streak"); + CHECK(s.rounds_complete() == 1, "round completes only after recovery"); + } + + /* Determinism: two schedulers over the identical now-sequence produce the + * identical dwell sequence. */ + { + ScanScheduler a(cfg), b(cfg); + int64_t now = 0; + bool same = true; + for (int i = 0; i < 500; i++) { + auto pa = a.next(now); + auto pb = b.next(now); + if (pa.bin_ch != pb.bin_ch || pa.full_width != pb.full_width || + pa.round != pb.round) + same = false; + /* fail every 7th dwell in both */ + const bool ok = (i % 7) != 0; + a.complete(pa, now, ok); + b.complete(pb, now, ok); + now += 100; + } + CHECK(same, "dwell sequence is deterministic"); + } + + /* Wide verification dwells: off by default; when armed they appear at the + * cadence, carry the full candidate def, and never touch bin coverage. */ + { + ScanPlanConfig wcfg = cfg; + wcfg.fullwidth_ms = 1000; + ScanScheduler s(wcfg); + int64_t now = 0; + int wide_seen = 0; + uint64_t rounds_at_first_wide = 0; + for (int i = 0; i < 100; i++) { + auto p = s.next(now); + if (p.full_width) { + ++wide_seen; + CHECK(p.def.width == CHANNEL_WIDTH_40 && p.def.primary == 44, + "wide dwell carries the candidate def"); + if (wide_seen == 1) + rounds_at_first_wide = s.rounds_complete(); + } + s.complete(p, now, true); + now += 100; + } + CHECK(wide_seen >= 8 && wide_seen <= 11, "wide cadence honored"); + CHECK(rounds_at_first_wide >= 1, "first sweep ran before wide dwells"); + ScanScheduler off(cfg); + int64_t n2 = 0; + bool any_wide = false; + for (int i = 0; i < 100; i++) { + auto p = off.next(n2); + any_wide = any_wide || p.full_width; + off.complete(p, n2, true); + n2 += 100; + } + CHECK(!any_wide, "no wide dwells unless armed"); + } + + /* Shared bins: a 20 MHz candidate inside a backup 40 MHz block inherits the + * faster cadence (one physical bin, fastest containing candidate wins). */ + { + ScanPlanConfig sh = make_cfg("44/40:b,48"); + sh.backup_revisit_ms = 1000; + sh.bg_revisit_ms = 5000; + ScanScheduler s(sh); + CHECK(s.bin_count() == 2, "shared bin not duplicated"); + int64_t now = 0; + std::map visits; + for (int i = 0; i < 100; i++) { + auto p = s.next(now); + ++visits[p.bin_ch]; + s.complete(p, now, true); + now += 100; + } + CHECK(visits[48] >= visits[44] - 5 && visits[44] >= visits[48] - 5, + "shared bin rides the backup cadence"); + } + + /* plan_hash: stable across runs, sensitive to candidates and cadences. */ + { + ScanPlanConfig a = make_cfg("36,44/40:b"); + ScanPlanConfig b = make_cfg("36,44/40:b"); + CHECK(a.plan_hash() == b.plan_hash(), "plan hash stable"); + ScanPlanConfig c = make_cfg("36,44/40"); + CHECK(a.plan_hash() != c.plan_hash(), "flags change the hash"); + ScanPlanConfig d = make_cfg("36,44/40:b"); + d.bg_revisit_ms += 1; + CHECK(a.plan_hash() != d.plan_hash(), "cadence changes the hash"); + } + + return fails ? 1 : 0; +} diff --git a/tests/survey_agg_selftest.cpp b/tests/survey_agg_selftest.cpp new file mode 100644 index 0000000..4c35947 --- /dev/null +++ b/tests/survey_agg_selftest.cpp @@ -0,0 +1,326 @@ +/* EvidenceStore fold-path + SurveyJsonl round-trip + JsonlLite selftest. + * + * The fold path is the aggregator's trust boundary: synthetic counter wrap, + * missing constituent bins, stale and delayed records, width overlap, mixed + * calibration domains, and failure-flagged dwells must all be handled + * exactly — a record that should not rank must never rank. */ +#include "chanmig/EvidenceStore.h" +#include "chanmig/JsonlLite.h" +#include "chanmig/SurveyJsonl.h" + +#include +#include +#include +#include + +static int fails; +#define CHECK(x, msg) \ + do { \ + if (!(x)) { \ + std::fprintf(stderr, "FAIL: %s\n", msg); \ + ++fails; \ + } \ + } while (0) + +using namespace devourer::chanmig; +using Fold = EvidenceStore::Fold; + +static bool near(double a, double b) { return a > b - 1e-9 && a < b + 1e-9; } + +static std::vector plan() { + std::vector out; + std::vector errs; + /* 36/20, the 44+48 pair, and a 20 MHz candidate INSIDE that pair (width + * overlap: bin 44 serves both). */ + parse_scan_plan("36,44/40,44", out, errs); + return out; +} +static constexpr uint32_t kPlanHash = 0xabcd1234; +static constexpr int64_t kMaxAge = 60000; + +static SurveyDwell dwell(uint8_t bin, int64_t t_end, uint64_t round = 0, + uint32_t scout = 1) { + SurveyDwell d; + d.def.band = 5; + d.def.primary = bin; + d.def.width = CHANNEL_WIDTH_20; + d.plan_hash = kPlanHash; + d.t_start_ms = t_end - 130; + d.t_end_ms = t_end; + d.observe_ms = 100; + d.settle_ms = 30; + d.retune_us = 800; + d.valid_fa = true; + d.cca_ofdm = 40; + d.fa_ofdm = 12; + d.valid_igi = true; + d.igi = 0x30; + d.valid_nhm = true; + d.nhm_busy_pct = 10; + d.frames = 5; + d.dvr_air_us = 2000; + d.oth_air_us = 8000; + d.round = round; + d.scout_id = scout; + return d; +} + +int main() { + /* --- accept path + generation + rate reduction --- */ + { + EvidenceStore s(plan(), kPlanHash, kMaxAge); + CHECK(s.candidate_count() == 3, "3 candidates"); + CHECK(s.generation() == 0, "generation starts at 0"); + CHECK(s.ingest(dwell(36, 1000), 1100) == Fold::Accepted, "accept"); + CHECK(s.generation() == 1, "generation increments"); + std::vector cells; + s.fresh_cells(36, 1100, cells); + CHECK(cells.size() == 1, "cell stored"); + CHECK(near(cells[0]->cca_rate, 400.0) && near(cells[0]->fa_rate, 120.0), + "per-second rate reduction"); + CHECK(near(cells[0]->dvr_air_frac, 0.02) && + near(cells[0]->oth_air_frac, 0.08), + "airtime fractions"); + CHECK(s.scout_id() == 1, "scout identity latched"); + } + + /* --- counter wrap / implausible deltas --- */ + { + EvidenceStore s(plan(), kPlanHash, kMaxAge); + SurveyDwell d = dwell(36, 1000); + d.cca_ofdm = 0xFFFFFF00u; /* a wrapped counter reads as an absurd rate */ + CHECK(s.ingest(d, 1100) == Fold::RejectedSuspect, "wrap rejected"); + SurveyDwell f = dwell(36, 1000); + f.flags |= kFlagCounterSuspect; + CHECK(s.ingest(f, 1100) == Fold::RejectedSuspect, + "producer-flagged suspect rejected"); + CHECK(s.generation() == 0, "rejects never bump the generation"); + CHECK(s.fold_count(Fold::RejectedSuspect) == 2, "reject reason counted"); + /* boundary: exactly at the ceiling is plausible */ + SurveyDwell b = dwell(36, 1000); + b.cca_ofdm = 100 * 1000; /* kMaxCountsPerMs * observe_ms */ + CHECK(s.ingest(b, 1100) == Fold::Accepted, "ceiling itself accepted"); + } + + /* --- missing bins => width-incomplete --- */ + { + EvidenceStore s(plan(), kPlanHash, kMaxAge); + (void)s.ingest(dwell(44, 1000), 1100); + /* candidate 1 is 44/40 (bins 44+48): only one bin covered */ + CHECK(s.bins_covered(1, 1100) == 1 && s.bin_count(1) == 2, + "wide candidate width-incomplete"); + CHECK(s.evidence_age_ms(1, 1100) == -1, + "incomplete width has no evidence age"); + (void)s.ingest(dwell(48, 2000), 2100); + CHECK(s.bins_covered(1, 2100) == 2, "full width covered"); + CHECK(s.evidence_age_ms(1, 2100) == 1100, + "age = oldest freshest constituent bin"); + } + + /* --- width overlap: a shared bin serves both candidates --- */ + { + EvidenceStore s(plan(), kPlanHash, kMaxAge); + (void)s.ingest(dwell(44, 1000), 1100); + /* candidate 2 is 44/20 — the same bin covers it fully */ + CHECK(s.bins_covered(2, 1100) == 1 && s.bin_count(2) == 1, + "shared bin covers the 20 MHz candidate"); + CHECK(s.evidence_age_ms(2, 1100) == 100, "age from the shared cell"); + } + + /* --- stale and delayed records --- */ + { + EvidenceStore s(plan(), kPlanHash, kMaxAge); + CHECK(s.ingest(dwell(36, 1000), 1000 + kMaxAge) == Fold::Accepted, + "exactly max-age accepted"); + CHECK(s.ingest(dwell(36, 1000), 1000 + kMaxAge + 1) == Fold::RejectedStale, + "past max-age rejected"); + /* Delayed out-of-order arrival: an older-but-fresh record folds, and the + * newest cell (by record time, not arrival) still wins. */ + CHECK(s.ingest(dwell(36, 5000), 5100) == Fold::Accepted, "newer record"); + CHECK(s.ingest(dwell(36, 3000), 5200) == Fold::Accepted, + "delayed record folds by record time"); + std::vector cells; + s.fresh_cells(36, 5200, cells); + CHECK(cells.size() == 3, "all fresh cells kept"); + /* Freshness is judged against record time as the store ages. */ + s.fresh_cells(36, 3000 + kMaxAge + 1, cells); + bool has3000 = false; + for (auto *c : cells) + has3000 = has3000 || c->t_ms == 3000; + CHECK(!has3000, "delayed record ages out by its record time"); + } + + /* --- failure-flagged dwells never fold --- */ + { + EvidenceStore s(plan(), kPlanHash, kMaxAge); + for (uint16_t flag : {kFlagRetuneFailed, kFlagReadFailed, kFlagTruncated}) { + SurveyDwell d = dwell(36, 1000); + d.flags |= flag; + CHECK(s.ingest(d, 1100) == Fold::RejectedFlags, "failure flag rejected"); + } + SurveyDwell z = dwell(36, 1000); + z.observe_ms = 0; + CHECK(s.ingest(z, 1100) == Fold::RejectedFlags, "zero observation"); + } + + /* --- unknown bin / plan mismatch --- */ + { + EvidenceStore s(plan(), kPlanHash, kMaxAge); + CHECK(s.ingest(dwell(149, 1000), 1100) == Fold::RejectedUnknownBin, + "bin outside the plan"); + SurveyDwell d = dwell(36, 1000); + d.plan_hash = 0x1111; + CHECK(s.ingest(d, 1100) == Fold::RejectedPlanMismatch, "plan mismatch"); + } + + /* --- mixed calibration domains reset the store --- */ + { + EvidenceStore s(plan(), kPlanHash, kMaxAge); + (void)s.ingest(dwell(36, 1000, 0, 1), 1100); + (void)s.ingest(dwell(44, 1000, 0, 1), 1100); + CHECK(s.ingest(dwell(48, 2000, 0, 2), 2100) == Fold::RejectedMixedScout, + "different scout rejected"); + std::vector cells; + s.fresh_cells(36, 2100, cells); + CHECK(cells.empty(), "rings reset on domain change"); + CHECK(s.scout_id() == 2, "new domain adopted"); + CHECK(s.ingest(dwell(48, 3000, 0, 2), 3100) == Fold::Accepted, + "new domain folds after reset"); + } + + /* --- rounds covered + ring bound --- */ + { + EvidenceStore s(plan(), kPlanHash, kMaxAge); + (void)s.ingest(dwell(36, 1000, 3), 1100); + (void)s.ingest(dwell(36, 2000, 5), 2100); + CHECK(s.rounds_covered(0, 2100) == 3, "rounds span (hi-lo+1)"); + for (int i = 0; i < 40; i++) + (void)s.ingest(dwell(36, 3000 + i, 6), 3100 + i); + std::vector cells; + s.fresh_cells(36, 3200, cells); + CHECK(cells.size() == 32, "ring bounded"); + } + + /* --- full-width dwells land in the candidate's wide ring --- */ + { + EvidenceStore s(plan(), kPlanHash, kMaxAge); + SurveyDwell w = dwell(44, 1000); + w.def.width = CHANNEL_WIDTH_40; + w.def.offset = 1; + w.flags |= kFlagFullWidth; + CHECK(s.ingest(w, 1100) == Fold::Accepted, "wide dwell accepted"); + std::vector cells; + s.wide_cells(1, 1100, cells); + CHECK(cells.size() == 1, "wide ring holds it"); + s.fresh_cells(44, 1100, cells); + CHECK(cells.empty(), "wide dwell is not bin evidence"); + SurveyDwell u = w; + u.def.primary = 157; /* a wide def not in the plan */ + CHECK(s.ingest(u, 1100) == Fold::RejectedUnknownBin, + "unknown wide candidate rejected"); + } + + /* --- SurveyJsonl round-trip pins the schema --- */ + { + std::FILE *f = std::tmpfile(); + devourer::EventSink sink; + sink.configure(f); + SurveyDwell d = dwell(44, 12345, 7); + d.def.width = CHANNEL_WIDTH_40; + d.def.offset = 1; + d.flags = kFlagFullWidth; + d.seq = 99; + d.evm_valid = true; + d.evm_mean_raw = -52; + d.nhm[0] = 200; + d.nhm[11] = 3; + d.nhm_dur = 2; + d.nhm_peak = 0; + d.adapter_gen = 2; + d.scout_id = 0x9f3a2c11; + emit_survey_dwell(sink, d); + std::fflush(f); + long n = std::ftell(f); + std::rewind(f); + std::string line(static_cast(n), '\0'); + CHECK(std::fread(line.data(), 1, line.size(), f) == line.size(), + "read back emission"); + std::fclose(f); + + SurveyDwell r; + CHECK(survey_dwell_from_jsonl(line, r), "round-trip parses"); + CHECK(r.def.same_rf(d.def), "channel def survives"); + CHECK(r.seq == d.seq && r.round == d.round && r.flags == d.flags, + "counters survive"); + CHECK(r.t_end_ms == d.t_end_ms && r.observe_ms == d.observe_ms, + "timing survives"); + CHECK(r.valid_fa && r.cca_ofdm == d.cca_ofdm && r.fa_ofdm == d.fa_ofdm, + "energy survives"); + CHECK(r.valid_igi && r.igi == d.igi, "igi survives"); + CHECK(r.valid_nhm && r.nhm_busy_pct == d.nhm_busy_pct && + r.nhm[0] == 200 && r.nhm[11] == 3, + "nhm survives"); + CHECK(r.evm_valid && r.evm_mean_raw == -52, "evm survives"); + CHECK(r.dvr_air_us == d.dvr_air_us && r.oth_air_us == d.oth_air_us, + "airtime survives"); + CHECK(r.scout_id == d.scout_id && r.plan_hash == d.plan_hash, + "identity survives"); + + /* Nullable fields: a generation with no counters emits null, and the + * parse must come back invalid — never a fake zero. */ + std::FILE *f2 = std::tmpfile(); + devourer::EventSink sink2; + sink2.configure(f2); + SurveyDwell d2 = dwell(36, 1000); + d2.valid_fa = false; + d2.valid_igi = false; + d2.valid_nhm = false; + emit_survey_dwell(sink2, d2); + std::fflush(f2); + long n2 = std::ftell(f2); + std::rewind(f2); + std::string line2(static_cast(n2), '\0'); + CHECK(std::fread(line2.data(), 1, line2.size(), f2) == line2.size(), + "read back null emission"); + std::fclose(f2); + SurveyDwell r2; + CHECK(survey_dwell_from_jsonl(line2, r2), "null form parses"); + CHECK(!r2.valid_fa && !r2.valid_igi && !r2.valid_nhm, + "null fields stay invalid"); + } + + /* --- JsonlLite scanner corner cases --- */ + { + const std::string line = + "{\"ev\":\"rx.quality\",\"verdict\":\"HEALTHY\",\"frames\":120," + "\"snr_mean_db\":18.5,\"evm_db\":null," + "\"text\":\"fake \\\"frames\\\":999 inside a string\"," + "\"rssi_dbm\":[-40,-42],\"igi\":48}"; + CHECK(jsonl_ev_is(line, "rx.quality"), "event name match"); + CHECK(!jsonl_ev_is(line, "rx.qual"), "prefix does not match"); + long long v = 0; + CHECK(jsonl_int(line, "frames", &v) && v == 120, "int field"); + double dv = 0; + CHECK(jsonl_num(line, "snr_mean_db", &dv) && dv == 18.5, "double field"); + CHECK(!jsonl_num(line, "evm_db", &dv), "null reads as absent"); + CHECK(!jsonl_num(line, "missing", &dv), "absent key"); + std::string sv; + CHECK(jsonl_str(line, "verdict", &sv) && sv == "HEALTHY", "string field"); + CHECK(jsonl_str(line, "text", &sv) && + sv == "fake \"frames\":999 inside a string", + "escapes unescaped"); + /* The poisoned "frames":999 inside the string value must not shadow the + * real field. */ + CHECK(jsonl_int(line, "frames", &v) && v == 120, + "key inside a string value never matches"); + int arr[4]; + int n = 0; + CHECK(jsonl_arr(line, "rssi_dbm", arr, 4, &n) && n == 2 && arr[0] == -40 && + arr[1] == -42, + "array field"); + CHECK(!jsonl_num("not json at all", "frames", &dv), "non-JSON line"); + CHECK(!jsonl_num("{\"ev\":\"x\",\"k\":", "k", &dv), "truncated line"); + } + + return fails ? 1 : 0; +} diff --git a/tools/chanmig_dash.py b/tools/chanmig_dash.py new file mode 100755 index 0000000..f2babed --- /dev/null +++ b/tools/chanmig_dash.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""chanmig dashboard — a plain-terminal, stdlib-only view of the adaptive +channel-migration state, rendered ENTIRELY from the JSONL logs. + +That it can render the full picture — active-link health, candidate ranking, +evidence age, and the counterfactual reason a move is (not) recommended — +from nothing but the logged events IS the proof that every decision is +explainable from its logged score components. + + python3 tools/chanmig_dash.py --scout scout.jsonl [--primary primary.jsonl] + +Tails both files (append-only; never blocks the producers) and repaints at +1 Hz. No curses — SSH/serial friendly, like the other devourer displays. +""" +import argparse +import json +import sys +import time + +RESET = "\033[0m" +BOLD = "\033[1m" +DIM = "\033[2m" +RED = "\033[31m" +GRN = "\033[32m" +YEL = "\033[33m" +CYN = "\033[36m" + + +def follow(paths): + """Yield (path_key, event_dict) as lines are appended to any file.""" + handles = {} + for key, p in paths.items(): + if p: + try: + handles[key] = open(p, errors="replace") + except OSError: + handles[key] = None + buf = {k: "" for k in handles} + while True: + any_data = False + for key, fh in handles.items(): + if fh is None: + continue + chunk = fh.read() + if chunk: + any_data = True + buf[key] += chunk + while "\n" in buf[key]: + line, buf[key] = buf[key].split("\n", 1) + if '"ev":"' in line: + try: + yield key, json.loads(line) + except json.JSONDecodeError: + pass + if not any_data: + yield None, None + + +class State: + def __init__(self): + self.scout_id = None + self.plan = None + self.cands = {} + self.dwells = 0 + self.health = ("ok", "") + self.decision = None # last channel.recommend/hold + self.ranking = None # last channel.ranking + self.primary_verdict = None + self.primary_frames = 0 + + def update(self, key, ev): + e = ev.get("ev") + if e == "scout.id": + self.scout_id = ev + elif e == "scout.plan": + self.plan = ev + elif e == "scout.cand": + self.cands[ev["i"]] = ev + elif e == "survey.dwell": + self.dwells += 1 + elif e == "scout.health": + self.health = (ev.get("state", "?"), ev.get("reason", "")) + elif e in ("channel.recommend", "channel.hold"): + self.decision = ev + elif e == "channel.ranking": + self.ranking = ev + elif e in ("rx.quality", "link.health"): + self.primary_verdict = ev.get("verdict") + elif e == "rx.txhit": + self.primary_frames += 1 + + +def color_verdict(v): + if v in ("HEALTHY",): + return GRN + v + RESET + if v in ("MARGINAL", "WEAK"): + return YEL + v + RESET + if v in ("SATURATED", "INTERFERENCE", "NO_SIGNAL"): + return RED + (v or "?") + RESET + return v or "-" + + +def render(st): + out = ["\033[2J\033[H"] # clear + home + out.append(BOLD + "adaptive channel migration — scout advisory" + RESET) + if st.scout_id: + out.append(DIM + f"scout {st.scout_id.get('chip','?')} " + f"{st.scout_id.get('usb_id','?')} bus{st.scout_id.get('bus','?')} " + f"id={st.scout_id.get('scout_id','?')} " + f"note={st.scout_id.get('note','')}" + RESET) + if st.plan: + out.append(DIM + f"plan {st.plan.get('plan','?')} " + f"{st.plan.get('n_candidates','?')} candidates " + f"dwell {st.plan.get('dwell_ms','?')}ms" + RESET) + hs, hr = st.health + hc = GRN if hs == "ok" else (RED if hs == "wedged" else YEL) + out.append(f"dwells={st.dwells} scout={hc}{hs}{RESET}" + + (f"({hr})" if hr else "") + + f" primary={color_verdict(st.primary_verdict)}" + f" frames={st.primary_frames}") + out.append("") + + d = st.decision + if d: + kind = d["ev"].split(".")[1] + kc = GRN if kind == "recommend" else CYN + out.append(BOLD + f"decision: {kc}{kind.upper()}{RESET}" + f" from {d.get('from','?')}" + + (f" -> {GRN}{d.get('to','?')}{RESET}" if "to" in d else "")) + out.append(f" reason: {d.get('reason','?')}") + out.append(f" active: {color_verdict(d.get('active_verdict'))} " + f"domain={d.get('active_domain','?')} " + f"impaired_windows={d.get('impaired_windows','?')}") + out.append(DIM + " " + d.get("text", "") + RESET) + else: + out.append(DIM + "decision: (waiting for evidence)" + RESET) + out.append("") + + out.append(BOLD + "candidate ranking (why each is / isn't a target)" + RESET) + if st.ranking: + rank = 0 + while f"c{rank}" in st.ranking: + out.append(f" {rank+1}. {st.ranking[f'c{rank}']}") + rank += 1 + else: + out.append(DIM + " (no ranking yet)" + RESET) + out.append("") + out.append(DIM + f"updated {time.strftime('%H:%M:%S')} " + "(rendered purely from JSONL — this IS the explainability proof)" + + RESET) + sys.stdout.write("\n".join(out) + "\n") + sys.stdout.flush() + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--scout", required=True) + ap.add_argument("--primary") + ap.add_argument("--once", action="store_true", + help="ingest all available, render once, exit (for tests)") + args = ap.parse_args() + + st = State() + if args.once: + for key, p in (("scout", args.scout), ("primary", args.primary)): + if not p: + continue + try: + for line in open(p, errors="replace"): + if '"ev":"' in line: + try: + st.update(key, json.loads(line)) + except json.JSONDecodeError: + pass + except OSError: + pass + render(st) + return + + last = 0.0 + for key, ev in follow({"scout": args.scout, "primary": args.primary}): + if ev is not None: + st.update(key, ev) + now = time.time() + if now - last >= 1.0: + render(st) + last = now + if ev is None: + time.sleep(0.2) + + +if __name__ == "__main__": + main()