Files
ruvnet--RuView/v2/crates/ruview-swarm/src/evals/runner.rs
T
rUv 42dcf49f4d 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>
2026-06-13 14:31:38 -04:00

365 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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,
//! simulates CSI victim detection perturbed by `(sigma, kappa)` amplitude /
//! von-Mises-phase noise, and computes the GDOP of the contributing-drone
//! constellation at first detection. It is self-contained and seeded — no
//! Candle / training backend required — so it runs in CI by default.
use crate::config::SwarmConfig;
use crate::evals::gdop::gdop;
use crate::evals::metrics::EpisodeMetrics;
use crate::planning::patterns::{FlightPattern, PatternContext};
use crate::types::{NodeId, Position3D};
/// CSI-noise level: amplitude std `sigma` and von-Mises phase concentration `kappa`.
/// Higher `sigma` = noisier amplitude; *lower* `kappa` = noisier phase (more diffuse).
#[derive(Debug, Clone, Copy)]
pub struct NoiseLevel {
pub sigma: f64,
pub kappa: f64,
}
/// One evaluation configuration: a flight pattern + swarm/mission parameters.
#[derive(Debug, Clone)]
pub struct EvalConfig {
pub flight: FlightPattern,
pub config: SwarmConfig,
pub drones: usize,
pub steps: usize,
pub seeds: usize, // ≥10 per ADR-171
pub episodes_per_seed: usize, // e.g. 50
pub victims: Vec<Position3D>,
pub noise: NoiseLevel,
}
impl EvalConfig {
/// A small SAR default suitable for fast CI runs.
pub fn sar_small(flight: FlightPattern) -> Self {
EvalConfig {
flight,
config: SwarmConfig::sar_default(),
drones: 4,
steps: 120,
seeds: 10,
episodes_per_seed: 10,
victims: vec![
Position3D { x: 120.0, y: 90.0, z: 0.0 },
Position3D { x: 320.0, y: 280.0, z: 0.0 },
],
noise: NoiseLevel { sigma: 0.05, kappa: 8.0 },
}
}
}
/// Minimal reproducible LCG → f64 in [0, 1). Self-contained for determinism.
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Lcg(seed ^ 0xD1B5_4A32_D192_ED03)
}
#[inline]
fn next_u64(&mut self) -> u64 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.0
}
#[inline]
fn unit(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
/// Standard-normal sample via BoxMuller (deterministic).
#[inline]
fn normal(&mut self) -> f64 {
let u1 = self.unit().max(1e-12);
let u2 = self.unit();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
}
/// Run one kinematic episode deterministically from `seed`.
///
/// Drives drones step-by-step by the flight pattern, marks a coarse coverage
/// grid, and on the first step a drone comes within scan range of any victim
/// records a fused localization estimate (weighted centroid of contributing
/// drones' per-drone victim estimates, each perturbed by `(sigma, kappa)`
/// noise) and the GDOP of those contributing drones.
pub fn run_episode(cfg: &EvalConfig, seed: u64) -> EpisodeMetrics {
let mut rng = Lcg::new(seed);
let area_w = cfg.config.mission.area_width_m;
let area_h = cfg.config.mission.area_height_m;
let altitude_z = -cfg.config.planning.flight_altitude_m;
let scan_width = cfg.config.planning.csi_scan_width_m.max(1.0);
let min_sep = cfg.config.formation.min_separation_m.max(0.1);
let n = cfg.drones.max(1);
// Coverage grid sized so each cell ~= scan_width.
let gx = ((area_w / scan_width).ceil() as usize).max(1);
let gy = ((area_h / scan_width).ceil() as usize).max(1);
let cell_w = area_w / gx as f64;
let cell_h = area_h / gy as f64;
let mut cover_count = vec![0u32; gx * gy];
// Spread drones along the bottom edge with a small seeded jitter.
let mut positions: Vec<Position3D> = (0..n)
.map(|i| {
let frac = (i as f64 + 0.5) / n as f64;
Position3D {
x: (frac * area_w + (rng.unit() - 0.5) * scan_width).clamp(0.0, area_w),
y: (rng.unit() * scan_width).clamp(0.0, area_h),
z: altitude_z,
}
})
.collect();
// Recent-visit ring buffer for pheromone / potential-field patterns.
let mut visited: Vec<Position3D> = Vec::new();
let max_visited = 32usize;
let scan_range = scan_width; // detect a victim within one scan footprint
let mut collisions = 0u32;
let mut detected = false;
let mut loc_error: Option<f64> = None;
let mut gdop_val: Option<f64> = None;
let mut t_detect: Option<f64> = None;
let dt = step_seconds(cfg);
for step in 0..cfg.steps {
// Advance each drone one waypoint under the pattern.
let snapshot = positions.clone();
for (i, pos) in positions.iter_mut().enumerate() {
let peers: Vec<Position3D> = snapshot
.iter()
.enumerate()
.filter(|(j, _)| *j != i)
.map(|(_, p)| *p)
.collect();
let ctx = PatternContext {
drone_id: NodeId(i as u32),
swarm_size: n,
current: *pos,
area_w,
area_h,
altitude_z,
scan_width_m: scan_width,
step: step as u64,
visited: &visited,
peers: &peers,
};
*pos = cfg.flight.next_target(&ctx);
}
// Mark coverage + record visits.
for pos in &positions {
let cx = ((pos.x / cell_w).floor() as i64).clamp(0, gx as i64 - 1) as usize;
let cy = ((pos.y / cell_h).floor() as i64).clamp(0, gy as i64 - 1) as usize;
cover_count[cy * gx + cx] = cover_count[cy * gx + cx].saturating_add(1);
visited.push(*pos);
}
if visited.len() > max_visited {
let drop = visited.len() - max_visited;
visited.drain(0..drop);
}
// Proximity / collision check (kinematic proxy).
for a in 0..positions.len() {
for b in (a + 1)..positions.len() {
let d = positions[a].distance_to(&positions[b]);
if d < min_sep {
collisions = collisions.saturating_add(1);
}
}
}
// Detection: first step any victim falls within scan range of ≥1 drone,
// fuse a localization estimate from the contributing drones. A single
// contributor still yields a (noisier) estimate; GDOP is only defined
// for the multistatic ≥2-drone case and is `None` otherwise.
if !detected {
for victim in &cfg.victims {
let contributors: Vec<Position3D> = positions
.iter()
.filter(|p| horiz_dist(p, victim) <= scan_range)
.copied()
.collect();
if !contributors.is_empty() {
let (est, g) = fuse_estimate(&contributors, victim, cfg.noise, &mut rng);
loc_error = Some(horiz_dist(&est, victim));
gdop_val = g; // None for a single contributor
t_detect = Some((step as f64 + 1.0) * dt);
detected = true;
break;
}
}
}
}
// Coverage + overlap.
let total_cells = (gx * gy) as f64;
let scanned = cover_count.iter().filter(|&&c| c > 0).count() as f64;
let overlapped = cover_count.iter().filter(|&&c| c > 1).count() as f64;
let coverage_pct = if total_cells > 0.0 { scanned / total_cells } else { 0.0 };
let overlap_ratio = if scanned > 0.0 { overlapped / scanned } else { 0.0 };
// Episodic return: reward coverage + detection, penalize overlap + collisions.
let detect_bonus = if detected { 1.0 } else { 0.0 };
let loc_term = match loc_error {
Some(e) => (1.0 / (1.0 + e)).max(0.0),
None => 0.0,
};
let episodic_return = 100.0 * coverage_pct + 30.0 * detect_bonus + 20.0 * loc_term
- 10.0 * overlap_ratio
- 5.0 * collisions as f64;
EpisodeMetrics {
coverage_pct,
localization_error_m: loc_error,
gdop_at_detection: gdop_val,
time_to_first_detection_s: t_detect,
detected,
collisions,
overlap_ratio,
episodic_return,
}
}
/// Per-step wall-clock seconds, derived from scan width and drone speed.
fn step_seconds(cfg: &EvalConfig) -> f64 {
let speed = cfg.config.planning.max_speed_ms.max(0.1);
(cfg.config.planning.csi_scan_width_m.max(1.0) / speed).max(0.1)
}
/// Horizontal (x, y) distance, ignoring altitude.
fn horiz_dist(a: &Position3D, b: &Position3D) -> f64 {
(a.x - b.x).hypot(a.y - b.y)
}
/// Fuse contributing drones' per-drone victim estimates into a weighted
/// centroid, perturbed by `(sigma, kappa)` CSI noise, and compute the GDOP of
/// the contributing constellation.
fn fuse_estimate(
contributors: &[Position3D],
victim: &Position3D,
noise: NoiseLevel,
rng: &mut Lcg,
) -> (Position3D, Option<f64>) {
// Phase noise std from von Mises concentration: sigma_phase ≈ 1/sqrt(kappa).
let phase_std = 1.0 / noise.kappa.max(1e-3).sqrt();
let mut sx = 0.0;
let mut sy = 0.0;
let mut wsum = 0.0;
for c in contributors {
let range = horiz_dist(c, victim).max(1e-6);
// Each drone's estimate = true victim + range-scaled amplitude noise +
// bearing error from phase noise (perpendicular to LOS).
let amp = noise.sigma * range;
let nx = rng.normal() * amp;
let ny = rng.normal() * amp;
// Bearing wobble: rotate LOS unit vector by a small phase-noise angle.
let bearing = (victim.y - c.y).atan2(victim.x - c.x);
let dtheta = rng.normal() * phase_std;
let bx = range * (bearing + dtheta).cos();
let by = range * (bearing + dtheta).sin();
let est_x = c.x + bx + nx;
let est_y = c.y + by + ny;
// Inverse-range weighting: closer drones trusted more.
let w = 1.0 / range;
sx += est_x * w;
sy += est_y * w;
wsum += w;
}
let w = wsum.max(1e-9);
let est = Position3D { x: sx / w, y: sy / w, z: 0.0 };
let g = gdop(contributors, victim);
(est, g)
}
/// Run the full seed × episode matrix → per-seed strata of [`EpisodeMetrics`].
pub fn run_matrix(cfg: &EvalConfig) -> Vec<Vec<EpisodeMetrics>> {
(0..cfg.seeds)
.map(|s| {
(0..cfg.episodes_per_seed)
.map(|e| {
// Distinct deterministic seed per (seed, episode) cell.
let cell_seed = (s as u64)
.wrapping_mul(0x100_0000)
.wrapping_add(e as u64)
.wrapping_add(0xABCD);
run_episode(cfg, cell_seed)
})
.collect()
})
.collect()
}
/// 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];
let mut out = Vec::with_capacity(sigmas.len() * kappas.len());
for &sigma in &sigmas {
for &kappa in &kappas {
out.push(NoiseLevel { sigma, kappa });
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_run_episode_deterministic() {
let cfg = EvalConfig::sar_small(FlightPattern::PartitionedLawnmower);
let a = run_episode(&cfg, 12345);
let b = run_episode(&cfg, 12345);
assert_eq!(a.coverage_pct, b.coverage_pct);
assert_eq!(a.detected, b.detected);
assert_eq!(a.localization_error_m, b.localization_error_m);
assert_eq!(a.collisions, b.collisions);
assert_eq!(a.episodic_return, b.episodic_return);
}
#[test]
fn test_partitioned_beats_levy_coverage() {
let mut part = EvalConfig::sar_small(FlightPattern::PartitionedLawnmower);
part.seeds = 3;
part.episodes_per_seed = 5;
let mut levy = part.clone();
levy.flight = FlightPattern::LevyFlight;
let part_m = run_matrix(&part);
let levy_m = run_matrix(&levy);
let part_agg = crate::evals::metrics::AggregateMetrics::from_strata(&part_m, 1);
let levy_agg = crate::evals::metrics::AggregateMetrics::from_strata(&levy_m, 1);
assert!(
part_agg.coverage_iqm.point > levy_agg.coverage_iqm.point,
"partitioned coverage {} should beat levy {}",
part_agg.coverage_iqm.point,
levy_agg.coverage_iqm.point
);
}
#[test]
fn test_matrix_shape() {
let mut cfg = EvalConfig::sar_small(FlightPattern::Spiral);
cfg.seeds = 4;
cfg.episodes_per_seed = 6;
let m = run_matrix(&cfg);
assert_eq!(m.len(), 4);
assert!(m.iter().all(|s| s.len() == 6));
}
#[test]
fn test_noise_sweep_grid() {
let sweep = default_noise_sweep();
assert_eq!(sweep.len(), 9);
}
}