Files
ruvnet--RuView/v2/crates/ruview-swarm/src/marl/learning.rs
T
rUv 0d3d835bf8 feat(swarm): add ruview-swarm crate — drone swarm control system (ADR-148) (#862)
* feat(swarm): add wifi-densepose-swarm crate implementing ADR-148 drone swarm control system

New crate `wifi-densepose-swarm` with hierarchical-mesh swarm topology,
Raft consensus, MAPPO MARL, CSI sensing integration, and ITAR-gated
coordination features. Closes 3 of 7 milestones (M1, M2, M5) with 5/5
ADR-148 SOTA performance targets met.

## Modules (45 source files, 14 modules)

- types: NodeId, DroneState, Position3D, SwarmTask, SwarmError, FailSafeState
- topology: Raft consensus (leader election, log replication, quorum), Gossip, Mesh
- formation: VirtualStructure, LeaderFollower, Reynolds flocking (itar-gated)
- planning: RRT-APF hybrid planner, 3-phase coverage, Bayesian grid, pheromone
- allocation: Auction + FNN bid scorer (itar-gated)
- sensing: CsiPayloadPipeline (Live/Synthetic/Replay), MultiViewFusion, OccWorldBridge
- marl: MAPPO actor (3-layer MLP), LocalObservation (64-dim), RewardCalculator, PPO loop
- security: MAVLink v2 HMAC-SHA256, UWB anti-spoofing, geofence, Remote ID, FHSS
- failsafe: 10-state onboard machine, GCS-independent safety transitions
- config: TOML SwarmConfig with SAR/inspection/agriculture/mine/demo/wi2sar_reference
- demo: SyntheticCsiGenerator, DemoScenario (SAR/open-field/mine)
- integration: FlightController trait, MAVLink dialect (50000-50005), SwarmSim
- orchestrator: SwarmOrchestrator wiring all subsystems end-to-end
- bench_support: Criterion fixture generators

## ITAR compliance

Swarming coordination features gated behind `itar-unrestricted` feature
per USML Category VIII(h)(12). Default build compiles clean stubs.

## Benchmark results (criterion, release mode)

- MARL actor inference: 3.3 µs (target ≤ 5 ms — 1,516× headroom)
- RRT-APF planning (100 iter): 0.043 ms (target < 300 ms — 6,946× headroom)
- MultiView CSI fusion (3 UAVs): 58.5 ns (target < 10 ms — 171,000× headroom)
- 3-view localization: 1.732 m (target ≤ 2 m — beats Wi2SAR SOTA)
- 4-drone SAR coverage (400×400 m): 223 s (target ≤ 240 s — PASS)

## Tests

- --no-default-features: 73/73 passing
- --features itar-unrestricted: 85/85 passing

Closes #861

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(swarm): rename wifi-densepose-swarm → ruview-swarm

The swarm control system is a RuView-level capability (drone coordination,
Raft consensus, MARL) that operates above the wifi-densepose sensing layer
rather than being a sub-component of it. Rename aligns with the project
identity and separates coordination infrastructure from sensing modules.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(swarm): resolve all clippy warnings + add MARL convergence test

- planning/probability_grid: map_or(true,…) → is_none_or (clippy::unnecessary_map_or)
- planning/pheromone: &mut Vec<T> → &mut [T] on evaporate+deposit (clippy::ptr_arg)
- marl/observation: fix doc lazy-continuation warning on TOTAL line
- marl/trainer: manual Default impl → #[derive(Default)] + #[default] on Demo variant

Also adds test_marl_convergence_improves_mean_return: fills 64-transition
ReplayBuffer with mixed rewards (steps 0-31: negative, 32-63: positive),
runs ppo_update, asserts mean_return is finite and non-zero.

Result: 0 clippy warnings · 74/74 tests (default) · 86/86 (itar-unrestricted)

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): integrate Ruflo AI-agent capabilities into ruview-swarm

