fix(adr-185): hoist AETHER compute surface into a std-only leaf crate

ADR-185 §13 flagged the `[aether]` wheel as linking the whole
`wifi-densepose-sensing-server` Axum/tokio/worldgraph/ruvector tree via the
non-optional deps of that crate. Hoist the pure-compute AETHER stack out so the
binding depends only on pure Rust.

New crate `wifi-densepose-aether` (std-only, ZERO external deps) holds the four
self-contained modules the AETHER surface needs — `embedding`, `graph_transformer`,
`sona`, `sparse_inference` (verified: no serde/tokio/axum/crate:: refs outside the
set; the four form a closed dependency graph). `wifi-densepose-sensing-server`
now `pub use`s them from the leaf crate, so its own code (`crate::embedding`, …)
and public API (`wifi_densepose_sensing_server::embedding`, …) are unchanged —
`main.rs`/`trainer.rs` untouched. The Python binding + parity test + `[aether]`
feature repoint at the leaf crate; the sensing-server dep is dropped from python.

MEASURED wheel size (maturin build --release --features aether --strip; each
wheel install-verified to actually contain `AetherConfig`):
  BEFORE (aether -> sensing-server): 369,782 B (~361 KB)
  AFTER  (aether -> leaf crate):     319,719 B (~312 KB)

HONEST CORRECTION to the §9/§13 premise: the pre-hoist wheel was NOT over the
ADR-117 §5.4 5 MB budget — it was ~361 KB, ~14x under. Linker dead-code
elimination (--gc-sections on the pyo3 cdylib) already strips the unreached
server tree because the binding only reaches pure-compute symbols. So this is
"blows the budget" CLAIMED, not MEASURED. The hoist is still worthwhile and
lands the real, measured wins:
  - `[aether]` build time 71s -> 12s (no server tree to compile),
  - python/Cargo.lock -1238 lines (server tree no longer resolved),
  - ~50 KB smaller wheel,
  - honest, minimal declared dependency surface + removes the latent risk that a
    future change makes some server code reachable and *then* bloats the wheel.

Verified this session (real counts):
  - cargo test --features aether --test aether_parity: 2 passed / 0 failed
  - pytest tests/test_aether.py (maturin develop --features aether): 9 passed
  - cargo test -p wifi-densepose-sensing-server --no-default-features: 217 bin +
    388 lib, 0 failed (no regression)
  - cargo test -p wifi-densepose-aether: 96 passed (the relocated unit tests)

