mirror of
https://github.com/ruvnet/RuView
synced 2026-08-01 19:01:42 +00:00
fix(adr): resolve duplicate ADR numbers + close ADR-080 security + ADR-154 M1 signal backlog (#1051)
* fix(signal): circular phase variance for ghost-tap guard (ADR-154 §7.4 #1) `phase_variance` computed a LINEAR sample variance over phase angles that wrap at ±π, so a tightly-clustered set straddling the branch cut reported spuriously HIGH dispersion — false-tripping the `> TAU` ghost-tap guard on real, tightly-clustered CIR taps. Replace with Mardia's circular variance V = 1 − R̄, bounded [0,1] and invariant to where the cluster sits on the circle. Re-derive the guard against the bounded metric via a named const `GHOST_TAP_CIRCULAR_VARIANCE_MAX` (the old TAU-scaled threshold is meaningless on [0,1]). Grade: metric fix MEASURED; threshold value DATA-GATED — a clean single-path ramp also sweeps the circle, so V alone cannot separate clean from unsanitized without labelled frames. Conservative default (0.99) errs toward never false-rejecting, strictly more permissive at the wrap boundary than the buggy linear guard. Fails-on-old test: `phase_variance_circular_not_fooled_by_branch_cut` — inlines the old linear variance to show it exceeds TAU on wrap-straddling phases while circular V≈0 and the guard no longer trips. Plus `phase_variance_circular_is_bounded_and_extremal` (V∈[0,1], V≈0 identical, V≈1 uniform). cargo test -p wifi-densepose-signal --no-default-features --features cir --lib → 432 passed, 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * fix(signal): pin Welford n=0/n=1 finiteness guard (ADR-154 §7.4 #10) The shared `WelfordStats` (field_model.rs, used by longitudinal.rs and others) relies on `count < 2` guards in `variance`/`sample_variance`/`std_dev`/ `z_score` to stay finite at the boundaries. The guards existed but the n=0 boundary was UNTESTED — exactly the §4 divide-by-(n−1) family the ADR groups this with. Add `welford_finite_at_n0_and_n1` asserting every statistic is finite and returns the documented sentinel (0.0) at n=0 and n=1, plus load-bearing doc comments on the two guards. Fails-on-old proof: with the `sample_variance` guard removed, the test FAILS with "attempt to subtract with overflow" at the `(self.count - 1)` underflow (0usize − 1); `variance` would similarly yield 0.0/0.0 = NaN. The guard is restored; the test pins it so a future regression is caught. Grade: MEASURED (boundary finiteness is asserted; the guard is the §4-family fix made testable). cargo test -p wifi-densepose-signal --no-default-features --lib field_model → 22 passed, 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic adversarial thresholds + boundary tests (ADR-154 §7.4 #13) Lift the bare numeric literals buried in `check`/`check_consistency` into named, documented module consts (FIELD_MODEL_GINI_VIOLATION=0.8, ENERGY_RATIO_HIGH_VIOLATION=2.0, ENERGY_RATIO_LOW_VIOLATION=0.1, CONSISTENCY_ACTIVE_FRACTION_OF_MEAN=0.1, SCORE_W_* weights). VALUES UNCHANGED — each const equals the original literal; only names + pinning tests are new. Grade: DATA-GATED. The operating values stay empirical (defensible values need labelled spoofed/clean CSI — Wi-Spoof, §6.2/§7.3). The de-magicking + characterization tests are MEASURED: `tuning_consts_unchanged_from_literals`, `energy_ratio_high_boundary`, `energy_ratio_low_boundary`, `field_model_gini_boundary`, `consistency_active_fraction_boundary` pin the decision boundaries at/just-below/just-above each threshold, so a future data-driven retune is a visible, tested change. Fails-on-change proof: bumping ENERGY_RATIO_HIGH_VIOLATION 2.0→3.0 makes `energy_ratio_high_boundary` FAIL (restored). Operating values explicitly NOT changed. cargo test -p wifi-densepose-signal --no-default-features --lib ruvsense::adversarial → 20 passed, 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic coherence drift/gate thresholds (ADR-154 §7.4 #9) Lift the bare detection literals in `coherence.rs::classify_drift` (DRIFT_STABLE_SCORE=0.85, DRIFT_STEP_CHANGE_MAX_STALE=10) and the `coherence_gate.rs` Default impl (DEFAULT_ACCEPT_THRESHOLD=0.85, DEFAULT_REJECT_THRESHOLD=0.5, DEFAULT_MAX_STALE_FRAMES=200, DEFAULT_PREDICT_ONLY_NOISE=3.0) into named, documented consts. VALUES UNCHANGED. The gate already exposed these via GatePolicyConfig (config seam); this names + pins the defaults. Grade: DATA-GATED. Operating values stay empirical (defensible Z-score thresholds need labelled stable/drifting coherence traces). De-magicking + boundary tests are MEASURED: `classify_drift_stable_score_boundary`, `classify_drift_stale_count_boundary` pin the at/just-below/just-above decisions; `drift_consts_unchanged_from_literals` / `gate_default_consts_unchanged_from_literals` pin the values. Operating values explicitly NOT changed. cargo test -p wifi-densepose-signal --no-default-features --lib ruvsense::coherence → 40 passed, 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr-154): mark §7.4 P1 backlog cleared — Milestone-1 (#1,#10 RESOLVED; #9,#13 DATA-GATED) Update ADR-154 §7.4 backlog rows #1, #9, #10, #13 with commit refs + grades, the §7.4 intro count (four P1 items cleared, ~41 P2/P3 remain), the Horizon-ledger one-liner (Milestone-1 DONE), and the §8 honest-limits #1 line (metric now correct; threshold still DATA-GATED). Add CHANGELOG [Unreleased] entry. Grades: #1 RESOLVED (MEASURED metric / DATA-GATED threshold), #10 RESOLVED (MEASURED), #9 & #13 RESOLVED-PARTIAL (DATA-GATED — de-magicked + boundary tested, operating values unchanged). Validation: cargo test --workspace --no-default-features → 2057 passed, 0 failed; wifi-densepose-signal lib → 442 passed (no-default + --features cir); python archive/v1/data/proof/verify.py → VERDICT: PASS, hash f8e76f21…46f7a UNCHANGED (CIR ghost-tap guard is not on the deterministic proof path). Co-Authored-By: claude-flow <ruv@ruv.net> * fix(sensing-server): stop leaking internal errors in HTTP responses (ADR-080 #2) Six handlers in `main.rs` serialized the internal error `Display` straight into the JSON response body, leaking server internals to any client (ADR-080 finding #2, CWE-209; reframed onto the Rust boundary by ADR-164 G11): - edge_registry_endpoint: a panicked spawn_blocking `JoinError` ("task … panicked") in a 500, and the raw upstream error in a 503 - delete_model / delete_recording / start_recording: std::io::Error strings carrying OS detail / filesystem paths - calibration_start / calibration_stop: the FieldModel error chain New `error_response` module: `internal_error` / `internal_error_json` / `upstream_unavailable` log the full detail server-side only (tagged with a correlation id) and return a generic body (`{"error":"internal_error","correlation_id":…}`) — no `panicked`, no file paths, no Debug chain. The correlation id lets an operator join a client report to the exact server log line without ever shipping the detail. Pinned by 5 error_response tests, incl. a leak-substring guard (internal_error_body_does_not_leak_detail) verified to FAIL on the reverted old body (returns the panic message / path / "os error"). The HOMECORE sweep (ADR-161) covered homecore-server, not this crate. Co-Authored-By: claude-flow <ruv@ruv.net> * test(sensing-server): pin XFF-immunity + no-query-token (ADR-080 #1, #3) Findings #1 (XFF-spoofing bypass) and #3 (JWT-in-URL, CWE-598) were logged against the Python v1 API but are VERIFIED ABSENT on the current Rust sensing-server, so they get regression tests rather than redundant fixes: - #1 XFF: there is no IP-based rate-limiter or IP-allowlist to bypass, and neither security middleware reads a forwarded header. Added bearer_auth::xff_header_never_affects_auth_decision (spoofed X-Forwarded-For never flips a 401<->200 decision) and host_validation::forwarded_headers_never_bypass_host_allowlist (spoofed X-Forwarded-Host: localhost never lets Host: evil.com past the allowlist). - #3 JWT-in-URL: require_bearer reads the token only from the Authorization header; WS handlers take no query token; the sole Query extractor (EdgeRegistryParams) is a non-secret refresh flag. Added bearer_auth::query_string_token_is_never_accepted — ?token= / ?access_token= in the URL never authenticates (stays 401) while the header path still 200s. Verified to FAIL when a query-token path is injected into require_bearer. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr-080): mark P0 security findings #1-#3 RESOLVED; close ADR-164 G11 - ADR-080: Status note + per-finding closure (#1 XFF and #3 JWT-in-URL verified absent + regression-pinned; #2 leaked errors fixed via the error_response module). Records the v1-vs-Rust boundary distinction explicitly: v1 paths remain archived; this closure governs the shipped Rust sensing-server. - ADR-164: Gap Register G11 and the Open/Gated Backlog entry marked RESOLVED with the fix + branch reference. - CHANGELOG: [Unreleased] -> ### Security entry covering all three findings. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr): renumber 6 displaced ADRs to resolve duplicate-number collisions (ADR-164 G1) Resolves the 5 duplicate ADR numbers (6 displaced files) flagged by ADR-164 Gap Register item G1. Canonical keeper per number = first file committed at that number (date tie-broken by inbound cross-reference count / parent-appendix relationship). Displaced files renumbered to the next free numbers (166-171): 050 keeps provisioning-tool-enhancements (5 refs vs 1) -> ADR-166-quality-engineering-security-hardening 052 keeps tauri-desktop-frontend (parent ADR) -> ADR-167-ddd-bounded-contexts (its appendix) 147 keeps nvidia-cosmos/OccWorld (the actual ADR, has Status header) -> ADR-168-benchmark-proof (proof companion, no Status) -> ADR-169-adam-mode-light-theme (was untracked) 148 keeps drone-swarm-control-system (committed #862) -> ADR-170-yoga-mode-pose-system (was untracked) 149 keeps public-community-leaderboard-huggingface (committed 16:47 vs 17:38) -> ADR-171-swarm-benchmarking-evaluation-methodology Updates in-file `# ADR-NNN` headers and intra-file self-references (yoga-modes * docs(adr): repoint inbound cross-references to renumbered ADRs (166-171) Follow-up to the ADR renumbering (ADR-164 G1). Updates every inbound reference that pointed at a displaced ADR, disambiguating shared numbers by title/slug so only references to the DISPLACED topic move and keeper references stay put. ADR-168 (was 147 benchmark-proof): README, CHANGELOG, user-guide, proof-of-capabilities, research docs 00/03 — all path/label refs updated. ADR-169 (was 147 adam-mode) / ADR-170 (was 148 yoga-mode): docs/adr/README index. ADR-171 (was 149 swarm-benchmarking): all ruview-swarm eval code+docs (Cargo.toml, evals/, eval_swarm.rs, metrics/mod/report/runner.rs), research doc 03 (every §-ref matched ADR-171 sections, not AetherArena), 00-system-review, series README, CHANGELOG, and ADR-148's forward/"open issues" pointers. ADR-166 (was 050 quality-engineering / security-hardening): disambiguated from the ADR-050 provisioning KEEPER by topic. The HMAC/secure_tdm, directory-traversal, bind-address, and OTA-PSK-auth references in code comments (wifi-densepose-hardware Cargo.toml + secure_tdm.rs, sensing-server main.rs) and in ADR-052-tauri / ADR-167 all describe the security-hardening ADR -> ADR-166. ADR-167 (was 052 ddd-appendix): inbound appendix references. Index/registry updates: docs/adr/README.md, gap-analysis/census.md (rows + header count), gap-analysis/lens-findings.md (collision table marked RESOLVED), and ADR-164 Gap Register G1 marked RESOLVED with the full renumber map. Keeper references deliberately untouched: all ADR-147 OccWorld code, all ADR-148 drone-swarm code/docs, all ADR-149 AetherArena refs (incl. ADR-150's SSL/resampling refs, which ADR-150 explicitly binds to the AetherArena benchmark), ADR-050 provisioning refs, ADR-052 tauri refs. The frozen GitHub blob URLs in docs/adr/.issue-177-body.md (pinned to an old branch) are left as historical. Comment-only code edits; no behavior change. wifi-densepose-hardware compiles clean; the sensing-server build's sole blocker is the pre-existing upstream midstreamer-temporal-compare@0.2.1 registry crate, unrelated to these edits. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -79,6 +79,6 @@ harness = false
|
||||
name = "train_marl"
|
||||
required-features = ["train"]
|
||||
|
||||
# ADR-149 Stage-1 evaluation CLI — pure Rust, no special feature needed.
|
||||
# ADR-171 Stage-1 evaluation CLI — pure Rust, no special feature needed.
|
||||
[[bin]]
|
||||
name = "eval_swarm"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# ADR-149 evaluation outputs
|
||||
# ADR-171 evaluation outputs
|
||||
RESULTS.md is generated by the `eval_swarm` binary.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ruview-swarm Evaluation Results (ADR-149 Stage 1, kinematic)
|
||||
# ruview-swarm Evaluation Results (ADR-171 Stage 1, kinematic)
|
||||
|
||||
Statistically-rigorous evaluation harness: seeded multi-run rollouts with IQM + 95% stratified-bootstrap confidence intervals (Agarwal et al., NeurIPS 2021).
|
||||
|
||||
@@ -9,7 +9,7 @@ Statistically-rigorous evaluation harness: seeded multi-run rollouts with IQM +
|
||||
- **CI method**: 95% stratified bootstrap of the IQM, stratified by seed
|
||||
- **GDOP**: 2-D geometric dilution of precision at first detection
|
||||
|
||||
> **Stage 2 pending**: high-fidelity Gazebo/PX4 SITL evaluation (false-alarm rate, real collision rate on the median seeds) is a follow-on — see ADR-149 §6.1. The collision figures below are a kinematic min-separation proxy, not SITL physics.
|
||||
> **Stage 2 pending**: high-fidelity Gazebo/PX4 SITL evaluation (false-alarm rate, real collision rate on the median seeds) is a follow-on — see ADR-171 §6.1. The collision figures below are a kinematic min-separation proxy, not SITL physics.
|
||||
|
||||
## Flight-pattern leaderboard
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! ADR-149 Stage-1 evaluation CLI.
|
||||
//! ADR-171 Stage-1 evaluation CLI.
|
||||
//!
|
||||
//! Runs the kinematic eval matrix over every flight pattern (default) and
|
||||
//! writes a ranked `RESULTS.md` leaderboard. Pure Rust — no special feature
|
||||
//! flag required, so it builds and runs in default CI.
|
||||
//!
|
||||
//! Defaults are intentionally small (10 seeds × 10 episodes) so the run is fast.
|
||||
//! The full ADR-149 reporting configuration is 10 seeds × 50 episodes — pass
|
||||
//! The full ADR-171 reporting configuration is 10 seeds × 50 episodes — pass
|
||||
//! `--seeds 10 --episodes 50` for the publication run.
|
||||
//!
|
||||
//! ```text
|
||||
@@ -45,7 +45,7 @@ fn main() {
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
eprintln!(
|
||||
"eval_swarm — ADR-149 Stage-1 kinematic evaluator\n\
|
||||
"eval_swarm — ADR-171 Stage-1 kinematic evaluator\n\
|
||||
Usage: eval_swarm [--seeds N] [--episodes M] [--out PATH]\n\
|
||||
Defaults: --seeds 10 --episodes 10 --out crates/ruview-swarm/evals/RESULTS.md"
|
||||
);
|
||||
@@ -59,7 +59,7 @@ fn main() {
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"Running ADR-149 Stage-1 eval: {seeds} seeds × {episodes} episodes \
|
||||
"Running ADR-171 Stage-1 eval: {seeds} seeds × {episodes} episodes \
|
||||
over {} flight patterns...",
|
||||
FlightPattern::all().len()
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Per-episode and aggregate SAR + MARL metrics (ADR-149 Stage 1).
|
||||
//! Per-episode and aggregate SAR + MARL metrics (ADR-171 Stage 1).
|
||||
|
||||
use crate::evals::stats::{stratified_bootstrap_ci, ConfidenceInterval};
|
||||
|
||||
@@ -38,7 +38,7 @@ pub struct AggregateMetrics {
|
||||
impl AggregateMetrics {
|
||||
/// Aggregate a seed-stratified matrix of episodes. Each inner `Vec` is one
|
||||
/// seed's episodes; bootstrap resampling is stratified by seed so the CI
|
||||
/// reflects between-seed variance (the dominant source per ADR-149).
|
||||
/// reflects between-seed variance (the dominant source per ADR-171).
|
||||
pub fn from_strata(per_seed: &[Vec<EpisodeMetrics>], boot_seed: u64) -> Self {
|
||||
const N_BOOT: usize = 1000;
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! ADR-149 statistically-rigorous evaluation harness (Stage 1, kinematic).
|
||||
//! ADR-171 statistically-rigorous evaluation harness (Stage 1, kinematic).
|
||||
//!
|
||||
//! Produces SAR + MARL metrics over a seeded N-seed × M-episode matrix with
|
||||
//! IQM + 95% stratified-bootstrap CIs, a (sigma, kappa) CSI-noise sweep, and
|
||||
//! GDOP-stratified localization error. Generates evals/RESULTS.md.
|
||||
//!
|
||||
//! Stage 2 (Gazebo/PX4 SITL high-fidelity, false-alarm + collision rate on the
|
||||
//! median seeds) is a follow-on — see ADR-149 §6.1.
|
||||
//! median seeds) is a follow-on — see ADR-171 §6.1.
|
||||
pub mod gdop;
|
||||
pub mod stats;
|
||||
pub mod metrics;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! RESULTS.md leaderboard generator (ADR-149 Stage 1).
|
||||
//! RESULTS.md leaderboard generator (ADR-171 Stage 1).
|
||||
|
||||
use crate::evals::metrics::AggregateMetrics;
|
||||
use crate::evals::stats::ConfidenceInterval;
|
||||
@@ -19,7 +19,7 @@ fn fmt_ci(ci: &ConfidenceInterval) -> String {
|
||||
/// so callers should pre-sort (e.g. by descending coverage point estimate).
|
||||
pub fn render_results_md(rows: &[(String, AggregateMetrics)]) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("# ruview-swarm Evaluation Results (ADR-149 Stage 1, kinematic)\n\n");
|
||||
s.push_str("# ruview-swarm Evaluation Results (ADR-171 Stage 1, kinematic)\n\n");
|
||||
s.push_str(
|
||||
"Statistically-rigorous evaluation harness: seeded multi-run rollouts with \
|
||||
IQM + 95% stratified-bootstrap confidence intervals (Agarwal et al., \
|
||||
@@ -46,7 +46,7 @@ pub fn render_results_md(rows: &[(String, AggregateMetrics)]) -> String {
|
||||
s.push_str(
|
||||
"\n> **Stage 2 pending**: high-fidelity Gazebo/PX4 SITL evaluation \
|
||||
(false-alarm rate, real collision rate on the median seeds) is a \
|
||||
follow-on — see ADR-149 §6.1. The collision figures below are a \
|
||||
follow-on — see ADR-171 §6.1. The collision figures below are a \
|
||||
kinematic min-separation proxy, not SITL physics.\n\n",
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Stage-1 kinematic rollout + seed × episode matrix (ADR-149).
|
||||
//! Stage-1 kinematic rollout + seed × episode matrix (ADR-171).
|
||||
//!
|
||||
//! A single `run_episode` deterministically drives `drones` drones across a
|
||||
//! mission area under a chosen [`FlightPattern`], marks coverage on a grid,
|
||||
@@ -28,7 +28,7 @@ pub struct EvalConfig {
|
||||
pub config: SwarmConfig,
|
||||
pub drones: usize,
|
||||
pub steps: usize,
|
||||
pub seeds: usize, // ≥10 per ADR-149
|
||||
pub seeds: usize, // ≥10 per ADR-171
|
||||
pub episodes_per_seed: usize, // e.g. 50
|
||||
pub victims: Vec<Position3D>,
|
||||
pub noise: NoiseLevel,
|
||||
@@ -297,7 +297,7 @@ pub fn run_matrix(cfg: &EvalConfig) -> Vec<Vec<EpisodeMetrics>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Standard ADR-149 noise sweep grid: cartesian product of σ × κ levels.
|
||||
/// Standard ADR-171 noise sweep grid: cartesian product of σ × κ levels.
|
||||
pub fn default_noise_sweep() -> Vec<NoiseLevel> {
|
||||
let sigmas = [0.02, 0.05, 0.10];
|
||||
let kappas = [16.0, 8.0, 4.0];
|
||||
|
||||
@@ -24,7 +24,7 @@ linux-wifi = []
|
||||
[dependencies]
|
||||
# CLI argument parsing (for bin/aggregator)
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
# Cryptographic HMAC (ADR-050: replace fake XOR-fold HMAC)
|
||||
# Cryptographic HMAC (ADR-166: replace fake XOR-fold HMAC)
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
# Byte parsing
|
||||
|
||||
@@ -265,7 +265,7 @@ impl AuthenticatedBeacon {
|
||||
/// Compute the HMAC-SHA256 tag for this beacon, truncated to 8 bytes.
|
||||
///
|
||||
/// Uses the `hmac` + `sha2` crates for cryptographically secure
|
||||
/// message authentication (ADR-050, Sprint 1).
|
||||
/// message authentication (ADR-166, Sprint 1).
|
||||
pub fn compute_tag(payload_and_nonce: &[u8], key: &[u8; 16]) -> [u8; HMAC_TAG_SIZE] {
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts any key length");
|
||||
mac.update(payload_and_nonce);
|
||||
@@ -953,7 +953,7 @@ mod tests {
|
||||
assert_eq!(SecLevel::Enforcing as u8, 2);
|
||||
}
|
||||
|
||||
// ---- Security tests (ADR-050) ----
|
||||
// ---- Security tests (ADR-166) ----
|
||||
|
||||
#[test]
|
||||
fn test_hmac_different_keys_produce_different_tags() {
|
||||
|
||||
@@ -254,6 +254,98 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// REGRESSION (ADR-080 #3, CWE-598 — token in URL query string).
|
||||
///
|
||||
/// ADR-080 flagged "JWT in URL" as a HIGH finding (tokens in query strings
|
||||
/// leak into logs, proxies, browser history, `Referer`). The current
|
||||
/// sensing-server only ever reads the token from the `Authorization: Bearer`
|
||||
/// header — there is no `?token=` / `?access_token=` query path in
|
||||
/// `require_bearer` (see [`require_bearer`] above, which only inspects the
|
||||
/// `AUTHORIZATION` header). This test pins that: a request carrying the
|
||||
/// correct token *only* in the query string is still `401`, while the same
|
||||
/// token in the header is `200`. If anyone ever re-introduces a query-string
|
||||
/// token path, this fails.
|
||||
#[tokio::test]
|
||||
async fn query_string_token_is_never_accepted() {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
// Correct token, but supplied only in the URL — must NOT authenticate.
|
||||
assert_eq!(
|
||||
status(r.clone(), "GET", "/api/v1/info?token=s3cr3t", None).await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"?token= in the query string must not authenticate (CWE-598)"
|
||||
);
|
||||
assert_eq!(
|
||||
status(
|
||||
r.clone(),
|
||||
"GET",
|
||||
"/api/v1/info?access_token=s3cr3t",
|
||||
None
|
||||
)
|
||||
.await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"?access_token= in the query string must not authenticate (CWE-598)"
|
||||
);
|
||||
// A query token must not "help" a request that also lacks the header,
|
||||
// even combined with an unrelated param.
|
||||
assert_eq!(
|
||||
status(
|
||||
r.clone(),
|
||||
"GET",
|
||||
"/api/v1/info?foo=bar&token=s3cr3t",
|
||||
None
|
||||
)
|
||||
.await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
// The header path is the only accepted channel — same token, header,
|
||||
// succeeds. (Proves we didn't just break auth entirely.)
|
||||
assert_eq!(
|
||||
status(r, "GET", "/api/v1/info?token=s3cr3t", Some("s3cr3t")).await,
|
||||
StatusCode::OK,
|
||||
"the Authorization: Bearer header is the supported channel"
|
||||
);
|
||||
}
|
||||
|
||||
/// REGRESSION (ADR-080 #1 — X-Forwarded-For spoofing).
|
||||
///
|
||||
/// The bearer middleware authenticates on the token alone and must be
|
||||
/// completely insensitive to a client-supplied `X-Forwarded-For` header:
|
||||
/// an attacker cannot flip an auth decision by spoofing XFF. A wrong token
|
||||
/// stays `401` and a right token stays `200` regardless of XFF. (The
|
||||
/// sensing-server has no IP-based rate-limit / allowlist that XFF could
|
||||
/// bypass; this locks in that auth itself never consults XFF.)
|
||||
#[tokio::test]
|
||||
async fn xff_header_never_affects_auth_decision() {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
async fn with_xff(router: Router, token: Option<&str>, xff: &str) -> StatusCode {
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/info")
|
||||
.header("X-Forwarded-For", xff)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
if let Some(t) = token {
|
||||
req.headers_mut()
|
||||
.insert(AUTHORIZATION, format!("Bearer {t}").parse().unwrap());
|
||||
}
|
||||
router.oneshot(req).await.unwrap().status()
|
||||
}
|
||||
// Spoofed XFF + no/ wrong token ⇒ still rejected.
|
||||
assert_eq!(
|
||||
with_xff(r.clone(), None, "127.0.0.1").await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
with_xff(r.clone(), Some("nope"), "10.0.0.1, 127.0.0.1").await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
// Spoofed XFF + correct token ⇒ still accepted (XFF is irrelevant).
|
||||
assert_eq!(
|
||||
with_xff(r, Some("s3cr3t"), "evil-proxy").await,
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enabled_never_gates_paths_outside_api_v1() {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Generic, leak-free error responses for the sensing-server HTTP API.
|
||||
//!
|
||||
//! ## ADR-080 finding #2 — leaked internal errors in responses
|
||||
//!
|
||||
//! Several handlers historically serialized the *internal* error `Display`
|
||||
//! (`format!("{e}")`, `err.to_string()`, a panicked `JoinError`) straight into
|
||||
//! the JSON response body. That leaks server internals to any client: OS error
|
||||
//! strings can carry filesystem paths, a `JoinError` carries the panic message
|
||||
//! (`task … panicked`), and an upstream-fetch error can carry an internal URL.
|
||||
//! ADR-080 flagged this HIGH (CWE-209: Generation of Error Message Containing
|
||||
//! Sensitive Information). The HOMECORE/M7 sweep (ADR-161) covered
|
||||
//! `homecore-server`, **not** this crate, so the finding stayed open.
|
||||
//!
|
||||
//! ## Contract
|
||||
//!
|
||||
//! [`internal_error`] logs the full detail **server-side only** (at `error`
|
||||
//! level, tagged with a correlation id) and returns a *generic* body to the
|
||||
//! client:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "error": "internal_error", "correlation_id": "a1b2c3d4e5f60718", "success": false }
|
||||
//! ```
|
||||
//!
|
||||
//! The correlation id lets an operator grep the server log for the matching
|
||||
//! detail line without ever shipping that detail to the client. The body
|
||||
//! deliberately contains no `Display`/`Debug` of the underlying error, no file
|
||||
//! paths, and never the word `panicked`.
|
||||
//!
|
||||
//! Handlers that previously returned `Json<serde_json::Value>` keep doing so via
|
||||
//! [`internal_error_json`]; handlers that return `(StatusCode, Json<…>)` use
|
||||
//! [`internal_error`]. A "service unavailable" flavor ([`upstream_unavailable`])
|
||||
//! exists for the 503 upstream-fetch path so it, too, stops leaking the raw
|
||||
//! upstream error.
|
||||
|
||||
use std::fmt::Display;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use axum::{http::StatusCode, response::Json};
|
||||
use serde_json::json;
|
||||
|
||||
/// Monotonic component of the correlation id, so two errors in the same
|
||||
/// nanosecond still get distinct ids. Wraps harmlessly.
|
||||
static CORRELATION_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Generate a short, opaque correlation id (16 lowercase hex chars). Built from
|
||||
/// a nanosecond timestamp XORed with a monotonic counter — unique enough to tie
|
||||
/// a client-visible id back to a single server-side log line without pulling in
|
||||
/// a UUID dependency. It is **not** a security token; it is only an opaque
|
||||
/// log-join key, so a non-cryptographic source is fine.
|
||||
pub fn correlation_id() -> String {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let seq = CORRELATION_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
// Mix the counter into the high bits so concurrent calls in the same
|
||||
// nanosecond don't collide.
|
||||
let mixed = nanos ^ seq.rotate_left(40);
|
||||
format!("{mixed:016x}")
|
||||
}
|
||||
|
||||
/// Build a generic internal-error response **and log the real detail
|
||||
/// server-side**. The client sees only `{"error":"internal_error",
|
||||
/// "correlation_id":…,"success":false}` with a `500` status; the detail is
|
||||
/// written to the `error`-level log tagged with the same correlation id.
|
||||
///
|
||||
/// `context` is a short, *static* description of where the error happened
|
||||
/// (e.g. `"model delete"`); it is safe to log but is **not** sent to the
|
||||
/// client.
|
||||
pub fn internal_error(context: &str, detail: impl Display) -> (StatusCode, Json<serde_json::Value>) {
|
||||
let cid = correlation_id();
|
||||
// Server-side only — this is where the real detail lives.
|
||||
tracing::error!(
|
||||
correlation_id = %cid,
|
||||
context = context,
|
||||
detail = %detail,
|
||||
"internal error (detail logged server-side only; client received a generic body)"
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": "internal_error",
|
||||
"correlation_id": cid,
|
||||
"success": false,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// Same as [`internal_error`] but returns a bare `Json` body (HTTP `200` at the
|
||||
/// transport layer) for the legacy handlers that are typed
|
||||
/// `-> Json<serde_json::Value>` and signal failure via `"success": false`
|
||||
/// rather than an HTTP status code. The detail is still logged server-side and
|
||||
/// never reaches the client.
|
||||
pub fn internal_error_json(context: &str, detail: impl Display) -> Json<serde_json::Value> {
|
||||
let cid = correlation_id();
|
||||
tracing::error!(
|
||||
correlation_id = %cid,
|
||||
context = context,
|
||||
detail = %detail,
|
||||
"internal error (detail logged server-side only; client received a generic body)"
|
||||
);
|
||||
Json(json!({
|
||||
"error": "internal_error",
|
||||
"correlation_id": cid,
|
||||
"success": false,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Generic `503 Service Unavailable` for an upstream dependency that failed,
|
||||
/// without leaking the raw upstream error (which can carry an internal URL or
|
||||
/// connection detail). Detail is logged server-side with a correlation id.
|
||||
pub fn upstream_unavailable(
|
||||
context: &str,
|
||||
detail: impl Display,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
let cid = correlation_id();
|
||||
tracing::warn!(
|
||||
correlation_id = %cid,
|
||||
context = context,
|
||||
detail = %detail,
|
||||
"upstream unavailable (detail logged server-side only; client received a generic body)"
|
||||
);
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"error": "upstream_unavailable",
|
||||
"correlation_id": cid,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A "detail" string carrying the kind of internal information the old
|
||||
/// `format!("{e}")` path would have leaked: a filesystem path, an OS error,
|
||||
/// and the word `panicked`.
|
||||
const LEAKY_DETAIL: &str =
|
||||
"task 42 panicked at 'C:\\Users\\ruv\\secret\\models\\foo.rvf': No such file or directory (os error 2)";
|
||||
|
||||
/// Recursively collect every string value in a JSON document, so a test can
|
||||
/// assert no leaky substring appears *anywhere* in the body (not just in a
|
||||
/// single known field).
|
||||
fn all_strings(v: &serde_json::Value, out: &mut Vec<String>) {
|
||||
match v {
|
||||
serde_json::Value::String(s) => out.push(s.clone()),
|
||||
serde_json::Value::Array(a) => a.iter().for_each(|x| all_strings(x, out)),
|
||||
serde_json::Value::Object(o) => o.values().for_each(|x| all_strings(x, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn body_strings(body: &Json<serde_json::Value>) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
all_strings(&body.0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// REGRESSION (ADR-080 #2): the response body must NOT contain the panic
|
||||
/// message, the filesystem path, or the OS error string. The pre-fix code
|
||||
/// returned `format!("{e}")` / `join_err.to_string()` directly, so the body
|
||||
/// *did* contain `panicked`, the path, and `os error 2` — this test fails
|
||||
/// on that old behavior.
|
||||
#[test]
|
||||
fn internal_error_body_does_not_leak_detail() {
|
||||
let (status, body) = internal_error("unit-test", LEAKY_DETAIL);
|
||||
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
|
||||
for s in body_strings(&body) {
|
||||
assert!(
|
||||
!s.contains("panicked"),
|
||||
"response body leaked the panic message: {s:?}"
|
||||
);
|
||||
assert!(
|
||||
!s.contains("secret"),
|
||||
"response body leaked a filesystem path: {s:?}"
|
||||
);
|
||||
assert!(
|
||||
!s.contains("os error"),
|
||||
"response body leaked an OS error string: {s:?}"
|
||||
);
|
||||
assert!(
|
||||
!s.contains(".rvf"),
|
||||
"response body leaked a file name/path: {s:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The generic body still carries a correlation id so an operator can join
|
||||
/// the client report to the server log line that *does* hold the detail.
|
||||
#[test]
|
||||
fn internal_error_body_is_generic_with_correlation_id() {
|
||||
let (_status, body) = internal_error("unit-test", LEAKY_DETAIL);
|
||||
assert_eq!(body.0["error"], "internal_error");
|
||||
assert_eq!(body.0["success"], false);
|
||||
let cid = body.0["correlation_id"]
|
||||
.as_str()
|
||||
.expect("correlation_id must be a string");
|
||||
assert_eq!(cid.len(), 16, "correlation id should be 16 hex chars");
|
||||
assert!(
|
||||
cid.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"correlation id should be hex: {cid:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Same leak guarantee for the bare-`Json` (legacy "success: false")
|
||||
/// variant used by handlers that don't return an HTTP status.
|
||||
#[test]
|
||||
fn internal_error_json_does_not_leak_detail() {
|
||||
let body = internal_error_json("unit-test", LEAKY_DETAIL);
|
||||
assert_eq!(body.0["error"], "internal_error");
|
||||
assert_eq!(body.0["success"], false);
|
||||
for s in body_strings(&body) {
|
||||
assert!(!s.contains("panicked"), "leaked panic message: {s:?}");
|
||||
assert!(!s.contains("secret"), "leaked filesystem path: {s:?}");
|
||||
assert!(!s.contains("os error"), "leaked OS error: {s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The 503 upstream flavor must likewise not echo the raw upstream error
|
||||
/// (which can carry an internal URL / connection string).
|
||||
#[test]
|
||||
fn upstream_unavailable_does_not_leak_detail() {
|
||||
let (status, body) = upstream_unavailable(
|
||||
"edge-registry",
|
||||
"https://internal-host.local:9000/app-registry.json: connection refused",
|
||||
);
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
for s in body_strings(&body) {
|
||||
assert!(
|
||||
!s.contains("internal-host"),
|
||||
"leaked internal upstream host: {s:?}"
|
||||
);
|
||||
assert!(
|
||||
!s.contains("connection refused"),
|
||||
"leaked upstream connection detail: {s:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(body.0["error"], "upstream_unavailable");
|
||||
assert!(body.0["correlation_id"].is_string());
|
||||
}
|
||||
|
||||
/// Correlation ids are unique across rapid successive calls (so two errors
|
||||
/// can be told apart in the log even under load).
|
||||
#[test]
|
||||
fn correlation_ids_are_unique() {
|
||||
let a = correlation_id();
|
||||
let b = correlation_id();
|
||||
assert_ne!(a, b, "successive correlation ids must differ: {a} == {b}");
|
||||
}
|
||||
}
|
||||
@@ -362,6 +362,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// REGRESSION (ADR-080 #1 — X-Forwarded-For / X-Forwarded-Host spoofing).
|
||||
///
|
||||
/// The DNS-rebinding allowlist must decide purely on the real `Host` header
|
||||
/// and ignore any client-supplied forwarding headers. Otherwise an attacker
|
||||
/// could spoof `X-Forwarded-Host: localhost` (or `X-Forwarded-For`) to slip a
|
||||
/// foreign `Host` past the allowlist. This test sends a rejected `Host:
|
||||
/// evil.com` *with* allowlisted forwarding headers and asserts the request is
|
||||
/// still `421` — the forwarded headers must not bypass the control. It also
|
||||
/// confirms an allowed `Host` stays `200` regardless of a hostile XFF.
|
||||
#[tokio::test]
|
||||
async fn forwarded_headers_never_bypass_host_allowlist() {
|
||||
let r = router(HostAllowlist::loopback_only());
|
||||
async fn with_forwarded(
|
||||
router: Router,
|
||||
host: &str,
|
||||
xff: &str,
|
||||
xfh: &str,
|
||||
) -> StatusCode {
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/pose/current")
|
||||
.header(HOST, host)
|
||||
.header("X-Forwarded-For", xff)
|
||||
.header("X-Forwarded-Host", xfh)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
router.oneshot(req).await.unwrap().status()
|
||||
}
|
||||
// Foreign Host + spoofed allowlisted forwarding headers ⇒ still rejected.
|
||||
assert_eq!(
|
||||
with_forwarded(r.clone(), "evil.com", "127.0.0.1", "localhost").await,
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
"X-Forwarded-* must not let a foreign Host bypass the allowlist"
|
||||
);
|
||||
// Allowed Host + hostile forwarding headers ⇒ still allowed (forwarded
|
||||
// headers are simply not consulted).
|
||||
assert_eq!(
|
||||
with_forwarded(r, "127.0.0.1:8080", "evil.com", "evil.com").await,
|
||||
StatusCode::OK,
|
||||
"the real Host header is the only signal; XFF/XFH are ignored"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_allowlist_is_no_op() {
|
||||
let r = router(HostAllowlist::disabled());
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! - RVF (RuVector Format) binary container for model weights
|
||||
//! - Opt-in bearer-token auth for the `/api/v1/*` HTTP surface (`bearer_auth`)
|
||||
//! - Host-header allowlist / DNS-rebinding defense (`host_validation`)
|
||||
//! - Generic, leak-free internal-error responses (`error_response`, ADR-080 #2)
|
||||
//! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099)
|
||||
|
||||
pub mod bearer_auth;
|
||||
@@ -13,6 +14,7 @@ pub mod dataset;
|
||||
pub mod edge_registry;
|
||||
#[allow(dead_code)]
|
||||
pub mod embedding;
|
||||
pub mod error_response;
|
||||
pub mod graph_transformer;
|
||||
pub mod host_validation;
|
||||
pub mod introspection;
|
||||
|
||||
@@ -24,7 +24,9 @@ pub mod types;
|
||||
mod vital_signs;
|
||||
|
||||
// Training pipeline modules (exposed via lib.rs)
|
||||
use wifi_densepose_sensing_server::{dataset, embedding, graph_transformer, trainer};
|
||||
use wifi_densepose_sensing_server::{
|
||||
dataset, embedding, error_response, graph_transformer, trainer,
|
||||
};
|
||||
|
||||
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
@@ -4280,7 +4282,7 @@ async fn delete_model(
|
||||
State(state): State<SharedState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
// ADR-050: Sanitize path to prevent directory traversal
|
||||
// ADR-166: Sanitize path to prevent directory traversal
|
||||
let safe_id = std::path::Path::new(&id)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
@@ -4291,10 +4293,9 @@ async fn delete_model(
|
||||
let path = effective_models_dir().join(format!("{}.rvf", safe_id));
|
||||
if path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
warn!("Failed to delete model file {:?}: {}", path, e);
|
||||
return Json(
|
||||
serde_json::json!({ "error": format!("delete failed: {e}"), "success": false }),
|
||||
);
|
||||
// ADR-080 #2: log the OS error (incl. path) server-side only; the
|
||||
// client gets a generic body + correlation id, no leaked path.
|
||||
return error_response::internal_error_json("model delete", e);
|
||||
}
|
||||
// If this was the active model, unload it
|
||||
let mut s = state.write().await;
|
||||
@@ -4434,11 +4435,9 @@ async fn start_recording(
|
||||
let file = match std::fs::File::create(&rec_path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!("Failed to create recording file {:?}: {}", rec_path, e);
|
||||
return Json(serde_json::json!({
|
||||
"error": format!("cannot create file: {e}"),
|
||||
"success": false,
|
||||
}));
|
||||
// ADR-080 #2: the OS error can carry the recordings path; log it
|
||||
// server-side only and return a generic body + correlation id.
|
||||
return error_response::internal_error_json("recording create", e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4550,7 +4549,7 @@ async fn delete_recording(
|
||||
State(state): State<SharedState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
// ADR-050: Sanitize path to prevent directory traversal
|
||||
// ADR-166: Sanitize path to prevent directory traversal
|
||||
let safe_id = std::path::Path::new(&id)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
@@ -4561,10 +4560,8 @@ async fn delete_recording(
|
||||
let path = PathBuf::from("data/recordings").join(format!("{}.jsonl", safe_id));
|
||||
if path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
warn!("Failed to delete recording {:?}: {}", path, e);
|
||||
return Json(
|
||||
serde_json::json!({ "error": format!("delete failed: {e}"), "success": false }),
|
||||
);
|
||||
// ADR-080 #2: log the OS error (incl. path) server-side only.
|
||||
return error_response::internal_error_json("recording delete", e);
|
||||
}
|
||||
let mut s = state.write().await;
|
||||
s.recordings
|
||||
@@ -4773,10 +4770,8 @@ async fn calibration_start(State(state): State<SharedState>) -> Json<serde_json:
|
||||
"message": "Calibration started — keep room empty while frames accumulate.",
|
||||
}))
|
||||
}
|
||||
Err(e) => Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error": format!("{e}"),
|
||||
})),
|
||||
// ADR-080 #2: FieldModel init error chain stays server-side only.
|
||||
Err(e) => error_response::internal_error_json("calibration start", e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4796,10 +4791,8 @@ async fn calibration_stop(State(state): State<SharedState>) -> Json<serde_json::
|
||||
"frame_count": fm.calibration_frame_count(),
|
||||
}))
|
||||
}
|
||||
Err(e) => Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error": format!("{e}"),
|
||||
})),
|
||||
// ADR-080 #2: finalize error chain stays server-side only.
|
||||
Err(e) => error_response::internal_error_json("calibration stop", e),
|
||||
}
|
||||
} else {
|
||||
Json(serde_json::json!({
|
||||
@@ -4895,26 +4888,13 @@ async fn edge_registry_endpoint(
|
||||
Ok(Ok(resp)) => Ok(Json(
|
||||
serde_json::to_value(resp).unwrap_or(serde_json::json!({})),
|
||||
)),
|
||||
Ok(Err(err)) => {
|
||||
tracing::warn!(error = %err, "edge_registry upstream fetch failed and no cache");
|
||||
Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({
|
||||
"error": "edge_registry_upstream_unavailable",
|
||||
"detail": err.to_string()
|
||||
})),
|
||||
))
|
||||
}
|
||||
Err(join_err) => {
|
||||
tracing::error!(error = %join_err, "edge_registry spawn_blocking task panicked");
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "edge_registry_internal_error",
|
||||
"detail": join_err.to_string()
|
||||
})),
|
||||
))
|
||||
}
|
||||
// ADR-080 #2: the upstream error can carry an internal URL/connection
|
||||
// detail — log it server-side only and return a generic 503.
|
||||
Ok(Err(err)) => Err(error_response::upstream_unavailable("edge_registry", err)),
|
||||
// ADR-080 #2: a panicked spawn_blocking surfaces "task … panicked" via
|
||||
// JoinError::Display — never ship that to the client. Generic 500 +
|
||||
// correlation id; the panic detail is logged server-side.
|
||||
Err(join_err) => Err(error_response::internal_error("edge_registry", join_err)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7375,7 +7355,7 @@ async fn main() {
|
||||
tokio::spawn(simulated_data_task(state.clone(), args.tick_ms));
|
||||
}
|
||||
|
||||
// ADR-050: Parse bind address once, use for all listeners
|
||||
// ADR-166: Parse bind address once, use for all listeners
|
||||
let bind_ip: std::net::IpAddr = args
|
||||
.bind_addr
|
||||
.parse()
|
||||
|
||||
@@ -72,6 +72,44 @@ impl Default for AdversarialConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detection tuning constants (ADR-154 §7.4 #13 — DATA-GATED)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// These were bare numeric literals buried in `check`/`check_consistency`. They
|
||||
// are EMPIRICAL DEFAULTS, not calibrated operating points — setting defensible
|
||||
// values needs labelled spoofed/clean CSI (the Wi-Spoof benchmark, §6.2/§7.3).
|
||||
// De-magicking + the boundary tests below make any future data-driven retune a
|
||||
// visible, tested change. The VALUES here are unchanged from the pre-ADR-154
|
||||
// behaviour; only their names and the pinning tests are new.
|
||||
|
||||
/// Gini coefficient above which the energy distribution is flagged as a
|
||||
/// `FieldModelViolation` (one link hogging the energy → likely injection).
|
||||
/// EMPIRICAL DEFAULT pending labelled calibration.
|
||||
const FIELD_MODEL_GINI_VIOLATION: f64 = 0.8;
|
||||
|
||||
/// Energy-conservation ratio (total / expected-for-body-count) above which the
|
||||
/// frame is flagged as an `EnergyViolation` (too much energy for the occupancy).
|
||||
/// EMPIRICAL DEFAULT pending labelled calibration.
|
||||
const ENERGY_RATIO_HIGH_VIOLATION: f64 = 2.0;
|
||||
|
||||
/// Energy-conservation ratio below which an *occupied* frame is flagged as an
|
||||
/// `EnergyViolation` (too little energy for a claimed body — possible dropout
|
||||
/// or masking). Only applied when `n_bodies > 0`. EMPIRICAL DEFAULT.
|
||||
const ENERGY_RATIO_LOW_VIOLATION: f64 = 0.1;
|
||||
|
||||
/// Fraction of the mean per-link energy a link must exceed to count as
|
||||
/// "active" in the multi-link consistency check. EMPIRICAL DEFAULT.
|
||||
const CONSISTENCY_ACTIVE_FRACTION_OF_MEAN: f64 = 0.1;
|
||||
|
||||
/// Weights of the four checks in the aggregate anomaly score (sum to 1.0).
|
||||
/// EMPIRICAL DEFAULTS — equal 0.2 split with consistency double-weighted (0.4)
|
||||
/// because single-link injection is the primary threat model (ADR-030 Tier 7).
|
||||
const SCORE_W_CONSISTENCY: f64 = 0.4;
|
||||
const SCORE_W_FIELD_MODEL: f64 = 0.2;
|
||||
const SCORE_W_TEMPORAL: f64 = 0.2;
|
||||
const SCORE_W_ENERGY: f64 = 0.2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detection results
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -250,13 +288,15 @@ impl AdversarialDetector {
|
||||
if consistency < self.config.consistency_threshold {
|
||||
violations.push(AnomalyType::SingleLinkInjection);
|
||||
}
|
||||
if field_residual > 0.8 {
|
||||
if field_residual > FIELD_MODEL_GINI_VIOLATION {
|
||||
violations.push(AnomalyType::FieldModelViolation);
|
||||
}
|
||||
if temporal > self.config.max_temporal_discontinuity {
|
||||
violations.push(AnomalyType::TemporalDiscontinuity);
|
||||
}
|
||||
if energy_ratio > 2.0 || (n_bodies > 0 && energy_ratio < 0.1) {
|
||||
if energy_ratio > ENERGY_RATIO_HIGH_VIOLATION
|
||||
|| (n_bodies > 0 && energy_ratio < ENERGY_RATIO_LOW_VIOLATION)
|
||||
{
|
||||
violations.push(AnomalyType::EnergyViolation);
|
||||
}
|
||||
|
||||
@@ -268,10 +308,10 @@ impl AdversarialDetector {
|
||||
};
|
||||
|
||||
// Score: weighted combination
|
||||
let anomaly_score = ((1.0 - consistency) * 0.4
|
||||
+ field_residual * 0.2
|
||||
+ (temporal / self.config.max_temporal_discontinuity).min(1.0) * 0.2
|
||||
+ ((energy_ratio - 1.0).abs() / 2.0).min(1.0) * 0.2)
|
||||
let anomaly_score = ((1.0 - consistency) * SCORE_W_CONSISTENCY
|
||||
+ field_residual * SCORE_W_FIELD_MODEL
|
||||
+ (temporal / self.config.max_temporal_discontinuity).min(1.0) * SCORE_W_TEMPORAL
|
||||
+ ((energy_ratio - 1.0).abs() / 2.0).min(1.0) * SCORE_W_ENERGY)
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
// Find affected links (highest single-link energy ratio)
|
||||
@@ -304,7 +344,8 @@ impl AdversarialDetector {
|
||||
}
|
||||
|
||||
let mean = total / energies.len() as f64;
|
||||
let threshold = mean * 0.1; // link must have at least 10% of mean energy
|
||||
// link must have at least CONSISTENCY_ACTIVE_FRACTION_OF_MEAN of mean energy
|
||||
let threshold = mean * CONSISTENCY_ACTIVE_FRACTION_OF_MEAN;
|
||||
|
||||
let active_count = energies.iter().filter(|&&e| e > threshold).count();
|
||||
active_count as f64 / energies.len() as f64
|
||||
@@ -641,4 +682,118 @@ mod tests {
|
||||
gini
|
||||
);
|
||||
}
|
||||
|
||||
// ── ADR-154 §7.4 #13: threshold characterization (DATA-GATED) ───────────
|
||||
// These pin the CURRENT empirical threshold values so a future labelled-data
|
||||
// retune is a visible, tested change. They do NOT assert the values are
|
||||
// "correct" — only that the named consts equal the de-magicked literals and
|
||||
// that the decision boundaries sit exactly where the old bare literals put
|
||||
// them.
|
||||
|
||||
/// The named consts must equal the original bare literals (no value drift).
|
||||
#[test]
|
||||
fn tuning_consts_unchanged_from_literals() {
|
||||
assert_eq!(FIELD_MODEL_GINI_VIOLATION, 0.8);
|
||||
assert_eq!(ENERGY_RATIO_HIGH_VIOLATION, 2.0);
|
||||
assert_eq!(ENERGY_RATIO_LOW_VIOLATION, 0.1);
|
||||
assert_eq!(CONSISTENCY_ACTIVE_FRACTION_OF_MEAN, 0.1);
|
||||
assert!(
|
||||
(SCORE_W_CONSISTENCY + SCORE_W_FIELD_MODEL + SCORE_W_TEMPORAL + SCORE_W_ENERGY - 1.0)
|
||||
.abs()
|
||||
< 1e-12,
|
||||
"score weights must sum to 1.0"
|
||||
);
|
||||
}
|
||||
|
||||
/// Energy-ratio HIGH boundary: the `> ENERGY_RATIO_HIGH_VIOLATION` decision
|
||||
/// flips just above 2.0. With max_energy_per_body=10 and n_bodies=1, total
|
||||
/// energy E gives ratio E/10, so E=20 is the boundary. Use a clean uniform
|
||||
/// distribution so ONLY the energy check can fire.
|
||||
#[test]
|
||||
fn energy_ratio_high_boundary() {
|
||||
let mk = |per_link: f64| {
|
||||
// 6 links, uniform → consistency=1, gini≈0, temporal=0 (first frame).
|
||||
vec![per_link; 6]
|
||||
};
|
||||
// ratio just BELOW 2.0 (total=19.2 → ratio 1.92): no energy violation.
|
||||
let mut det = AdversarialDetector::new(default_config()).unwrap();
|
||||
let below = det.check(&mk(3.2), 1, 0).unwrap(); // 6*3.2=19.2
|
||||
assert!(
|
||||
!below.anomaly_detected,
|
||||
"ratio 1.92 (<2.0) must not flag energy violation: {:?}",
|
||||
below.anomaly_type
|
||||
);
|
||||
// ratio just ABOVE 2.0 (total=21.0 → ratio 2.1): energy violation fires.
|
||||
let mut det2 = AdversarialDetector::new(default_config()).unwrap();
|
||||
let above = det2.check(&mk(3.5), 1, 0).unwrap(); // 6*3.5=21.0
|
||||
assert!(
|
||||
above.anomaly_detected,
|
||||
"ratio 2.1 (>2.0) must flag an anomaly"
|
||||
);
|
||||
}
|
||||
|
||||
/// Energy-ratio LOW boundary: an occupied frame with ratio < 0.1 flags an
|
||||
/// `EnergyViolation`. With n_bodies=1, max_energy_per_body=10, boundary
|
||||
/// total = 1.0 (ratio 0.1). Below it (total 0.9 → 0.09) must flag.
|
||||
#[test]
|
||||
fn energy_ratio_low_boundary() {
|
||||
// just ABOVE 0.1 (total 1.2 → ratio 0.12): no energy violation.
|
||||
let mut det = AdversarialDetector::new(default_config()).unwrap();
|
||||
let above = det.check(&vec![0.2; 6], 1, 0).unwrap(); // 6*0.2=1.2
|
||||
assert!(
|
||||
!above.anomaly_detected,
|
||||
"ratio 0.12 (>0.1) must not flag: {:?}",
|
||||
above.anomaly_type
|
||||
);
|
||||
// just BELOW 0.1 (total 0.6 → ratio 0.06): energy violation fires.
|
||||
let mut det2 = AdversarialDetector::new(default_config()).unwrap();
|
||||
let below = det2.check(&vec![0.1; 6], 1, 0).unwrap(); // 6*0.1=0.6
|
||||
assert!(
|
||||
below.anomaly_detected,
|
||||
"ratio 0.06 (<0.1) must flag an energy anomaly"
|
||||
);
|
||||
}
|
||||
|
||||
/// Field-model Gini boundary: `check_field_model` > 0.8 → FieldModelViolation.
|
||||
/// We directly characterize where the Gini crosses 0.8 for a one-hot vs
|
||||
/// uniform-tail mix, pinning the 0.8 const.
|
||||
#[test]
|
||||
fn field_model_gini_boundary() {
|
||||
let det = AdversarialDetector::new(default_config()).unwrap();
|
||||
// Fully concentrated (one-hot) over 6 links → Gini = (n-1)/n = 0.833 > 0.8.
|
||||
let concentrated = det.check_field_model(&[6.0, 0.0, 0.0, 0.0, 0.0, 0.0], 6.0);
|
||||
assert!(
|
||||
concentrated > FIELD_MODEL_GINI_VIOLATION,
|
||||
"one-hot Gini {concentrated} must exceed the 0.8 violation threshold"
|
||||
);
|
||||
// Uniform → Gini ≈ 0 < 0.8.
|
||||
let uniform = det.check_field_model(&[1.0; 6], 6.0);
|
||||
assert!(
|
||||
uniform < FIELD_MODEL_GINI_VIOLATION,
|
||||
"uniform Gini {uniform} must be below the 0.8 threshold"
|
||||
);
|
||||
}
|
||||
|
||||
/// Consistency active-fraction boundary: a link counts as "active" iff its
|
||||
/// energy > 0.1·mean. Pin that exactly one sub-threshold link is excluded.
|
||||
#[test]
|
||||
fn consistency_active_fraction_boundary() {
|
||||
let det = AdversarialDetector::new(default_config()).unwrap();
|
||||
// 5 links at 1.0, one link at just BELOW 0.1·mean.
|
||||
// mean over 6 = (5.0 + x)/6; for x small, threshold ≈ 0.1*5/6 ≈ 0.083.
|
||||
let mut e = vec![1.0; 6];
|
||||
e[5] = 0.05; // below ~0.083 threshold → excluded
|
||||
let c_excluded = det.check_consistency(&e, e.iter().sum());
|
||||
assert!(
|
||||
(c_excluded - 5.0 / 6.0).abs() < 1e-9,
|
||||
"sub-threshold link must be excluded: got {c_excluded}"
|
||||
);
|
||||
// Bump it well above threshold → counts as active (all 6).
|
||||
e[5] = 1.0;
|
||||
let c_included = det.check_consistency(&e, e.iter().sum());
|
||||
assert!(
|
||||
(c_included - 1.0).abs() < 1e-9,
|
||||
"above-threshold link must count: got {c_included}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,8 +145,10 @@ pub enum CirError {
|
||||
#[error("subcarrier count mismatch: expected {expected}, got {got}")]
|
||||
SubcarrierMismatch { expected: usize, got: usize },
|
||||
|
||||
/// Phase variance exceeds 2π — frame appears unsanitized (ghost-tap risk).
|
||||
#[error("CSI phase variance {variance:.3} suggests unsanitized input (ghost-tap risk)")]
|
||||
/// Circular phase variance (V = 1 − R̄ ∈ [0,1]) is too high — the CSI phase
|
||||
/// is near-uniformly spread across subcarriers, the signature of unsanitized
|
||||
/// SFO/CFO (ghost-tap risk). See `GHOST_TAP_CIRCULAR_VARIANCE_MAX`.
|
||||
#[error("CSI circular phase variance {variance:.3} suggests unsanitized input (ghost-tap risk)")]
|
||||
UnsanitizedPhase { variance: f32 },
|
||||
|
||||
/// ISTA did not converge within the iteration budget.
|
||||
@@ -567,9 +569,14 @@ impl CirEstimator {
|
||||
|
||||
let y = self.extract_csi_vector(csi);
|
||||
|
||||
// Ghost-tap guard: phase variance > 2π signals unsanitized SFO/CFO.
|
||||
// Ghost-tap guard: a near-uniform spread of CSI phase across subcarriers
|
||||
// signals unsanitized SFO/CFO (raw hardware phase ramps that were never
|
||||
// de-rotated). `phase_variance` is now Mardia's *circular* variance
|
||||
// V = 1 − R̄ ∈ [0,1] (ADR-154 §7.4 #1), so the old `> TAU` (≈6.28)
|
||||
// threshold — meaningful only for the unbounded linear variance — no
|
||||
// longer applies. We compare against the bounded const below.
|
||||
let phase_var = phase_variance(&y);
|
||||
if phase_var > std::f32::consts::TAU {
|
||||
if phase_var > GHOST_TAP_CIRCULAR_VARIANCE_MAX {
|
||||
return Err(CirError::UnsanitizedPhase {
|
||||
variance: phase_var,
|
||||
});
|
||||
@@ -988,17 +995,64 @@ fn normalize_complex(v: &mut [Complex32]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Variance of the instantaneous phase angles (rad) across a complex vector.
|
||||
/// Ghost-tap guard threshold on the **circular** phase variance (ADR-154 §7.4 #1).
|
||||
///
|
||||
/// `phase_variance` returns Mardia's circular variance V = 1 − R̄ ∈ [0,1].
|
||||
/// The guard rejects a frame as unsanitized when V exceeds this cutoff, i.e.
|
||||
/// when the mean resultant length R̄ falls below `1 − MAX`. At V = 0.99 the
|
||||
/// guard fires only when R̄ ≤ 0.01 — essentially uniform phase, the signature
|
||||
/// of raw SFO/CFO ramps the gate is meant to reject — while a sanitized,
|
||||
/// concentrated phase set (R̄ near 1, V near 0) passes comfortably.
|
||||
///
|
||||
/// **DATA-GATED (ADR-154 §7.4 #1):** this is a deliberately *conservative*
|
||||
/// default, not a calibrated operating point. A clean single-path channel with
|
||||
/// appreciable delay also sweeps the circle (high V), so V alone cannot cleanly
|
||||
/// separate "clean ramp" from "unsanitized noise" without labelled
|
||||
/// sanitized/unsanitized frames. The *metric* (circular variance) is MEASURED;
|
||||
/// this *value* awaits per-deployment calibration. Until then we err toward
|
||||
/// never false-rejecting a real frame — strictly more permissive at the wrap
|
||||
/// boundary than the old linear-variance guard, which is the bug being fixed.
|
||||
const GHOST_TAP_CIRCULAR_VARIANCE_MAX: f32 = 0.99;
|
||||
|
||||
/// Circular variance of the instantaneous phase angles across a complex vector.
|
||||
///
|
||||
/// Phase angles live on the circle and wrap at ±π, so a *linear* sample variance
|
||||
/// (the previous implementation, ADR-154 §7.4 #1) reports spuriously HIGH
|
||||
/// dispersion for a tightly-clustered set straddling the ±π branch cut — e.g.
|
||||
/// `{+3.13, −3.13}` are 0.02 rad apart on the circle but ≈2π apart on the line.
|
||||
/// That made the `phase_variance > TAU` ghost-tap guard FALSE-TRIP on real,
|
||||
/// tightly-clustered CIR taps.
|
||||
///
|
||||
/// The correct metric is Mardia's circular variance:
|
||||
///
|
||||
/// R̄ = | (1/n) · Σ_k e^{iθ_k} | (mean resultant length, ∈ [0,1])
|
||||
/// V = 1 − R̄ (circular variance, ∈ [0,1])
|
||||
///
|
||||
/// V = 0 ⇔ all angles identical (maximally concentrated); V = 1 ⇔ the unit
|
||||
/// phasors cancel (e.g. uniformly-spread angles → R̄ = 0). It is invariant to
|
||||
/// where the cluster sits on the circle, so the branch-cut artefact is gone.
|
||||
///
|
||||
/// Reference: Mardia & Jupp, *Directional Statistics* (2000), §1.3.
|
||||
#[inline]
|
||||
fn phase_variance(y: &[Complex32]) -> f32 {
|
||||
let n = y.len();
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
// Mean resultant vector of the *unit* phasors e^{iθ_k}. Normalising each
|
||||
// term to unit magnitude makes this a pure phase statistic (amplitude does
|
||||
// not bias the dispersion), matching the linear version which used only
|
||||
// `arg()`.
|
||||
let mut sx = 0.0f32;
|
||||
let mut sy = 0.0f32;
|
||||
for c in y {
|
||||
let theta = c.arg();
|
||||
sx += theta.cos();
|
||||
sy += theta.sin();
|
||||
}
|
||||
let nf = n as f32;
|
||||
let phases: Vec<f32> = y.iter().map(|c| c.arg()).collect();
|
||||
let mean = phases.iter().sum::<f32>() / nf;
|
||||
phases.iter().map(|p| (p - mean) * (p - mean)).sum::<f32>() / nf
|
||||
let r_bar = ((sx * sx + sy * sy).sqrt() / nf).clamp(0.0, 1.0);
|
||||
1.0 - r_bar
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1205,6 +1259,108 @@ mod tests {
|
||||
assert!(phase_variance(&y) < 1e-6);
|
||||
}
|
||||
|
||||
// ── ADR-154 §7.4 #1: circular vs linear phase variance ──────────────────
|
||||
|
||||
/// Inline replica of the OLD linear sample variance over `arg()` — kept in
|
||||
/// the test only, so we can show the exact contrast the fix removes.
|
||||
fn old_linear_phase_variance(y: &[Complex32]) -> f32 {
|
||||
let n = y.len();
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let nf = n as f32;
|
||||
let phases: Vec<f32> = y.iter().map(|c| c.arg()).collect();
|
||||
let mean = phases.iter().sum::<f32>() / nf;
|
||||
phases.iter().map(|p| (p - mean) * (p - mean)).sum::<f32>() / nf
|
||||
}
|
||||
|
||||
/// FAILS-ON-OLD: phases tightly clustered across the ±π branch cut. The old
|
||||
/// LINEAR variance reports a huge value (≈π²) and would trip the `> TAU`
|
||||
/// guard; the new CIRCULAR variance reports ≈0 (the cluster is 0.04 rad wide
|
||||
/// on the circle) and the guard does NOT false-trip.
|
||||
#[test]
|
||||
fn phase_variance_circular_not_fooled_by_branch_cut() {
|
||||
// 40 unit phasors split between +π−ε and −π+ε: true angular spread ≈0.04
|
||||
// rad, but they straddle the wrap point.
|
||||
let eps = 0.02_f32;
|
||||
let y: Vec<Complex32> = (0..40)
|
||||
.map(|i| {
|
||||
let theta = if i % 2 == 0 {
|
||||
std::f32::consts::PI - eps
|
||||
} else {
|
||||
-std::f32::consts::PI + eps
|
||||
};
|
||||
Complex32::new(theta.cos(), theta.sin())
|
||||
})
|
||||
.collect();
|
||||
|
||||
let old = old_linear_phase_variance(&y);
|
||||
let new = phase_variance(&y);
|
||||
|
||||
// The OLD metric is spuriously huge (well past the old TAU≈6.28 guard).
|
||||
assert!(
|
||||
old > std::f32::consts::TAU,
|
||||
"old linear variance should be large (>TAU) on wrap-straddling phases, was {old}"
|
||||
);
|
||||
// The NEW circular variance is ≈0 — the cluster is genuinely tight.
|
||||
assert!(
|
||||
new < 0.01,
|
||||
"circular variance must be ~0 for a tight cluster across ±π, was {new}"
|
||||
);
|
||||
// And the guard must NOT false-trip on this (a real tight CIR tap).
|
||||
assert!(
|
||||
new <= GHOST_TAP_CIRCULAR_VARIANCE_MAX,
|
||||
"ghost-tap guard must not false-trip on a tight wrap-straddling cluster"
|
||||
);
|
||||
}
|
||||
|
||||
/// Circular variance is bounded [0,1] for arbitrary (deterministic-random)
|
||||
/// inputs, and hits its documented extremes: ≈0 for identical angles, ≈1
|
||||
/// for uniformly-spread angles.
|
||||
#[test]
|
||||
fn phase_variance_circular_is_bounded_and_extremal() {
|
||||
// Deterministic pseudo-random phases via an LCG — bounded check.
|
||||
let mut s: u32 = 0x1234_5678;
|
||||
let y: Vec<Complex32> = (0..200)
|
||||
.map(|_| {
|
||||
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
let u = (s >> 8) as f32 / (1u32 << 24) as f32; // [0,1)
|
||||
let theta = u * std::f32::consts::TAU - std::f32::consts::PI;
|
||||
Complex32::new(theta.cos(), theta.sin())
|
||||
})
|
||||
.collect();
|
||||
let v = phase_variance(&y);
|
||||
assert!((0.0..=1.0).contains(&v), "V must be in [0,1], was {v}");
|
||||
|
||||
// Identical angles → V ≈ 0.
|
||||
let same: Vec<Complex32> = (0..64)
|
||||
.map(|_| {
|
||||
let t = 0.7_f32;
|
||||
Complex32::new(t.cos(), t.sin())
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
phase_variance(&same) < 1e-5,
|
||||
"identical angles must give V≈0, got {}",
|
||||
phase_variance(&same)
|
||||
);
|
||||
|
||||
// Angles spread uniformly around the full circle → resultant cancels,
|
||||
// V ≈ 1.
|
||||
let n = 360usize;
|
||||
let uniform: Vec<Complex32> = (0..n)
|
||||
.map(|k| {
|
||||
let t = std::f32::consts::TAU * (k as f32) / (n as f32);
|
||||
Complex32::new(t.cos(), t.sin())
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
phase_variance(&uniform) > 0.99,
|
||||
"uniformly-spread angles must give V≈1, got {}",
|
||||
phase_variance(&uniform)
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a CsiFrame with a deterministic single-tap channel at `tau_sec`.
|
||||
fn make_single_tap_frame(
|
||||
num_subcarriers: usize,
|
||||
|
||||
@@ -249,11 +249,22 @@ pub fn coherence_score(current: &[f32], reference: &[f32], variance: &[f32]) ->
|
||||
(weighted_sum / weight_sum).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Coherence score at/above which the environment is classified `Stable`
|
||||
/// (ADR-154 §7.4 #9 — DATA-GATED). EMPIRICAL DEFAULT, not a calibrated cutoff:
|
||||
/// a defensible value needs labelled stable/drifting environment traces. Pinned
|
||||
/// by `classify_drift_*_boundary` so a future retune is a visible, tested change.
|
||||
const DRIFT_STABLE_SCORE: f32 = 0.85;
|
||||
|
||||
/// Stale-frame count below which a coherence loss is treated as a transient
|
||||
/// `StepChange` rather than a sustained `Linear` drift (ADR-154 §7.4 #9 —
|
||||
/// DATA-GATED). EMPIRICAL DEFAULT pending labelled calibration.
|
||||
const DRIFT_STEP_CHANGE_MAX_STALE: u64 = 10;
|
||||
|
||||
/// Classify drift profile based on coherence history.
|
||||
fn classify_drift(score: f32, stale_count: u64) -> DriftProfile {
|
||||
if score >= 0.85 {
|
||||
if score >= DRIFT_STABLE_SCORE {
|
||||
DriftProfile::Stable
|
||||
} else if stale_count < 10 {
|
||||
} else if stale_count < DRIFT_STEP_CHANGE_MAX_STALE {
|
||||
// Brief coherence loss -> likely step change
|
||||
DriftProfile::StepChange
|
||||
} else {
|
||||
@@ -418,6 +429,38 @@ mod tests {
|
||||
assert_eq!(classify_drift(0.3, 20), DriftProfile::Linear);
|
||||
}
|
||||
|
||||
// ── ADR-154 §7.4 #9: drift-threshold characterization (DATA-GATED) ──────
|
||||
// Pin the CURRENT empirical thresholds so a future labelled-data retune is a
|
||||
// visible, tested change. These assert the decision boundaries, not that the
|
||||
// values are "correct".
|
||||
|
||||
/// The named consts must equal the original bare literals (no value drift).
|
||||
#[test]
|
||||
fn drift_consts_unchanged_from_literals() {
|
||||
assert_eq!(DRIFT_STABLE_SCORE, 0.85);
|
||||
assert_eq!(DRIFT_STEP_CHANGE_MAX_STALE, 10);
|
||||
}
|
||||
|
||||
/// Stable score boundary: `>= 0.85` is Stable; just below flips to a
|
||||
/// non-stable profile.
|
||||
#[test]
|
||||
fn classify_drift_stable_score_boundary() {
|
||||
// exactly at threshold → Stable
|
||||
assert_eq!(classify_drift(0.85, 0), DriftProfile::Stable);
|
||||
// just below → not Stable (StepChange, since stale_count < 10)
|
||||
assert_eq!(classify_drift(0.849, 0), DriftProfile::StepChange);
|
||||
}
|
||||
|
||||
/// Stale-count boundary: `< 10` is StepChange, `>= 10` is Linear (when the
|
||||
/// score is below the Stable cutoff).
|
||||
#[test]
|
||||
fn classify_drift_stale_count_boundary() {
|
||||
// just below 10 → StepChange
|
||||
assert_eq!(classify_drift(0.3, 9), DriftProfile::StepChange);
|
||||
// exactly 10 → Linear
|
||||
assert_eq!(classify_drift(0.3, 10), DriftProfile::Linear);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_subcarrier_zscores_correct() {
|
||||
let current = vec![2.0, 4.0];
|
||||
|
||||
@@ -77,13 +77,27 @@ pub struct GatePolicyConfig {
|
||||
pub adaptive: bool,
|
||||
}
|
||||
|
||||
// Gate-policy DEFAULTS (ADR-154 §7.4 #9 — DATA-GATED). These were bare literals
|
||||
// in the `Default` impl. They are already tunable per-instance via
|
||||
// `GatePolicyConfig`/`GatePolicy::new` (the config seam exists), so de-magicking
|
||||
// here is about naming + pinning the DEFAULTS. EMPIRICAL — defensible values
|
||||
// need labelled coherence traces; the VALUES are unchanged.
|
||||
/// Default coherence accept cutoff (full Kalman update above this).
|
||||
const DEFAULT_ACCEPT_THRESHOLD: f32 = 0.85;
|
||||
/// Default coherence reject cutoff (discard measurement below this).
|
||||
const DEFAULT_REJECT_THRESHOLD: f32 = 0.5;
|
||||
/// Default stale-frame budget before forcing recalibration (≈10 s at 20 Hz).
|
||||
const DEFAULT_MAX_STALE_FRAMES: u64 = 200;
|
||||
/// Default PredictOnly-zone measurement-noise inflation factor.
|
||||
const DEFAULT_PREDICT_ONLY_NOISE: f32 = 3.0;
|
||||
|
||||
impl Default for GatePolicyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
accept_threshold: 0.85,
|
||||
reject_threshold: 0.5,
|
||||
max_stale_frames: 200, // 10s at 20Hz
|
||||
predict_only_noise: 3.0,
|
||||
accept_threshold: DEFAULT_ACCEPT_THRESHOLD,
|
||||
reject_threshold: DEFAULT_REJECT_THRESHOLD,
|
||||
max_stale_frames: DEFAULT_MAX_STALE_FRAMES,
|
||||
predict_only_noise: DEFAULT_PREDICT_ONLY_NOISE,
|
||||
adaptive: false,
|
||||
}
|
||||
}
|
||||
@@ -114,7 +128,7 @@ impl GatePolicy {
|
||||
accept_threshold: accept,
|
||||
reject_threshold: reject,
|
||||
max_stale_frames: max_stale,
|
||||
predict_only_noise: 3.0,
|
||||
predict_only_noise: DEFAULT_PREDICT_ONLY_NOISE,
|
||||
consecutive_low: 0,
|
||||
last_decision: None,
|
||||
}
|
||||
@@ -343,6 +357,17 @@ mod tests {
|
||||
assert!(!cfg.adaptive);
|
||||
}
|
||||
|
||||
/// ADR-154 §7.4 #9 (DATA-GATED): the named DEFAULT_* consts must equal the
|
||||
/// original bare literals — pins the de-magicked defaults so a future
|
||||
/// labelled-data retune is a visible, tested change. Values UNCHANGED.
|
||||
#[test]
|
||||
fn gate_default_consts_unchanged_from_literals() {
|
||||
assert_eq!(DEFAULT_ACCEPT_THRESHOLD, 0.85);
|
||||
assert_eq!(DEFAULT_REJECT_THRESHOLD, 0.5);
|
||||
assert_eq!(DEFAULT_MAX_STALE_FRAMES, 200);
|
||||
assert_eq!(DEFAULT_PREDICT_ONLY_NOISE, 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_config_construction() {
|
||||
let cfg = GatePolicyConfig {
|
||||
|
||||
@@ -105,6 +105,10 @@ impl WelfordStats {
|
||||
}
|
||||
|
||||
/// Population variance (biased). Returns 0.0 if count < 2.
|
||||
///
|
||||
/// The `count < 2` guard is the n=0 NaN guard (ADR-154 §7.4 #10): at n=0,
|
||||
/// `m2 = 0` and `count = 0` would yield `0.0/0.0 = NaN`. Pinned by
|
||||
/// `welford_finite_at_n0_and_n1`.
|
||||
pub fn variance(&self) -> f64 {
|
||||
if self.count < 2 {
|
||||
0.0
|
||||
@@ -119,6 +123,10 @@ impl WelfordStats {
|
||||
}
|
||||
|
||||
/// Sample variance (unbiased). Returns 0.0 if count < 2.
|
||||
///
|
||||
/// The `count < 2` guard is load-bearing (ADR-154 §7.4 #10): at n=0 the
|
||||
/// `(self.count - 1)` term would underflow `0usize − 1` and at n=1 it would
|
||||
/// divide by zero. Pinned by `welford_finite_at_n0_and_n1`.
|
||||
pub fn sample_variance(&self) -> f64 {
|
||||
if self.count < 2 {
|
||||
0.0
|
||||
@@ -958,6 +966,52 @@ mod tests {
|
||||
assert!((w.variance() - 0.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
/// ADR-154 §7.4 #10: every statistic must stay FINITE at the n=0 and n=1
|
||||
/// boundaries. This pins the load-bearing `count < 2` guards: without them
|
||||
/// `sample_variance` at n=0 underflows `(0usize − 1)` and divides by a huge
|
||||
/// bogus divisor, and `variance`/`z_score` produce `0.0/0.0 = NaN`. Same
|
||||
/// family as the §4 divide-by-(n−1) window trio.
|
||||
#[test]
|
||||
fn welford_finite_at_n0_and_n1() {
|
||||
// n = 0: fresh accumulator, nothing observed.
|
||||
let w0 = WelfordStats::new();
|
||||
assert_eq!(w0.count, 0);
|
||||
for v in [
|
||||
w0.mean,
|
||||
w0.variance(),
|
||||
w0.sample_variance(),
|
||||
w0.std_dev(),
|
||||
w0.z_score(123.0),
|
||||
] {
|
||||
assert!(v.is_finite(), "n=0 statistic must be finite, got {v}");
|
||||
}
|
||||
// Documented sentinels at n=0.
|
||||
assert_eq!(w0.variance(), 0.0);
|
||||
assert_eq!(w0.sample_variance(), 0.0);
|
||||
assert_eq!(w0.std_dev(), 0.0);
|
||||
assert_eq!(w0.z_score(123.0), 0.0);
|
||||
|
||||
// n = 1: a single observation has no spread.
|
||||
let mut w1 = WelfordStats::new();
|
||||
w1.update(7.5);
|
||||
assert_eq!(w1.count, 1);
|
||||
for v in [
|
||||
w1.mean,
|
||||
w1.variance(),
|
||||
w1.sample_variance(),
|
||||
w1.std_dev(),
|
||||
w1.z_score(7.5),
|
||||
w1.z_score(999.0),
|
||||
] {
|
||||
assert!(v.is_finite(), "n=1 statistic must be finite, got {v}");
|
||||
}
|
||||
assert_eq!(w1.variance(), 0.0);
|
||||
assert_eq!(w1.sample_variance(), 0.0);
|
||||
assert_eq!(w1.std_dev(), 0.0);
|
||||
// z_score guards on near-zero sd → 0.0 even for an off-mean query.
|
||||
assert_eq!(w1.z_score(999.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_link_baseline_stats() {
|
||||
let mut stats = LinkBaselineStats::new(4);
|
||||
|
||||
Reference in New Issue
Block a user