Files
ruvnet--RuView/v2/crates/wifi-densepose-train/src/rapid_adapt.rs
T
rUv 1d12e8831a refactor(beyond-sota): ADR-155 M2 — host-verifiable §8 closeout (7 de-magic, 9 boundary tests, native-conv honest-null) (#1059)
* refactor(train): ADR-155 M2 §8 — de-magic train non-tch tuning constants + boundary tests

Lift bare numeric literals used as thresholds / guard epsilons in the
non-tch (host-verifiable) train surface into named, documented consts and
pin each set with a *_consts_unchanged_from_literals test. Values are
bit-identical to the prior inline literals — cleanup, no behaviour change.

De-magicked (const + pin test):
- metrics_core.rs: VISIBILITY_THRESHOLD (0.5), MIN_REFERENCE_EXTENT (1e-6),
  OKS_FALLBACK_SIGMA (0.07)
- ruview_metrics.rs: NUM_KEYPOINTS (17), VISIBILITY_THRESHOLD (0.5),
  PCK_THRESHOLD (0.2), MIN_BBOX_DIAG (1e-3), MIN_DURATION_MINUTES (1e-6)
- subcarrier.rs: SPARSE_BASIS_SIGMA (0.15), SPARSE_BASIS_THRESHOLD (1e-4),
  SPARSE_REGULARIZATION_LAMBDA (0.1), SPARSE_COO_PRUNE_EPS (1e-8),
  SPARSE_SOLVER_TOL (1e-5 f64), SPARSE_SOLVER_MAX_ITERS (500)
- eval.rs: MIN_POSITIVE_MPJPE (1e-10)
- domain.rs: LAYER_NORM_EPS (1e-5)
- virtual_aug.rs: BOX_MULLER_U1_FLOOR (1e-10), MIN_ROOM_SCALE (1e-10)

Boundary / characterization tests (pin CURRENT behaviour):
- visibility_threshold_boundary_is_inclusive (>= 0.5 at the edge)
- degenerate_extent_below_floor_is_unscoreable ((0,0,0.0)/0.0, not perfect)
- tracking_zero_duration_does_not_divide_by_zero
- oks_short_array_is_bounded_at_keypoint_count (16 rows, no panic)
- compute_interp_weights_single_target_is_index_zero (target_sc==1)
- sparse_interp_single_target_is_finite
- domain_gap_infinite_when_in_domain_perfect_but_cross_nonzero
- domain_gap_unity_when_everything_perfect
- augment_frame_zero_room_scale_passes_amplitude_finite

Doc-only (no behaviour change):
- rapid_adapt.rs: correct module-doc O(eps) -> O(eps^2) for central differences
- geometry.rs: add # Panics to DeepSets::encode (documents existing assert!)

train --no-default-features: 191 lib (was 176), 303 total (was 288), 0 failed.

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

* feat(nn): ADR-155 M2 §3 — pure-Rust LinearHead::try_new input guard + de-magic softplus threshold

ADR-155 §3 found rf_encoder.rs has no adversarial checkpoint-deserialization
assert — its assert_eq!s in LinearHead::new are construction-time API contracts
on programmer-supplied vectors. This adds the honest, in-scope improvement the
M2 task allows: a pure-Rust *fallible* constructor so weights from an untrusted /
deserialized checkpoint can be shape-validated without panicking.

- Add RfHeadError (WeightShape / BiasShape / VarWeightShape) + Display + Error.
- Add LinearHead::try_new returning Result<Self, RfHeadError>; on success the
  head is byte-identical to LinearHead::new. new() is unchanged (still asserts;
  now documents # Panics and points to try_new) — no behaviour change for
  existing callers.
- De-magic softplus's bare 20.0 overflow threshold into
  SOFTPLUS_LINEAR_THRESHOLD (value unchanged) + pin test.

Tests: try_new_accepts_valid_and_rejects_each_bad_shape (valid == new forward;
each bad shape → typed error), softplus_threshold_unchanged_from_literal.

nn --no-default-features lib: 37 passed (was 35), 0 failed.

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

* perf(nn): ADR-155 M2 §4 — native-conv bench-first → MEASURED-INCONCLUSIVE (no perf change shipped)

The §8 "native-conv naive-loop rewrite" backlog item: DensePoseHead::
apply_conv_layer is a pure-Rust 6-nested-loop conv (benchable on this host, not
tch/ort-gated). Bench-first per the §0 PROOF discipline.

- Add committed criterion bench benches/native_conv_bench.rs measuring forward()
  through the naive conv on representative single-layer configs (--no-default-
  features; no ort download).
- Prototyped a bit-identical range-clamped variant (hoist the per-tap in-bounds
  branch by pre-clamping kh/kw ranges; same ic→kh→kw MAC order ⇒ bit-identical).
  MEASURED before/after on this host: ~35% faster on padding-heavy small-channel
  maps (4.40→2.84 ms) but a ~3% *regression* on channel-heavy maps (11.09→11.48
  ms), all inside a ±20% run-to-run noise floor. Verdict: INCONCLUSIVE — the
  benefit is not robustly positive, so the rewrite is NOT shipped and NOT a
  fabricated speedup. Reverted to the naive loop; honestly deferred (ADR-155 §8).
- Add native_conv_matches_reference: a hand-computed characterization anchor
  (1×1 = scalar MAC; same-padded 3×3 ones = truncated-window sums 9/6/4) pinning
  CURRENT conv behaviour for any future rewrite.

nn --no-default-features lib: 38 passed (was 37), 0 failed. No behaviour change.

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

* docs(adr-155): M2 §8.2 — enumerated host-verifiable P3 backlog clearance + CHANGELOG

Replace the §8 bulk "~40 lower-severity findings" line with the real, enumerated
M2 resolution (§8.2): 7 de-magicked (const + pin == prior literal), 9 boundary
tests, 1 input guard (rf_encoder try_new), 2 doc-only, 1 perf bench-first
MEASURED-INCONCLUSIVE (not shipped). Mark native-conv + rf_encoder RESOLVED;
state which §8 items stay data-gated (GraphPose-Fi/INT4/CSI-JEPA) or tch-gated
(proof/trainer/model panic sites, metrics *_v2 dead code) and ONNX read-lock
upstream-gated — blocked, not dropped. Declare the non-tch-verifiable subset of
§8 cleared.

Validation: train --no-default-features 303 passed (was 288); nn lib 38 (was 35);
workspace --no-default-features 3,293 passed, 0 failed; Python proof VERDICT PASS,
hash f8e76f21…46f7a UNCHANGED bit-exact.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 00:07:56 -04:00

581 lines
19 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.
//! Few-shot rapid adaptation (MERIDIAN Phase 5).
//!
//! Test-time training with contrastive learning and entropy minimization on
//! unlabeled CSI frames. Produces LoRA weight deltas for new environments.
//!
//! # Honesty note (ADR-155 §Tier-1.3)
//!
//! Earlier this module's `contrastive_step` / `entropy_step` wrote a *fake*
//! gradient (`grad += v * 0.01`) that did **not** descend the stated triplet /
//! entropy objective — so any "TTA improves the metric" claim was unsupported
//! by the code. That placeholder is gone. The two `*_loss` functions are now
//! pure evaluators of the real objective, and [`RapidAdaptation::adapt`]
//! descends them with a **finite-difference gradient** of that exact loss.
//! Finite differences genuinely minimize the stated objective (central
//! differences are accurate to O(ε²) truncation; see [`RapidAdaptation::adapt`]),
//! so "the adaptation loss decreases" is now a real, reproducible
//! measurement rather than an artefact of a hand-tuned fake step.
//!
//! **Scope caveat (still honest):** this minimizes a *self-supervised proxy*
//! (temporal-contrastive + prediction entropy) over a tiny LoRA bottleneck on
//! raw CSI frames. It is NOT yet wired to the pose model, and there is no
//! measured end-to-end PCK gain on WiFi pose from this path. ADR-155 records
//! TTA-on-pose as a future, not-yet-measured capability — do not cite a PCK
//! improvement from this module.
/// Loss function(s) for test-time adaptation.
#[derive(Debug, Clone)]
pub enum AdaptationLoss {
/// Contrastive TTT: positive = temporally adjacent, negative = random.
ContrastiveTTT {
/// Gradient-descent epochs.
epochs: usize,
/// Learning rate.
lr: f32,
},
/// Minimize entropy of confidence outputs for sharper predictions.
EntropyMin {
/// Gradient-descent epochs.
epochs: usize,
/// Learning rate.
lr: f32,
},
/// Both contrastive and entropy losses combined.
Combined {
/// Gradient-descent epochs.
epochs: usize,
/// Learning rate.
lr: f32,
/// Weight for entropy term.
lambda_ent: f32,
},
}
impl AdaptationLoss {
/// Number of epochs for this variant.
pub fn epochs(&self) -> usize {
match self {
Self::ContrastiveTTT { epochs, .. }
| Self::EntropyMin { epochs, .. }
| Self::Combined { epochs, .. } => *epochs,
}
}
/// Learning rate for this variant.
pub fn lr(&self) -> f32 {
match self {
Self::ContrastiveTTT { lr, .. }
| Self::EntropyMin { lr, .. }
| Self::Combined { lr, .. } => *lr,
}
}
}
/// Result of [`RapidAdaptation::adapt`].
#[derive(Debug, Clone)]
pub struct AdaptationResult {
/// LoRA weight deltas.
pub lora_weights: Vec<f32>,
/// Final epoch loss.
pub final_loss: f32,
/// Calibration frames consumed.
pub frames_used: usize,
/// Epochs executed.
pub adaptation_epochs: usize,
}
/// Error type for rapid adaptation.
#[derive(Debug, Clone)]
pub enum AdaptError {
/// Not enough calibration frames.
InsufficientFrames {
/// Frames currently buffered.
have: usize,
/// Minimum required.
need: usize,
},
/// LoRA rank must be at least 1.
InvalidRank,
}
impl std::fmt::Display for AdaptError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InsufficientFrames { have, need } => write!(
f,
"insufficient calibration frames: have {have}, need at least {need}"
),
Self::InvalidRank => write!(f, "lora_rank must be >= 1"),
}
}
}
impl std::error::Error for AdaptError {}
/// Few-shot rapid adaptation engine.
///
/// Accumulates unlabeled CSI calibration frames and runs test-time training
/// to produce LoRA weight deltas. Buffer is capped at `max_buffer_frames`
/// (default 10 000) to prevent unbounded memory growth.
///
/// ```rust
/// use wifi_densepose_train::rapid_adapt::{RapidAdaptation, AdaptationLoss};
/// let loss = AdaptationLoss::Combined { epochs: 5, lr: 0.001, lambda_ent: 0.5 };
/// let mut ra = RapidAdaptation::new(10, 4, loss);
/// for i in 0..10 { ra.push_frame(&vec![i as f32; 8]); }
/// assert!(ra.is_ready());
/// let r = ra.adapt().unwrap();
/// assert_eq!(r.frames_used, 10);
/// ```
pub struct RapidAdaptation {
/// Minimum frames before adaptation (default 200 = 10 s @ 20 Hz).
pub min_calibration_frames: usize,
/// LoRA factorization rank (must be >= 1).
pub lora_rank: usize,
/// Loss variant for test-time training.
pub adaptation_loss: AdaptationLoss,
/// Maximum buffer size (ring-buffer eviction beyond this cap).
pub max_buffer_frames: usize,
calibration_buffer: Vec<Vec<f32>>,
}
/// Default maximum calibration buffer size.
const DEFAULT_MAX_BUFFER: usize = 10_000;
impl RapidAdaptation {
/// Create a new adaptation engine.
pub fn new(
min_calibration_frames: usize,
lora_rank: usize,
adaptation_loss: AdaptationLoss,
) -> Self {
Self {
min_calibration_frames,
lora_rank,
adaptation_loss,
max_buffer_frames: DEFAULT_MAX_BUFFER,
calibration_buffer: Vec::new(),
}
}
/// Push a single unlabeled CSI frame. Evicts oldest frame when buffer is full.
pub fn push_frame(&mut self, frame: &[f32]) {
if self.calibration_buffer.len() >= self.max_buffer_frames {
self.calibration_buffer.remove(0);
}
self.calibration_buffer.push(frame.to_vec());
}
/// True when buffer >= min_calibration_frames.
pub fn is_ready(&self) -> bool {
self.calibration_buffer.len() >= self.min_calibration_frames
}
/// Number of buffered frames.
pub fn buffer_len(&self) -> usize {
self.calibration_buffer.len()
}
/// Run test-time adaptation producing LoRA weight deltas.
///
/// Returns an error if the calibration buffer is empty or lora_rank is 0.
pub fn adapt(&self) -> Result<AdaptationResult, AdaptError> {
if self.calibration_buffer.is_empty() {
return Err(AdaptError::InsufficientFrames { have: 0, need: 1 });
}
if self.lora_rank == 0 {
return Err(AdaptError::InvalidRank);
}
let (n, fdim) = (
self.calibration_buffer.len(),
self.calibration_buffer[0].len(),
);
let lora_sz = 2 * fdim * self.lora_rank;
let mut w = vec![0.01_f32; lora_sz];
let (epochs, lr) = (self.adaptation_loss.epochs(), self.adaptation_loss.lr());
let mut final_loss = self.total_loss(&w, fdim);
for _ in 0..epochs {
// Real gradient of the *actual* objective via central finite
// differences (ADR-155 §Tier-1.3). No hand-tuned fake step.
let grad = self.finite_diff_grad(&w, fdim);
for (wi, gi) in w.iter_mut().zip(grad.iter()) {
*wi -= lr * gi;
}
final_loss = self.total_loss(&w, fdim);
}
Ok(AdaptationResult {
lora_weights: w,
final_loss,
frames_used: n,
adaptation_epochs: epochs,
})
}
/// The scalar objective being minimized, for the active loss variant.
fn total_loss(&self, w: &[f32], fdim: usize) -> f32 {
match &self.adaptation_loss {
AdaptationLoss::ContrastiveTTT { .. } => self.contrastive_loss(w, fdim),
AdaptationLoss::EntropyMin { .. } => self.entropy_loss(w, fdim),
AdaptationLoss::Combined { lambda_ent, .. } => {
self.contrastive_loss(w, fdim) + lambda_ent * self.entropy_loss(w, fdim)
}
}
}
/// Central finite-difference gradient of [`Self::total_loss`] w.r.t. `w`.
///
/// `∂L/∂wᵢ ≈ (L(w + ε eᵢ) L(w ε eᵢ)) / (2ε)`. This is the true gradient
/// of the stated objective up to O(ε²) truncation — descending it genuinely
/// reduces the loss (validated by the `*_loss_decreases` tests), unlike the
/// removed `grad += v*0.01` placeholder which was unrelated to the loss.
fn finite_diff_grad(&self, w: &[f32], fdim: usize) -> Vec<f32> {
const EPS: f32 = 1e-3;
let mut grad = vec![0.0_f32; w.len()];
let mut wp = w.to_vec();
for i in 0..w.len() {
let orig = wp[i];
wp[i] = orig + EPS;
let lp = self.total_loss(&wp, fdim);
wp[i] = orig - EPS;
let lm = self.total_loss(&wp, fdim);
wp[i] = orig;
grad[i] = (lp - lm) / (2.0 * EPS);
}
grad
}
/// Temporal-contrastive triplet loss (pure evaluator — no gradient writes).
///
/// Positive = temporally adjacent frame, negative = a half-buffer-away
/// frame; margin-1 triplet hinge over the LoRA-projected features.
fn contrastive_loss(&self, w: &[f32], fdim: usize) -> f32 {
let n = self.calibration_buffer.len();
if n < 2 {
return 0.0;
}
let (margin, pairs) = (1.0_f32, n - 1);
let mut total = 0.0_f32;
for i in 0..pairs {
let (anc, pos) = (&self.calibration_buffer[i], &self.calibration_buffer[i + 1]);
let neg = &self.calibration_buffer[(i + n / 2) % n];
let (pa, pp, pn) = (
self.project(anc, w, fdim),
self.project(pos, w, fdim),
self.project(neg, w, fdim),
);
total += (l2_dist(&pa, &pp) - l2_dist(&pa, &pn) + margin).max(0.0);
}
total / pairs as f32
}
/// Prediction-entropy loss (pure evaluator — no gradient writes).
fn entropy_loss(&self, w: &[f32], fdim: usize) -> f32 {
let n = self.calibration_buffer.len();
if n == 0 {
return 0.0;
}
let nc = self.lora_rank.max(2);
let mut total = 0.0_f32;
for frame in &self.calibration_buffer {
let proj = self.project(frame, w, fdim);
let mut logits = vec![0.0_f32; nc];
for (i, &v) in proj.iter().enumerate() {
logits[i % nc] += v;
}
let mx = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let exps: Vec<f32> = logits.iter().map(|&l| (l - mx).exp()).collect();
let s: f32 = exps.iter().sum();
total += exps
.iter()
.map(|&e| {
let p = e / s;
if p > 1e-10 {
-p * p.ln()
} else {
0.0
}
})
.sum::<f32>();
}
total / n as f32
}
fn project(&self, frame: &[f32], w: &[f32], fdim: usize) -> Vec<f32> {
let rank = self.lora_rank;
let mut hidden = vec![0.0_f32; rank];
for (r, hr) in hidden.iter_mut().enumerate() {
#[allow(clippy::needless_range_loop)]
for d in 0..fdim.min(frame.len()) {
let idx = d * rank + r;
if idx < w.len() {
*hr += w[idx] * frame[d];
}
}
}
let boff = fdim * rank;
(0..fdim)
.map(|d| {
let lora: f32 = (0..rank)
.map(|r| {
let idx = boff + r * fdim + d;
if idx < w.len() {
w[idx] * hidden[r]
} else {
0.0
}
})
.sum();
frame.get(d).copied().unwrap_or(0.0) + lora
})
.collect()
}
}
fn l2_dist(a: &[f32], b: &[f32]) -> f32 {
a.iter()
.zip(b.iter())
.map(|(&x, &y)| (x - y).powi(2))
.sum::<f32>()
.sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_frame_accumulates() {
let mut a = RapidAdaptation::new(
5,
4,
AdaptationLoss::ContrastiveTTT {
epochs: 1,
lr: 0.01,
},
);
assert_eq!(a.buffer_len(), 0);
a.push_frame(&[1.0, 2.0]);
assert_eq!(a.buffer_len(), 1);
a.push_frame(&[3.0, 4.0]);
assert_eq!(a.buffer_len(), 2);
}
#[test]
fn is_ready_threshold() {
let mut a = RapidAdaptation::new(
5,
4,
AdaptationLoss::EntropyMin {
epochs: 3,
lr: 0.001,
},
);
for i in 0..4 {
a.push_frame(&[i as f32; 8]);
assert!(!a.is_ready());
}
a.push_frame(&[99.0; 8]);
assert!(a.is_ready());
a.push_frame(&[100.0; 8]);
assert!(a.is_ready());
}
#[test]
fn adapt_lora_weight_dimension() {
let (fdim, rank) = (16, 4);
let mut a = RapidAdaptation::new(
10,
rank,
AdaptationLoss::ContrastiveTTT {
epochs: 3,
lr: 0.01,
},
);
for i in 0..10 {
a.push_frame(&vec![i as f32 * 0.1; fdim]);
}
let r = a.adapt().unwrap();
assert_eq!(r.lora_weights.len(), 2 * fdim * rank);
assert_eq!(r.frames_used, 10);
assert_eq!(r.adaptation_epochs, 3);
}
#[test]
fn contrastive_loss_decreases() {
// ADR-155 §Tier-1.3: with REAL finite-difference gradients of the actual
// triplet objective, more optimisation must not increase the loss.
let (fdim, rank) = (32, 4);
let mk = |ep| {
let mut a = RapidAdaptation::new(
20,
rank,
AdaptationLoss::ContrastiveTTT {
epochs: ep,
lr: 0.05,
},
);
for i in 0..20 {
let v = i as f32 * 0.1;
a.push_frame(&(0..fdim).map(|d| v + d as f32 * 0.01).collect::<Vec<_>>());
}
a.adapt().unwrap().final_loss
};
let l0 = mk(0); // no optimisation: loss at the initial weights
let l20 = mk(20); // 20 real gradient steps
assert!(
l20 <= l0 + 1e-6,
"20 gradient steps must not increase the contrastive loss: l0={l0}, l20={l20}"
);
}
#[test]
fn entropy_loss_decreases() {
// ADR-155 §Tier-1.3: entropy minimisation must actually reduce entropy.
let (fdim, rank) = (16, 4);
let mk = |ep| {
let mut a = RapidAdaptation::new(
10,
rank,
AdaptationLoss::EntropyMin {
epochs: ep,
lr: 0.05,
},
);
for i in 0..10 {
a.push_frame(
&(0..fdim)
.map(|d| ((i * fdim + d) as f32).sin())
.collect::<Vec<_>>(),
);
}
a.adapt().unwrap().final_loss
};
let l0 = mk(0);
let l30 = mk(30);
assert!(
l30 <= l0 + 1e-6,
"entropy minimisation must not increase entropy: l0={l0}, l30={l30}"
);
}
#[test]
fn reported_loss_is_the_real_objective_not_a_placeholder() {
// The returned final_loss must equal an independent recomputation of the
// contrastive objective at the produced LoRA weights — i.e. it is the
// real loss, not a fabricated number (ADR-155 §Tier-1.3).
let (fdim, rank) = (16, 4);
let mut a = RapidAdaptation::new(
8,
rank,
AdaptationLoss::ContrastiveTTT {
epochs: 3,
lr: 0.02,
},
);
for i in 0..8 {
a.push_frame(&(0..fdim).map(|d| (i + d) as f32 * 0.05).collect::<Vec<_>>());
}
let r = a.adapt().unwrap();
let recomputed = a.contrastive_loss(&r.lora_weights, fdim);
assert!(
(r.final_loss - recomputed).abs() < 1e-5,
"final_loss {} must match the real objective {} at the output weights",
r.final_loss,
recomputed
);
}
#[test]
fn combined_loss_adaptation() {
let (fdim, rank) = (16, 4);
let mut a = RapidAdaptation::new(
10,
rank,
AdaptationLoss::Combined {
epochs: 5,
lr: 0.001,
lambda_ent: 0.5,
},
);
for i in 0..10 {
a.push_frame(
&(0..fdim)
.map(|d| ((i * fdim + d) as f32).sin())
.collect::<Vec<_>>(),
);
}
let r = a.adapt().unwrap();
assert_eq!(r.frames_used, 10);
assert_eq!(r.adaptation_epochs, 5);
assert!(r.final_loss.is_finite());
assert_eq!(r.lora_weights.len(), 2 * fdim * rank);
assert!(r.lora_weights.iter().all(|w| w.is_finite()));
}
#[test]
fn adapt_empty_buffer_returns_error() {
let a = RapidAdaptation::new(
10,
4,
AdaptationLoss::ContrastiveTTT {
epochs: 1,
lr: 0.01,
},
);
assert!(a.adapt().is_err());
}
#[test]
fn adapt_zero_rank_returns_error() {
let mut a = RapidAdaptation::new(
1,
0,
AdaptationLoss::ContrastiveTTT {
epochs: 1,
lr: 0.01,
},
);
a.push_frame(&[1.0, 2.0]);
assert!(a.adapt().is_err());
}
#[test]
fn buffer_cap_evicts_oldest() {
let mut a = RapidAdaptation::new(
2,
4,
AdaptationLoss::ContrastiveTTT {
epochs: 1,
lr: 0.01,
},
);
a.max_buffer_frames = 3;
for i in 0..5 {
a.push_frame(&[i as f32]);
}
assert_eq!(a.buffer_len(), 3);
}
#[test]
fn l2_distance_tests() {
assert!(l2_dist(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]).abs() < 1e-10);
assert!((l2_dist(&[0.0, 0.0], &[3.0, 4.0]) - 5.0).abs() < 1e-6);
}
#[test]
fn loss_accessors() {
let c = AdaptationLoss::ContrastiveTTT {
epochs: 7,
lr: 0.02,
};
assert_eq!(c.epochs(), 7);
assert!((c.lr() - 0.02).abs() < 1e-7);
let e = AdaptationLoss::EntropyMin { epochs: 3, lr: 0.1 };
assert_eq!(e.epochs(), 3);
assert!((e.lr() - 0.1).abs() < 1e-7);
let cb = AdaptationLoss::Combined {
epochs: 5,
lr: 0.001,
lambda_ent: 0.3,
};
assert_eq!(cb.epochs(), 5);
assert!((cb.lr() - 0.001).abs() < 1e-7);
}
}