The §9/§13 wheel-size narrative in ADR-185 (owned by the ADR author) should be
corrected to reflect the measured reality — flagged to the team lead.
This commit is contained in:
ruv
2026-07-21 17:43:32 -07:00
parent f74e73ccf7
commit a47bb71b22
14 changed files with 98 additions and 1222 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
//! AETHER pure-compute stack (ADR-024 / ADR-185 §3.2).
//!
//! This crate is the dependency-free leaf hoisted out of
//! `wifi-densepose-sensing-server` so that the Python `wifi_densepose[aether]`
//! wheel can bind the contrastive-embedding surface without linking the server's
//! Axum / tokio / worldgraph / ruvector tree (which blew the ADR-117 §5.4 ≤5 MB
//! wheel budget).
//!
//! Modules:
//! - [`embedding`] — AETHER contrastive CSI embedding: `EmbeddingConfig`,
//! `EmbeddingExtractor`, `ProjectionHead`, `CsiAugmenter`, `info_nce_loss`,
//! fingerprint indices.
//! - [`graph_transformer`] — CSI-to-pose transformer primitives
//! (`CsiToPoseTransformer`, `TransformerConfig`, `Linear`).
//! - [`sona`] — self-organizing drift detection + LoRA adaptation + EWC.
//! - [`sparse_inference`] — quantization helpers used by the embedding path.
//!
//! `wifi-densepose-sensing-server` re-exports these modules so its own code and
//! public API are unchanged.
// `embedding` carries a couple of not-yet-read fields (e.g. `PoseEncoder.d_proj`);
// this mirrors the `#[allow(dead_code)]` the module had at its previous home in
// `wifi-densepose-sensing-server`.
#[allow(dead_code)]
pub mod embedding;
pub mod graph_transformer;
pub mod sona;
pub mod sparse_inference;
+838
View File
@@ -0,0 +1,838 @@
//! SONA online adaptation: LoRA + EWC++ for WiFi-DensePose (ADR-023 Phase 5).
//!
//! Enables rapid low-parameter adaptation to changing WiFi environments without
//! catastrophic forgetting. All arithmetic uses `f32`, no external dependencies.
use std::collections::VecDeque;
// ── LoRA Adapter ────────────────────────────────────────────────────────────
/// Low-Rank Adaptation layer storing factorised delta `scale * A * B`.
#[derive(Debug, Clone)]
pub struct LoraAdapter {
pub a: Vec<Vec<f32>>, // (in_features, rank)
pub b: Vec<Vec<f32>>, // (rank, out_features)
pub scale: f32, // alpha / rank
pub in_features: usize,
pub out_features: usize,
pub rank: usize,
}
impl LoraAdapter {
pub fn new(in_features: usize, out_features: usize, rank: usize, alpha: f32) -> Self {
Self {
a: vec![vec![0.0f32; rank]; in_features],
b: vec![vec![0.0f32; out_features]; rank],
scale: alpha / rank.max(1) as f32,
in_features,
out_features,
rank,
}
}
/// Compute `scale * input * A * B`, returning a vector of length `out_features`.
#[allow(clippy::needless_range_loop)]
pub fn forward(&self, input: &[f32]) -> Vec<f32> {
assert_eq!(input.len(), self.in_features);
let mut hidden = vec![0.0f32; self.rank];
for (i, &x) in input.iter().enumerate() {
for r in 0..self.rank {
hidden[r] += x * self.a[i][r];
}
}
let mut output = vec![0.0f32; self.out_features];
for r in 0..self.rank {
for j in 0..self.out_features {
output[j] += hidden[r] * self.b[r][j];
}
}
for v in output.iter_mut() {
*v *= self.scale;
}
output
}
/// Full delta weight matrix `scale * A * B`, shape (in_features, out_features).
#[allow(clippy::needless_range_loop)]
pub fn delta_weights(&self) -> Vec<Vec<f32>> {
let mut delta = vec![vec![0.0f32; self.out_features]; self.in_features];
for i in 0..self.in_features {
for r in 0..self.rank {
let a_val = self.a[i][r];
for j in 0..self.out_features {
delta[i][j] += a_val * self.b[r][j];
}
}
}
for row in delta.iter_mut() {
for v in row.iter_mut() {
*v *= self.scale;
}
}
delta
}
/// Add LoRA delta to base weights in place.
pub fn merge_into(&self, base_weights: &mut [Vec<f32>]) {
let delta = self.delta_weights();
for (rb, rd) in base_weights.iter_mut().zip(delta.iter()) {
for (w, &d) in rb.iter_mut().zip(rd.iter()) {
*w += d;
}
}
}
/// Subtract LoRA delta from base weights in place.
pub fn unmerge_from(&self, base_weights: &mut [Vec<f32>]) {
let delta = self.delta_weights();
for (rb, rd) in base_weights.iter_mut().zip(delta.iter()) {
for (w, &d) in rb.iter_mut().zip(rd.iter()) {
*w -= d;
}
}
}
/// Trainable parameter count: `rank * (in_features + out_features)`.
pub fn n_params(&self) -> usize {
self.rank * (self.in_features + self.out_features)
}
/// Reset A and B to zero.
pub fn reset(&mut self) {
for row in self.a.iter_mut() {
for v in row.iter_mut() {
*v = 0.0;
}
}
for row in self.b.iter_mut() {
for v in row.iter_mut() {
*v = 0.0;
}
}
}
}
// ── EWC++ Regularizer ───────────────────────────────────────────────────────
/// Elastic Weight Consolidation++ regularizer with running Fisher average.
#[derive(Debug, Clone)]
pub struct EwcRegularizer {
pub lambda: f32,
pub decay: f32,
pub fisher_diag: Vec<f32>,
pub reference_params: Vec<f32>,
}
impl EwcRegularizer {
pub fn new(lambda: f32, decay: f32) -> Self {
Self {
lambda,
decay,
fisher_diag: Vec::new(),
reference_params: Vec::new(),
}
}
/// Diagonal Fisher via numerical central differences: F_i = grad_i^2.
pub fn compute_fisher(
params: &[f32],
loss_fn: impl Fn(&[f32]) -> f32,
n_samples: usize,
) -> Vec<f32> {
let eps = 1e-4f32;
let n = params.len();
let mut fisher = vec![0.0f32; n];
let samples = n_samples.max(1);
for _ in 0..samples {
let mut p = params.to_vec();
for i in 0..n {
let orig = p[i];
p[i] = orig + eps;
let lp = loss_fn(&p);
p[i] = orig - eps;
let lm = loss_fn(&p);
p[i] = orig;
let g = (lp - lm) / (2.0 * eps);
fisher[i] += g * g;
}
}
for f in fisher.iter_mut() {
*f /= samples as f32;
}
fisher
}
/// Online update: `F = decay * F_old + (1-decay) * F_new`.
pub fn update_fisher(&mut self, new_fisher: &[f32]) {
if self.fisher_diag.is_empty() {
self.fisher_diag = new_fisher.to_vec();
return;
}
assert_eq!(self.fisher_diag.len(), new_fisher.len());
for (old, &nv) in self.fisher_diag.iter_mut().zip(new_fisher.iter()) {
*old = self.decay * *old + (1.0 - self.decay) * nv;
}
}
/// Penalty: `0.5 * lambda * sum(F_i * (theta_i - theta_i*)^2)`.
pub fn penalty(&self, current_params: &[f32]) -> f32 {
if self.reference_params.is_empty() || self.fisher_diag.is_empty() {
return 0.0;
}
let n = current_params
.len()
.min(self.reference_params.len())
.min(self.fisher_diag.len());
let mut sum = 0.0f32;
#[allow(clippy::needless_range_loop)]
for i in 0..n {
let d = current_params[i] - self.reference_params[i];
sum += self.fisher_diag[i] * d * d;
}
0.5 * self.lambda * sum
}
/// Gradient of penalty: `lambda * F_i * (theta_i - theta_i*)`.
pub fn penalty_gradient(&self, current_params: &[f32]) -> Vec<f32> {
if self.reference_params.is_empty() || self.fisher_diag.is_empty() {
return vec![0.0f32; current_params.len()];
}
let n = current_params
.len()
.min(self.reference_params.len())
.min(self.fisher_diag.len());
let mut grad = vec![0.0f32; current_params.len()];
for i in 0..n {
grad[i] =
self.lambda * self.fisher_diag[i] * (current_params[i] - self.reference_params[i]);
}
grad
}
/// Save current params as the new reference point.
pub fn consolidate(&mut self, params: &[f32]) {
self.reference_params = params.to_vec();
}
}
// ── Configuration & Types ───────────────────────────────────────────────────
/// SONA adaptation configuration.
#[derive(Debug, Clone)]
pub struct SonaConfig {
pub lora_rank: usize,
pub lora_alpha: f32,
pub ewc_lambda: f32,
pub ewc_decay: f32,
pub adaptation_lr: f32,
pub max_steps: usize,
pub convergence_threshold: f32,
pub temporal_consistency_weight: f32,
}
impl Default for SonaConfig {
fn default() -> Self {
Self {
lora_rank: 4,
lora_alpha: 8.0,
ewc_lambda: 5000.0,
ewc_decay: 0.99,
adaptation_lr: 0.001,
max_steps: 50,
convergence_threshold: 1e-4,
temporal_consistency_weight: 0.1,
}
}
}
/// Single training sample for online adaptation.
#[derive(Debug, Clone)]
pub struct AdaptationSample {
pub csi_features: Vec<f32>,
pub target: Vec<f32>,
}
/// Result of a SONA adaptation run.
#[derive(Debug, Clone)]
pub struct AdaptationResult {
pub adapted_params: Vec<f32>,
pub steps_taken: usize,
pub final_loss: f32,
pub converged: bool,
pub ewc_penalty: f32,
}
/// Saved environment-specific adaptation profile.
#[derive(Debug, Clone)]
pub struct SonaProfile {
pub name: String,
pub lora_a: Vec<Vec<f32>>,
pub lora_b: Vec<Vec<f32>>,
pub fisher_diag: Vec<f32>,
pub reference_params: Vec<f32>,
pub adaptation_count: usize,
}
// ── SONA Adapter ────────────────────────────────────────────────────────────
/// Full SONA system: LoRA adapter + EWC++ regularizer for online adaptation.
#[derive(Debug, Clone)]
pub struct SonaAdapter {
pub config: SonaConfig,
pub lora: LoraAdapter,
pub ewc: EwcRegularizer,
pub param_count: usize,
pub adaptation_count: usize,
}
impl SonaAdapter {
pub fn new(config: SonaConfig, param_count: usize) -> Self {
let lora = LoraAdapter::new(param_count, 1, config.lora_rank, config.lora_alpha);
let ewc = EwcRegularizer::new(config.ewc_lambda, config.ewc_decay);
Self {
config,
lora,
ewc,
param_count,
adaptation_count: 0,
}
}
/// Run gradient descent with LoRA + EWC on the given samples.
pub fn adapt(&mut self, base_params: &[f32], samples: &[AdaptationSample]) -> AdaptationResult {
assert_eq!(base_params.len(), self.param_count);
if samples.is_empty() {
return AdaptationResult {
adapted_params: base_params.to_vec(),
steps_taken: 0,
final_loss: 0.0,
converged: true,
ewc_penalty: self.ewc.penalty(base_params),
};
}
let lr = self.config.adaptation_lr;
let (mut prev_loss, mut steps, mut converged) = (f32::MAX, 0usize, false);
let out_dim = samples[0].target.len();
let in_dim = samples[0].csi_features.len();
for step in 0..self.config.max_steps {
steps = step + 1;
let df = self.lora_delta_flat();
let eff: Vec<f32> = base_params
.iter()
.zip(df.iter())
.map(|(&b, &d)| b + d)
.collect();
let (dl, dg) = Self::mse_loss_grad(&eff, samples, in_dim, out_dim);
let ep = self.ewc.penalty(&eff);
let eg = self.ewc.penalty_gradient(&eff);
let total = dl + ep;
if (prev_loss - total).abs() < self.config.convergence_threshold {
converged = true;
prev_loss = total;
break;
}
prev_loss = total;
let gl = df.len().min(dg.len()).min(eg.len());
let mut tg = vec![0.0f32; gl];
for i in 0..gl {
tg[i] = dg[i] + eg[i];
}
self.update_lora(&tg, lr);
}
let df = self.lora_delta_flat();
let adapted: Vec<f32> = base_params
.iter()
.zip(df.iter())
.map(|(&b, &d)| b + d)
.collect();
let ewc_penalty = self.ewc.penalty(&adapted);
self.adaptation_count += 1;
AdaptationResult {
adapted_params: adapted,
steps_taken: steps,
final_loss: prev_loss,
converged,
ewc_penalty,
}
}
pub fn save_profile(&self, name: &str) -> SonaProfile {
SonaProfile {
name: name.to_string(),
lora_a: self.lora.a.clone(),
lora_b: self.lora.b.clone(),
fisher_diag: self.ewc.fisher_diag.clone(),
reference_params: self.ewc.reference_params.clone(),
adaptation_count: self.adaptation_count,
}
}
pub fn load_profile(&mut self, profile: &SonaProfile) {
self.lora.a = profile.lora_a.clone();
self.lora.b = profile.lora_b.clone();
self.ewc.fisher_diag = profile.fisher_diag.clone();
self.ewc.reference_params = profile.reference_params.clone();
self.adaptation_count = profile.adaptation_count;
}
fn lora_delta_flat(&self) -> Vec<f32> {
self.lora
.delta_weights()
.into_iter()
.map(|r| r[0])
.collect()
}
fn mse_loss_grad(
params: &[f32],
samples: &[AdaptationSample],
in_dim: usize,
out_dim: usize,
) -> (f32, Vec<f32>) {
let n = samples.len() as f32;
let ws = in_dim * out_dim;
let mut grad = vec![0.0f32; params.len()];
let mut loss = 0.0f32;
for s in samples {
let (inp, tgt) = (&s.csi_features, &s.target);
let mut pred = vec![0.0f32; out_dim];
#[allow(clippy::needless_range_loop)]
for j in 0..out_dim {
for i in 0..in_dim.min(inp.len()) {
let idx = j * in_dim + i;
if idx < ws && idx < params.len() {
pred[j] += params[idx] * inp[i];
}
}
}
for j in 0..out_dim.min(tgt.len()) {
let e = pred[j] - tgt[j];
loss += e * e;
#[allow(clippy::needless_range_loop)]
for i in 0..in_dim.min(inp.len()) {
let idx = j * in_dim + i;
if idx < ws && idx < grad.len() {
grad[idx] += 2.0 * e * inp[i] / n;
}
}
}
}
(loss / n, grad)
}
#[allow(clippy::needless_range_loop)]
fn update_lora(&mut self, grad: &[f32], lr: f32) {
let (scale, rank) = (self.lora.scale, self.lora.rank);
if self.lora.b.iter().all(|r| r.iter().all(|&v| v == 0.0)) && rank > 0 {
self.lora.b[0][0] = 1.0;
}
for i in 0..self.lora.in_features.min(grad.len()) {
for r in 0..rank {
self.lora.a[i][r] -= lr * grad[i] * scale * self.lora.b[r][0];
}
}
for r in 0..rank {
let mut g = 0.0f32;
for i in 0..self.lora.in_features.min(grad.len()) {
g += grad[i] * scale * self.lora.a[i][r];
}
self.lora.b[r][0] -= lr * g;
}
}
}
// ── Environment Detector ────────────────────────────────────────────────────
/// CSI baseline drift information.
#[derive(Debug, Clone)]
pub struct DriftInfo {
pub magnitude: f32,
pub duration_frames: usize,
pub baseline_mean: f32,
pub current_mean: f32,
}
/// Detects environmental drift in CSI statistics (>3 sigma from baseline).
#[derive(Debug, Clone)]
pub struct EnvironmentDetector {
window_size: usize,
means: VecDeque<f32>,
variances: VecDeque<f32>,
baseline_mean: f32,
baseline_var: f32,
baseline_std: f32,
baseline_set: bool,
drift_frames: usize,
}
impl EnvironmentDetector {
pub fn new(window_size: usize) -> Self {
Self {
window_size: window_size.max(2),
means: VecDeque::with_capacity(window_size),
variances: VecDeque::with_capacity(window_size),
baseline_mean: 0.0,
baseline_var: 0.0,
baseline_std: 0.0,
baseline_set: false,
drift_frames: 0,
}
}
pub fn update(&mut self, csi_mean: f32, csi_var: f32) {
self.means.push_back(csi_mean);
self.variances.push_back(csi_var);
while self.means.len() > self.window_size {
self.means.pop_front();
}
while self.variances.len() > self.window_size {
self.variances.pop_front();
}
if !self.baseline_set && self.means.len() >= self.window_size {
self.reset_baseline();
}
if self.drift_detected() {
self.drift_frames += 1;
} else {
self.drift_frames = 0;
}
}
pub fn drift_detected(&self) -> bool {
if !self.baseline_set || self.means.is_empty() {
return false;
}
let dev = (self.current_mean() - self.baseline_mean).abs();
let thr = if self.baseline_std > f32::EPSILON {
3.0 * self.baseline_std
} else {
f32::EPSILON * 100.0
};
dev > thr
}
pub fn reset_baseline(&mut self) {
if self.means.is_empty() {
return;
}
let n = self.means.len() as f32;
self.baseline_mean = self.means.iter().sum::<f32>() / n;
let var = self
.means
.iter()
.map(|&m| (m - self.baseline_mean).powi(2))
.sum::<f32>()
/ n;
self.baseline_var = var;
self.baseline_std = var.sqrt();
self.baseline_set = true;
self.drift_frames = 0;
}
pub fn drift_info(&self) -> DriftInfo {
let cm = self.current_mean();
let abs_dev = (cm - self.baseline_mean).abs();
let magnitude = if self.baseline_std > f32::EPSILON {
abs_dev / self.baseline_std
} else if abs_dev > f32::EPSILON {
abs_dev / f32::EPSILON
} else {
0.0
};
DriftInfo {
magnitude,
duration_frames: self.drift_frames,
baseline_mean: self.baseline_mean,
current_mean: cm,
}
}
fn current_mean(&self) -> f32 {
if self.means.is_empty() {
0.0
} else {
self.means.iter().sum::<f32>() / self.means.len() as f32
}
}
}
// ── Temporal Consistency Loss ───────────────────────────────────────────────
/// Penalises large velocity between consecutive outputs: `sum((c-p)^2) / dt`.
pub struct TemporalConsistencyLoss;
impl TemporalConsistencyLoss {
pub fn compute(prev_output: &[f32], curr_output: &[f32], dt: f32) -> f32 {
if dt <= 0.0 {
return 0.0;
}
let n = prev_output.len().min(curr_output.len());
let mut sq = 0.0f32;
for i in 0..n {
let d = curr_output[i] - prev_output[i];
sq += d * d;
}
sq / dt
}
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lora_adapter_param_count() {
let lora = LoraAdapter::new(64, 32, 4, 8.0);
assert_eq!(lora.n_params(), 4 * (64 + 32));
}
#[test]
fn lora_adapter_forward_shape() {
let lora = LoraAdapter::new(8, 4, 2, 4.0);
assert_eq!(lora.forward(&[1.0f32; 8]).len(), 4);
}
#[test]
fn lora_adapter_zero_init_produces_zero_delta() {
let delta = LoraAdapter::new(8, 4, 2, 4.0).delta_weights();
assert_eq!(delta.len(), 8);
for row in &delta {
assert_eq!(row.len(), 4);
for &v in row {
assert_eq!(v, 0.0);
}
}
}
#[test]
fn lora_adapter_merge_unmerge_roundtrip() {
let mut lora = LoraAdapter::new(3, 2, 1, 2.0);
lora.a[0][0] = 1.0;
lora.a[1][0] = 2.0;
lora.a[2][0] = 3.0;
lora.b[0][0] = 0.5;
lora.b[0][1] = -0.5;
let mut base = vec![vec![10.0, 20.0], vec![30.0, 40.0], vec![50.0, 60.0]];
let orig = base.clone();
lora.merge_into(&mut base);
assert_ne!(base, orig);
lora.unmerge_from(&mut base);
for (rb, ro) in base.iter().zip(orig.iter()) {
for (&b, &o) in rb.iter().zip(ro.iter()) {
assert!((b - o).abs() < 1e-5, "roundtrip failed: {b} vs {o}");
}
}
}
#[test]
fn lora_adapter_rank_1_outer_product() {
let mut lora = LoraAdapter::new(3, 2, 1, 1.0); // scale=1
lora.a[0][0] = 1.0;
lora.a[1][0] = 2.0;
lora.a[2][0] = 3.0;
lora.b[0][0] = 4.0;
lora.b[0][1] = 5.0;
let d = lora.delta_weights();
let expected = [[4.0, 5.0], [8.0, 10.0], [12.0, 15.0]];
for (i, row) in expected.iter().enumerate() {
for (j, &v) in row.iter().enumerate() {
assert!((d[i][j] - v).abs() < 1e-6);
}
}
}
#[test]
fn lora_scale_factor() {
assert!((LoraAdapter::new(8, 4, 4, 16.0).scale - 4.0).abs() < 1e-6);
assert!((LoraAdapter::new(8, 4, 2, 8.0).scale - 4.0).abs() < 1e-6);
}
#[test]
fn ewc_fisher_positive() {
let fisher = EwcRegularizer::compute_fisher(
&[1.0f32, -2.0, 0.5],
|p: &[f32]| p.iter().map(|&x| x * x).sum::<f32>(),
1,
);
assert_eq!(fisher.len(), 3);
for &f in &fisher {
assert!(f >= 0.0, "Fisher must be >= 0, got {f}");
}
}
#[test]
fn ewc_penalty_zero_at_reference() {
let mut ewc = EwcRegularizer::new(5000.0, 0.99);
let p = vec![1.0, 2.0, 3.0];
ewc.fisher_diag = vec![1.0; 3];
ewc.consolidate(&p);
assert!(ewc.penalty(&p).abs() < 1e-10);
}
#[test]
fn ewc_penalty_positive_away_from_reference() {
let mut ewc = EwcRegularizer::new(5000.0, 0.99);
ewc.fisher_diag = vec![1.0; 3];
ewc.consolidate(&[1.0, 2.0, 3.0]);
let pen = ewc.penalty(&[2.0, 3.0, 4.0]);
assert!(pen > 0.0); // 0.5 * 5000 * 3 = 7500
assert!((pen - 7500.0).abs() < 1e-3, "expected ~7500, got {pen}");
}
#[test]
fn ewc_penalty_gradient_direction() {
let mut ewc = EwcRegularizer::new(100.0, 0.99);
let r = vec![1.0, 2.0, 3.0];
ewc.fisher_diag = vec![1.0; 3];
ewc.consolidate(&r);
let c = vec![2.0, 4.0, 5.0];
let grad = ewc.penalty_gradient(&c);
for (i, &g) in grad.iter().enumerate() {
assert!(g * (c[i] - r[i]) > 0.0, "gradient[{i}] wrong sign");
}
}
#[test]
fn ewc_online_update_decays() {
let mut ewc = EwcRegularizer::new(1.0, 0.5);
ewc.update_fisher(&[10.0, 20.0]);
assert!((ewc.fisher_diag[0] - 10.0).abs() < 1e-6);
ewc.update_fisher(&[0.0, 0.0]);
assert!((ewc.fisher_diag[0] - 5.0).abs() < 1e-6); // 0.5*10 + 0.5*0
assert!((ewc.fisher_diag[1] - 10.0).abs() < 1e-6); // 0.5*20 + 0.5*0
}
#[test]
fn ewc_consolidate_updates_reference() {
let mut ewc = EwcRegularizer::new(1.0, 0.99);
ewc.consolidate(&[1.0, 2.0]);
assert_eq!(ewc.reference_params, vec![1.0, 2.0]);
ewc.consolidate(&[3.0, 4.0]);
assert_eq!(ewc.reference_params, vec![3.0, 4.0]);
}
#[test]
fn sona_config_defaults() {
let c = SonaConfig::default();
assert_eq!(c.lora_rank, 4);
assert!((c.lora_alpha - 8.0).abs() < 1e-6);
assert!((c.ewc_lambda - 5000.0).abs() < 1e-3);
assert!((c.ewc_decay - 0.99).abs() < 1e-6);
assert!((c.adaptation_lr - 0.001).abs() < 1e-6);
assert_eq!(c.max_steps, 50);
assert!((c.convergence_threshold - 1e-4).abs() < 1e-8);
assert!((c.temporal_consistency_weight - 0.1).abs() < 1e-6);
}
#[test]
fn sona_adapter_converges_on_simple_task() {
let cfg = SonaConfig {
lora_rank: 1,
lora_alpha: 1.0,
ewc_lambda: 0.0,
ewc_decay: 0.99,
adaptation_lr: 0.01,
max_steps: 200,
convergence_threshold: 1e-6,
temporal_consistency_weight: 0.0,
};
let mut adapter = SonaAdapter::new(cfg, 1);
let samples: Vec<_> = (1..=5)
.map(|i| {
let x = i as f32;
AdaptationSample {
csi_features: vec![x],
target: vec![2.0 * x],
}
})
.collect();
let r = adapter.adapt(&[0.0f32], &samples);
assert!(
r.final_loss < 1.0,
"loss should decrease, got {}",
r.final_loss
);
assert!(r.steps_taken > 0);
}
#[test]
fn sona_adapter_respects_max_steps() {
let cfg = SonaConfig {
max_steps: 5,
convergence_threshold: 0.0,
..SonaConfig::default()
};
let mut a = SonaAdapter::new(cfg, 4);
let s = vec![AdaptationSample {
csi_features: vec![1.0, 0.0, 0.0, 0.0],
target: vec![1.0],
}];
assert_eq!(a.adapt(&[0.0; 4], &s).steps_taken, 5);
}
#[test]
fn sona_profile_save_load_roundtrip() {
let mut a = SonaAdapter::new(SonaConfig::default(), 8);
a.lora.a[0][0] = 1.5;
a.lora.b[0][0] = -0.3;
a.ewc.fisher_diag = vec![1.0, 2.0, 3.0];
a.ewc.reference_params = vec![0.1, 0.2, 0.3];
a.adaptation_count = 42;
let p = a.save_profile("test-env");
assert_eq!(p.name, "test-env");
assert_eq!(p.adaptation_count, 42);
let mut a2 = SonaAdapter::new(SonaConfig::default(), 8);
a2.load_profile(&p);
assert!((a2.lora.a[0][0] - 1.5).abs() < 1e-6);
assert!((a2.lora.b[0][0] - (-0.3)).abs() < 1e-6);
assert_eq!(a2.ewc.fisher_diag.len(), 3);
assert!((a2.ewc.fisher_diag[2] - 3.0).abs() < 1e-6);
assert_eq!(a2.adaptation_count, 42);
}
#[test]
fn environment_detector_no_drift_initially() {
assert!(!EnvironmentDetector::new(10).drift_detected());
}
#[test]
fn environment_detector_detects_large_shift() {
let mut d = EnvironmentDetector::new(10);
for _ in 0..10 {
d.update(10.0, 0.1);
}
assert!(!d.drift_detected());
for _ in 0..10 {
d.update(50.0, 0.1);
}
assert!(d.drift_detected());
assert!(
d.drift_info().magnitude > 3.0,
"magnitude = {}",
d.drift_info().magnitude
);
}
#[test]
fn environment_detector_reset_baseline() {
let mut d = EnvironmentDetector::new(10);
for _ in 0..10 {
d.update(10.0, 0.1);
}
for _ in 0..10 {
d.update(50.0, 0.1);
}
assert!(d.drift_detected());
d.reset_baseline();
assert!(!d.drift_detected());
}
#[test]
fn temporal_consistency_zero_for_static() {
let o = vec![1.0, 2.0, 3.0];
assert!(TemporalConsistencyLoss::compute(&o, &o, 0.033).abs() < 1e-10);
}
}
File diff suppressed because it is too large Load Diff