Adds a feature-gated Ruflo integration layer connecting ruview-swarm to the
claude-flow daemon's AgentDB, AIDefence, and SONA intelligence subsystems.
Default build is unaffected (all paths behind `Option<Box<dyn RufloBackend>>`).

## New module: src/ruflo/

- backend.rs: RufloBackend trait (9 async methods) + RufloError, MissionMemoryEntry,
  PatternEntry, MavlinkScanResult types (always compiled)
- mock_backend.rs: MockRufloBackend in-memory impl for testing (always compiled, 5 tests)
- http_backend.rs: HttpRufloBackend — JSON-RPC 2.0 → claude-flow daemon localhost:3000
  (gated behind `ruflo` feature, requires reqwest)
- mission_summary.rs: MissionSummary serializer with pattern description + confidence
  scoring from victim recall, coverage %, collision penalty (always compiled, 3 tests)

## 4 capability areas

1. MissionMemory   → memory_store / memory_search       (cross-mission victim memory)
2. PatternLearner  → agentdb_pattern-store / -search     (HNSW SONA trajectory patterns)
3. MavlinkDefence  → aidefence_is_safe / aidefence_scan  (scan MAVLink before accepting)
4. IntelligenceHooks → trajectory-start/step/end          (SONA learning loop)

## SwarmOrchestrator integration

- with_ruflo(backend): builder to attach a backend
- start_trajectory(task) / finish_trajectory(success, key): SONA mission lifecycle
- receive_peer_detection_checked(): AIDefence scan before accepting peer detections

## Cargo feature

`ruflo = ["dep:reqwest", "dep:serde_json"]` — optional, not in default

## Tests

- --no-default-features: 82/82 pass (8 new ruflo tests)
- --features ruflo,itar-unrestricted: 94/94 pass

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): M7 mission profiles with victim confirmation reports + pre-merge docs

Adds end-to-end mission runners producing structured MissionReport output,
and updates project docs (CHANGELOG, README, CLAUDE.md) per pre-merge checklist.

## M7 Mission Profiles (integration/mission_report.rs + swarm_sim.rs)

- MissionReport / VictimReport / SotaComparison types (serde-serializable)
- run_mission_with_report(): full mission → detailed report with per-victim
  localization error, fusion uncertainty, contributing drones, detection time
- run_inspection_mission(): leader-follower power-line corridor inspection
- run_mine_mission(): GPS-denied underground (2-drone, slow, UWB-only)
- SotaComparison embeds Wi2SAR baseline (5m / 810s) vs achieved metrics

## Docs (pre-merge checklist)

- CHANGELOG.md: ruview-swarm + Ruflo integration + performance entries
- README.md: ruview-swarm row
- CLAUDE.md: Key Rust Crates table row + ADR-148 in ADR list

## Tests
- --no-default-features: 86/86 pass
- --features ruflo,itar-unrestricted: 98/98 pass

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(swarm): convergence-assist for victim fusion + 5s Ruflo HTTP timeout

Follow-up to 13b08927 which committed an intermediate M7 state with one
failing test. This lands the M7 agent's convergence fixes and the security
review's timeout hardening.

## Fixes
- swarm_sim.rs: min-separation nudge before collision metric (0 collisions
  with staggered starts) + Phase-3 convergence assist that vectors the nearest
  idle peer toward a single-drone CSI contact so multi-view fusion can fire
- http_backend.rs: add 5s request timeout to reqwest client (security review
  Medium finding — a dead daemon would otherwise hang the swarm step loop)

## Security review verdict (HttpRufloBackend)
Safe to merge. No credentials in requests, serde_json prevents injection,
fail-open on daemon-down is documented and appropriate for SAR missions,
MAVLink passed as structured text (not raw bytes). Timeout fix applied.

## Tests
- --no-default-features: 87/87 pass
- --features ruflo,itar-unrestricted: 100/100 pass

Co-Authored-By: claude-flow <ruv@ruv.net>

* perf(swarm): add PPO training-throughput benchmark + fix bench crate-name imports

- bench_ppo_update: PPO update over 64-transition buffer — 244 µs median
- fix: bench imports referenced stale `wifi_densepose_swarm` (pre-rename),
  corrected to `ruview_swarm` so the bench target compiles

M6 benchmark suite now 5/5 compiling and running. Tests unchanged: 87/100.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): real Candle autodiff PPO + A-MAPPO role attention + GPU training (M4)

Replaces the finite-difference PPO placeholder with a real GPU-capable Candle
0.9 autodiff trainer, adds A-MAPPO heterogeneous-role attention, a runnable
training binary, and right-sized GCP/local launch scripts. This is the unlock
that makes "GPU long training cycles" actually mean something — the previous
ppo_update did no gradient descent.

## Real autodiff PPO (feature `train`, optional `cuda`)
- candle_ppo.rs: CandleActorCritic (64→128→64 MLP + action/value heads +
  learnable log_std), CandlePpoConfig, CandleTrainer with GAE and a genuine
  optimizer.backward_step over the network. select_device() picks CUDA when
  built --features cuda and a GPU is present, else CPU.
- Verified: 5-episode CPU smoke run shows value_loss 12643→12375 (critic
  actually learning); safetensors checkpoint saved. Placeholder never moved weights.

## A-MAPPO heterogeneous-role attention (role_attention.rs, always compiled)
Addresses the four sensor-vs-relay edge cases:
- relay attention floor (prevents collapse — relays produce no CSI)
- role-segmented sensor/relay attention pools (variable neighbor cardinality)
- sensor-gated triangulation-geometry penalty (protects 3-view fusion baseline,
  ADR-148 §4.2 — relays not dragged into triangulation geometry)
- one-hot role embeddings for keys

## Training binary
- src/bin/train_marl.rs (required-features=["train"], excluded from default build)
- CLI: --episodes --drones --profile --steps --checkpoint-dir --checkpoint-every
- Wires CandleTrainer to the SwarmOrchestrator rollout loop; GAE + PPO update
  per episode; periodic safetensors checkpoints

## Right-sized launch (scripts/gcp/)
- provision_marl.sh: g2-standard-16 (1× L4, 16 vCPU, ~$1.40/hr) — NOT the
  $29/hr A100×8 box. MARL is rollout-bound not matmul-bound; ~21× cheaper.
- run_marl_train.sh: GCP rsync + train + checkpoint pull
- run_marl_train_local.sh: local RTX 5080, $0
- A100×8 provision_training.sh left for OccWorld (which saturates the GPUs)

## Tests
- --no-default-features: 91/91 (87 + 4 role_attention)
- --features train: 96/96 (+ 5 candle_ppo, incl. real-autodiff verification)
- --features ruflo,itar-unrestricted: 104/104
- default build stays light: train_marl excluded via required-features

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-148): mark M4 complete — real GPU autodiff training; overall 98%

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): training visualizer — JSONL telemetry + self-contained HTML viewer

Adds an offline, dependency-free visualization for the drone training system:
a top-down swarm replay synced with training-metric curves, fed by a JSONL
telemetry log the trainer emits. No server, no build step, no CDN.

## Telemetry recorder (integration/telemetry.rs, always compiled, no new deps)
- TelemetryRecorder writes newline-delimited JSON: one `meta` (profile, area,
  ground-truth victims), many `step` (per-tick drone x/y/heading/battery/detection
  + coverage%), and per-episode `episode` (mean_return, policy_loss, value_loss).
- Written by hand (no serde_json) so it stays in the default build; 2 tests.

## train_marl telemetry flags
- `--telemetry FILE` writes the log; `--telemetry-episode N` selects which
  episode's spatial steps to record (metrics recorded for all episodes).

## Visualizer (viz/swarm_viz.html — single file, vanilla JS + canvas)
- LEFT: top-down replay — heading-oriented drone triangles (cyan/lime on
  detection), victim markers, growing coverage heatmap, detection pulse rings,
  play/pause/scrub/speed controls + live coverage/detection readout.
- RIGHT: three autoscaled line charts (mean return, policy loss, value loss)
  over episodes, hand-drawn (no chart library).
- Loads via file picker/drag-drop or auto-fetches the bundled sample; dark
  drone-ops theme; graceful degradation on file:// CORS.
- viz/sample_telemetry.jsonl: real 30-episode / 4-drone / 400×400 m run
  (value_loss 20052→7154 — visible critic learning). Parses 1 meta / 60 step / 30 episode.

## Usage
  cargo run --release -p ruview-swarm --features train,cuda --bin train_marl -- \
      --episodes 5000 --telemetry run.jsonl
  open v2/crates/ruview-swarm/viz/swarm_viz.html  # load run.jsonl

Tests unchanged (91 default / 96 train / 104 ruflo+itar); telemetry adds 2.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): selectable flight + self-learning patterns, wired into training + viz

Adds multiple flight/coverage-optimization strategies and self-learning
strategies, selectable from the trainer, and fixes drone clustering — the
demo sweep now covers 36% of the area (was ~0.9%) with 4 disjoint strips.

## Flight patterns (planning/patterns.rs) — `FlightPattern`
- PartitionedLawnmower (new default): area split into per-drone strips → no
  overlap, coverage scales ~linearly with swarm size (clustering fix)
- Boustrophedon (baseline), Spiral, Pheromone (stigmergic), PotentialField,
  LevyFlight. from_str/name/all + next_target(&PatternContext).

## Self-learning patterns (marl/learning.rs) — `LearningPattern`
- Mappo (CTDE centralized critic), Ippo (independent, jamming-robust),
  MappoCuriosity (count-based intrinsic novelty), MetaRl (MAML fast-adapt).
- CuriosityModule (visit_bonus = beta/sqrt(count), novelty decays on revisit),
  MetaAdapter (base + fast-weights, reset_fast/consolidate), shaped_reward().

## Trainer wiring (bin/train_marl.rs)
- --flight-pattern {boustrophedon|partitioned|spiral|pheromone|potential|levy}
- --learn-pattern  {mappo|ippo|curiosity|meta}
- Rollout now moves each drone per the selected FlightPattern (PatternContext
  with visited trail + live peers), curiosity-shapes the reward, and logs
  CTDE vs independent. Telemetry meta profile carries the pattern labels so the
  viewer header shows `flight=… · learn=…`.

## Verification
- Browser pass (viz at localhost:8777): partitioned run renders 4 distinct
  serpentine coverage bands, header shows the patterns, final coverage 36.3%,
  scrubber/speed/playback work, ZERO console errors. Screenshot confirmed.
- Regenerated viz/sample_telemetry.jsonl: 1 meta / 120 step / 30 episode,
  coverage 0.9% → 36.3%.

## Tests
- --no-default-features: 103/103 (was 91; +6 patterns +6 learning)
- --features train: 108/108

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): add flight-pattern telemetry presets for the visualizer

5 loadable presets (verified browser-distinct, physics-ordered coverage):
pheromone ~44% > potential ~40% > partitioned 36% > spiral ~13% > levy ~5%.
Load any in viz/swarm_viz.html to compare flight strategies without retraining.

Co-Authored-By: claude-flow <ruv@ruv.net>

* chore(swarm): clippy-clean + publish guard for ruview-swarm

- ruview-swarm src is now 0 clippy warnings across default/train/full feature
  sets (derive Default, targeted allows for intentional from_str + bounded
  casts + borrow-required index loops; removed redundant unsigned .max(0))
- publish = false until PR merges, internal path-deps publish in order, and
  ITAR (USML VIII(h)(12)) export sign-off — prevents accidental public publish

Tests unchanged: 103 default / 108 train / 116 ruflo+itar / 120 full+train.
(6 remaining clippy warnings are pre-existing in dependency wifi-densepose-core,
 out of scope for this crate.)

Co-Authored-By: claude-flow <ruv@ruv.net>

* ci(swarm): add ruview-swarm CI guard

Path-scoped guard for v2/crates/ruview-swarm/** (ADR-148). Complements the
main ci.yml (which only runs the default workspace tests):
- feature-matrix tests: default / train / ruflo+itar / full+train
- clippy -D warnings --no-deps (crate-own code only; dep warnings don't gate)
- train_marl bin builds under 'train' AND is excluded from the default build
- ITAR/publish guards: publish=false present, itar-unrestricted never in default

All steps verified locally green before commit.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-30 16:00:59 -04:00

302 lines
11 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.
//! Selectable self-learning strategies for swarm MARL.
//!
//! - Mappo: centralized-critic, decentralized-execution (CTDE). Best cooperative
//! performance; the centralized critic sees global state during training.
//! - Ippo: independent PPO — each agent learns alone, no shared critic. Robust to
//! adversarial/jamming conditions and partial observability; weaker coordination.
//! - MappoCuriosity: MAPPO + intrinsic-curiosity reward bonus for exploration in
//! sparse-reward regimes (count-based novelty over visited regions).
//! - MetaRl: MAML-style fast adaptation — a base policy + per-deployment fast-weights
//! that adapt in a few in-flight steps to wind/sensor drift.
//!
//! Pure Rust — always compiled (no Candle needed). This is the *strategy* layer;
//! the gradient backend lives in `candle_ppo.rs` behind the `train` feature.
/// Which self-learning strategy the swarm trains under. Selectable at runtime.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LearningPattern {
/// Centralized critic, decentralized execution (CTDE).
#[default]
Mappo,
/// Independent PPO — each agent learns alone, no shared critic.
Ippo,
/// MAPPO plus count-based intrinsic-curiosity reward bonus.
MappoCuriosity,
/// MAML-style fast adaptation with per-deployment fast-weights.
MetaRl,
}
impl LearningPattern {
/// Parse from a short identifier. Unknown strings fall back to the default
/// (Mappo). Accepts both canonical names and friendly aliases.
// Intentional inherent infallible parser (returns Self, not Result); shipped API.
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"mappo" => LearningPattern::Mappo,
"ippo" => LearningPattern::Ippo,
"curiosity" | "mappocuriosity" | "mappo_curiosity" => {
LearningPattern::MappoCuriosity
}
"meta" | "metarl" | "meta_rl" => LearningPattern::MetaRl,
_ => LearningPattern::default(),
}
}
/// Canonical short name. `from_str(p.name()) == p` for every variant.
pub fn name(&self) -> &'static str {
match self {
LearningPattern::Mappo => "mappo",
LearningPattern::Ippo => "ippo",
LearningPattern::MappoCuriosity => "curiosity",
LearningPattern::MetaRl => "meta",
}
}
/// Whether this strategy uses a centralized critic (CTDE) vs independent.
pub fn centralized_critic(&self) -> bool {
matches!(
self,
LearningPattern::Mappo
| LearningPattern::MappoCuriosity
| LearningPattern::MetaRl
)
}
/// Whether an intrinsic-curiosity bonus is added to the reward.
pub fn uses_curiosity(&self) -> bool {
matches!(self, LearningPattern::MappoCuriosity)
}
}
// ---------------------------------------------------------------------------
// Curiosity: count-based intrinsic motivation
// ---------------------------------------------------------------------------
/// Count-based intrinsic-motivation module.
///
/// Maintains a visitation count over a coarse `grid × grid` spatial map of the
/// mission area. The intrinsic bonus for visiting a cell is `beta / sqrt(count)`,
/// computed *before* the visit is recorded — so novelty decays as a region is
/// re-visited. This rewards exploration in sparse-reward regimes.
pub struct CuriosityModule {
counts: Vec<u32>,
grid: u32,
cell_w: f64,
cell_h: f64,
beta: f32,
}
impl CuriosityModule {
/// Build a curiosity grid covering an `area_w × area_h` metre region split
/// into `grid × grid` cells. `beta` scales the intrinsic bonus magnitude.
pub fn new(area_w: f64, area_h: f64, grid: u32, beta: f32) -> Self {
let g = grid.max(1);
let cells = (g as usize) * (g as usize);
let cell_w = if area_w > 0.0 { area_w / g as f64 } else { 1.0 };
let cell_h = if area_h > 0.0 { area_h / g as f64 } else { 1.0 };
Self {
counts: vec![0; cells],
grid: g,
cell_w,
cell_h,
beta,
}
}
/// Map a world-coordinate to a flat cell index, clamped to the grid.
fn cell_index(&self, x: f64, y: f64) -> usize {
let gx = ((x / self.cell_w).floor() as i64).clamp(0, self.grid as i64 - 1) as usize;
let gy = ((y / self.cell_h).floor() as i64).clamp(0, self.grid as i64 - 1) as usize;
gy * self.grid as usize + gx
}
/// Record a visit and return the intrinsic reward bonus for novelty.
///
/// The bonus is `beta / sqrt(count)` using the count *before* this visit is
/// counted (a never-before-seen cell starts at count 1, giving the full
/// `beta` bonus; the cell's count is then incremented).
pub fn visit_bonus(&mut self, x: f64, y: f64) -> f32 {
let idx = self.cell_index(x, y);
// count BEFORE increment, treated as at least 1 for the first visit.
let prior = self.counts[idx] + 1;
let bonus = self.beta / (prior as f32).sqrt();
self.counts[idx] = self.counts[idx].saturating_add(1);
bonus
}
/// Total recorded visits across the whole grid.
pub fn total_visits(&self) -> u64 {
self.counts.iter().map(|&c| c as u64).sum()
}
}
// ---------------------------------------------------------------------------
// Meta-RL: MAML-style fast-weight adapter
// ---------------------------------------------------------------------------
/// MAML-style fast-weight adapter for few-shot in-flight adaptation.
///
/// Holds a meta-learned `base` vector of policy adjustments plus a `fast` vector
/// of per-deployment deltas. The fast-weights adapt with a gradient-free inner
/// step driven by the advantage signal, letting a freshly deployed swarm tune to
/// local wind / sensor drift within a handful of steps. `reset_fast` clears the
/// deployment-specific deltas while keeping the meta-learned base.
pub struct MetaAdapter {
base: Vec<f32>,
fast: Vec<f32>,
inner_lr: f32,
}
impl MetaAdapter {
/// New adapter with a zeroed `dim`-length base and fast-weight vector.
pub fn new(dim: usize, inner_lr: f32) -> Self {
Self {
base: vec![0.0; dim],
fast: vec![0.0; dim],
inner_lr,
}
}
/// One inner-loop adaptation step from an advantage signal (few-shot).
///
/// Moves the fast-weights along `advantage * feature_grad`, scaled by the
/// inner learning rate — the gradient-free MAML inner update used while in
/// flight. `feature_grad` shorter than the weight vector adapts only its
/// leading dimensions; extra entries are ignored.
pub fn adapt(&mut self, advantage: f32, feature_grad: &[f32]) {
let n = self.fast.len().min(feature_grad.len());
for (f, &g) in self.fast.iter_mut().zip(feature_grad.iter()).take(n) {
*f += self.inner_lr * advantage * g;
}
}
/// Current effective weights (base + fast).
pub fn effective(&self) -> Vec<f32> {
self.base
.iter()
.zip(self.fast.iter())
.map(|(b, f)| b + f)
.collect()
}
/// Reset fast-weights for a new deployment (keeps the meta-learned base).
pub fn reset_fast(&mut self) {
for f in self.fast.iter_mut() {
*f = 0.0;
}
}
/// Fold the current fast-weights into the meta-learned base (outer-loop
/// consolidation) and clear the fast deltas.
pub fn consolidate(&mut self) {
for (b, f) in self.base.iter_mut().zip(self.fast.iter()) {
*b += *f;
}
self.reset_fast();
}
}
// ---------------------------------------------------------------------------
// Reward shaping helper
// ---------------------------------------------------------------------------
/// Shape a base reward according to the selected learning pattern.
///
/// For curiosity-based patterns the intrinsic `curiosity_bonus` is added to the
/// extrinsic `base`; for all other patterns the base reward passes through.
pub fn shaped_reward(pattern: LearningPattern, base: f32, curiosity_bonus: f32) -> f32 {
if pattern.uses_curiosity() {
base + curiosity_bonus
} else {
base
}
}
#[cfg(test)]
mod tests {
use super::*;
const ALL: [LearningPattern; 4] = [
LearningPattern::Mappo,
LearningPattern::Ippo,
LearningPattern::MappoCuriosity,
LearningPattern::MetaRl,
];
#[test]
fn test_pattern_from_str_roundtrip() {
for p in ALL {
assert_eq!(
LearningPattern::from_str(p.name()),
p,
"round-trip failed for {}",
p.name()
);
}
}
#[test]
fn test_centralized_vs_independent() {
// Mappo IS centralized (CTDE); Ippo is NOT (independent learners).
assert!(LearningPattern::Mappo.centralized_critic());
assert!(!LearningPattern::Ippo.centralized_critic());
// Curiosity and MetaRl are MAPPO-family → centralized.
assert!(LearningPattern::MappoCuriosity.centralized_critic());
assert!(LearningPattern::MetaRl.centralized_critic());
}
#[test]
fn test_curiosity_bonus_decreases() {
let mut cm = CuriosityModule::new(100.0, 100.0, 10, 1.0);
let first = cm.visit_bonus(50.0, 50.0);
let second = cm.visit_bonus(50.0, 50.0); // same cell again
assert!(
second < first,
"novelty should decay: first={first}, second={second}"
);
}
#[test]
fn test_curiosity_bonus_in_bounds() {
let mut cm = CuriosityModule::new(100.0, 100.0, 8, 0.5);
// In-bounds, out-of-bounds, and negative coords all clamp safely.
for &(x, y) in &[(0.0, 0.0), (50.0, 50.0), (999.0, -999.0), (-5.0, 1000.0)] {
let b = cm.visit_bonus(x, y);
assert!(b.is_finite(), "bonus must be finite, got {b}");
assert!(b >= 0.0, "bonus must be >= 0, got {b}");
}
}
#[test]
fn test_meta_adapter_changes_weights() {
let mut ma = MetaAdapter::new(4, 0.1);
let base = ma.effective();
ma.adapt(2.0, &[1.0, -1.0, 0.5, 0.0]);
let adapted = ma.effective();
assert_ne!(base, adapted, "adapt() must change effective weights");
ma.reset_fast();
assert_eq!(
base,
ma.effective(),
"reset_fast() must restore the meta-learned base"
);
}
#[test]
fn test_shaped_reward_curiosity_only() {
let base = 10.0;
let bonus = 3.0;
// MappoCuriosity adds the bonus.
assert_eq!(
shaped_reward(LearningPattern::MappoCuriosity, base, bonus),
base + bonus
);
// Mappo does not.
assert_eq!(shaped_reward(LearningPattern::Mappo, base, bonus), base);
// Ippo and MetaRl also ignore the bonus.
assert_eq!(shaped_reward(LearningPattern::Ippo, base, bonus), base);
assert_eq!(shaped_reward(LearningPattern::MetaRl, base, bonus), base);
}
}