Files
ruvnet--RuView/v2/crates/wifi-densepose-nn/src/rf_encoder.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

485 lines
17 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.
//! ADR-146 — RF encoder multi-task heads + uncertainty quantification.
//!
//! Extends ADR-024 (AETHER contrastive embedding) with seven task-specific head
//! branches over a shared RF embedding, per-head uncertainty, a
//! calibration-robustness loss tying invariance to the ADR-135 `calibration_id`,
//! and a `ContrastiveBatcher` sampling contract. The tensor ABI is **pure-Rust
//! `f32`** (no backend-specific tensor type at this boundary) so inference is
//! deterministic and witnessable (ADR-136 §2.5) and a head can be toggled by the
//! ADR-145 ablation matrix.
/// Shared RF embedding dimension (ADR-146 / ADR-024 AETHER).
pub const EMBEDDING_DIM: usize = 256;
/// A 256-d shared RF embedding (pure-Rust f32 ABI).
#[derive(Debug, Clone, PartialEq)]
pub struct RfEmbedding(pub Vec<f32>);
impl RfEmbedding {
/// Wrap a vector, asserting it is [`EMBEDDING_DIM`] long.
#[must_use]
pub fn new(v: Vec<f32>) -> Self {
debug_assert_eq!(v.len(), EMBEDDING_DIM, "embedding must be {EMBEDDING_DIM}-d");
Self(v)
}
/// Squared L2 distance to another embedding.
#[must_use]
pub fn sq_dist(&self, other: &RfEmbedding) -> f32 {
self.0.iter().zip(&other.0).map(|(a, b)| (a - b).powi(2)).sum()
}
}
/// The seven task heads over the shared encoder (ADR-146 §2.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TaskKind {
/// 17-keypoint pose.
Pose,
/// Binary presence.
Presence,
/// Person count.
Count,
/// Activity class.
Activity,
/// Vital signs (HR/BR).
Vitals,
/// Gait signature.
Gait,
/// Identity embedding (AETHER re-ID).
IdentityEmbedding,
}
impl TaskKind {
/// All seven heads.
pub const ALL: [TaskKind; 7] = [
TaskKind::Pose,
TaskKind::Presence,
TaskKind::Count,
TaskKind::Activity,
TaskKind::Vitals,
TaskKind::Gait,
TaskKind::IdentityEmbedding,
];
}
/// One head's output: task values plus a scalar predictive uncertainty
/// (ADR-146 §2.2). `uncertainty` mirrors the spirit of the ADR-136
/// `QualityScored` trait — lower is more confident.
#[derive(Debug, Clone, PartialEq)]
pub struct HeadOutput {
/// Which head produced this.
pub task: TaskKind,
/// Raw output activations.
pub values: Vec<f32>,
/// Predictive uncertainty in [0, ∞); softplus of a learned log-variance.
pub uncertainty: f32,
}
impl HeadOutput {
/// Confidence in [0, 1] derived from uncertainty (`1 / (1 + uncertainty)`),
/// matching the ADR-136 `QualityScored::quality_score` contract shape.
#[must_use]
pub fn confidence(&self) -> f32 {
1.0 / (1.0 + self.uncertainty)
}
}
/// A linear task head: `out = W·emb + b`, plus a separate scalar log-variance
/// projection `lv = wᵥ·emb + bᵥ` whose softplus is the predictive uncertainty.
#[derive(Debug, Clone)]
pub struct LinearHead {
task: TaskKind,
/// Row-major `[out_dim × EMBEDDING_DIM]` weights.
w: Vec<f32>,
b: Vec<f32>,
out_dim: usize,
/// Uncertainty (log-variance) projection over the embedding.
var_w: Vec<f32>,
var_b: f32,
}
/// A shape mismatch when building a [`LinearHead`] from supplied weights.
///
/// Returned by [`LinearHead::try_new`] so a caller loading weights from an
/// **untrusted / deserialized** source can validate the tensor shapes without
/// the panic that [`LinearHead::new`] raises on a programmer-supplied mismatch
/// (ADR-155 M2 §3: a pure-Rust input guard ahead of the construction contract).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RfHeadError {
/// `w.len()` was not `out_dim * EMBEDDING_DIM`.
WeightShape {
/// Expected length (`out_dim * EMBEDDING_DIM`).
expected: usize,
/// Actual `w.len()`.
got: usize,
},
/// `b.len()` was not `out_dim`.
BiasShape {
/// Expected length (`out_dim`).
expected: usize,
/// Actual `b.len()`.
got: usize,
},
/// `var_w.len()` was not `EMBEDDING_DIM`.
VarWeightShape {
/// Expected length (`EMBEDDING_DIM`).
expected: usize,
/// Actual `var_w.len()`.
got: usize,
},
}
impl std::fmt::Display for RfHeadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WeightShape { expected, got } => {
write!(f, "weight shape mismatch: expected {expected}, got {got}")
}
Self::BiasShape { expected, got } => {
write!(f, "bias shape mismatch: expected {expected}, got {got}")
}
Self::VarWeightShape { expected, got } => {
write!(f, "var weight shape mismatch: expected {expected}, got {got}")
}
}
}
}
impl std::error::Error for RfHeadError {}
impl LinearHead {
/// Build a head with given weights. `w.len()` must be `out_dim * EMBEDDING_DIM`.
///
/// # Panics
///
/// Panics on a shape mismatch (`w`/`b`/`var_w`). This is a construction-time
/// API contract on *programmer-supplied* vectors. For weights from an
/// untrusted / deserialized source, prefer [`LinearHead::try_new`], which
/// returns a typed [`RfHeadError`] instead of panicking.
#[must_use]
pub fn new(task: TaskKind, out_dim: usize, w: Vec<f32>, b: Vec<f32>, var_w: Vec<f32>, var_b: f32) -> Self {
assert_eq!(w.len(), out_dim * EMBEDDING_DIM, "weight shape mismatch");
assert_eq!(b.len(), out_dim, "bias shape mismatch");
assert_eq!(var_w.len(), EMBEDDING_DIM, "var weight shape mismatch");
Self { task, w, b, out_dim, var_w, var_b }
}
/// Fallible constructor: validate the weight shapes and return a typed
/// [`RfHeadError`] on mismatch instead of panicking (ADR-155 M2 §3).
///
/// Use this when `w` / `b` / `var_w` originate from a checkpoint or any
/// untrusted source. On success the produced head is byte-for-byte identical
/// to [`LinearHead::new`] with the same arguments.
///
/// # Errors
///
/// Returns [`RfHeadError`] when any of:
/// - `w.len() != out_dim * EMBEDDING_DIM`
/// - `b.len() != out_dim`
/// - `var_w.len() != EMBEDDING_DIM`
pub fn try_new(
task: TaskKind,
out_dim: usize,
w: Vec<f32>,
b: Vec<f32>,
var_w: Vec<f32>,
var_b: f32,
) -> Result<Self, RfHeadError> {
let expected_w = out_dim * EMBEDDING_DIM;
if w.len() != expected_w {
return Err(RfHeadError::WeightShape { expected: expected_w, got: w.len() });
}
if b.len() != out_dim {
return Err(RfHeadError::BiasShape { expected: out_dim, got: b.len() });
}
if var_w.len() != EMBEDDING_DIM {
return Err(RfHeadError::VarWeightShape { expected: EMBEDDING_DIM, got: var_w.len() });
}
Ok(Self { task, w, b, out_dim, var_w, var_b })
}
/// A zero-initialised head (uncertainty = softplus(0) ≈ 0.693).
#[must_use]
pub fn zeros(task: TaskKind, out_dim: usize) -> Self {
Self::new(
task,
out_dim,
vec![0.0; out_dim * EMBEDDING_DIM],
vec![0.0; out_dim],
vec![0.0; EMBEDDING_DIM],
0.0,
)
}
/// Forward pass over a shared embedding.
#[must_use]
pub fn forward(&self, emb: &RfEmbedding) -> HeadOutput {
let mut values = vec![0.0f32; self.out_dim];
for o in 0..self.out_dim {
let row = &self.w[o * EMBEDDING_DIM..(o + 1) * EMBEDDING_DIM];
let dot: f32 = row.iter().zip(&emb.0).map(|(wi, xi)| wi * xi).sum();
values[o] = dot + self.b[o];
}
let log_var: f32 = self.var_w.iter().zip(&emb.0).map(|(wi, xi)| wi * xi).sum::<f32>() + self.var_b;
let uncertainty = softplus(log_var);
HeadOutput { task: self.task, values, uncertainty }
}
}
/// Input magnitude above which `softplus(x) ≈ x` to f32 precision, so the
/// `exp` is skipped to avoid overflow (ADR-155 M2 §8: de-magicked from a bare
/// `20.0`; value unchanged). At x = 20, `ln(1+e^20) 20 ≈ 2e-9`, below f32 eps.
const SOFTPLUS_LINEAR_THRESHOLD: f32 = 20.0;
fn softplus(x: f32) -> f32 {
// Numerically stable softplus.
if x > SOFTPLUS_LINEAR_THRESHOLD {
x
} else {
(1.0 + x.exp()).ln()
}
}
/// Multi-task encoder: a shared embedding feeding a set of [`LinearHead`]s
/// (ADR-146 §2.1). Heads can be subset for ADR-145 ablation.
#[derive(Debug, Clone, Default)]
pub struct MultiTaskHeads {
heads: Vec<LinearHead>,
}
impl MultiTaskHeads {
/// Empty head set.
#[must_use]
pub fn new() -> Self {
Self { heads: Vec::new() }
}
/// Add a head.
pub fn push(&mut self, head: LinearHead) {
self.heads.push(head);
}
/// Number of active heads.
#[must_use]
pub fn len(&self) -> usize {
self.heads.len()
}
/// Whether no heads are configured.
#[must_use]
pub fn is_empty(&self) -> bool {
self.heads.is_empty()
}
/// Run every head on the shared embedding.
#[must_use]
pub fn forward(&self, emb: &RfEmbedding) -> Vec<HeadOutput> {
self.heads.iter().map(|h| h.forward(emb)).collect()
}
/// Run only the heads in `enabled` (ADR-145 ablation toggle).
#[must_use]
pub fn forward_subset(&self, emb: &RfEmbedding, enabled: &[TaskKind]) -> Vec<HeadOutput> {
self.heads
.iter()
.filter(|h| enabled.contains(&h.task))
.map(|h| h.forward(emb))
.collect()
}
}
/// Calibration-robustness loss (ADR-146 §2.3): the encoder should produce the
/// same embedding for the same physical input under two different ADR-135
/// calibration baselines. Returns the mean squared embedding difference — a
/// penalty that is 0 under perfect calibration invariance.
#[must_use]
pub fn calibration_robustness_loss(under_cal_a: &RfEmbedding, under_cal_b: &RfEmbedding) -> f32 {
under_cal_a.sq_dist(under_cal_b) / EMBEDDING_DIM as f32
}
/// Triplet contrastive loss (ADR-024 / ADR-146 §2.4): pull `anchor` toward
/// `positive` (same physical state), push from `negative` (different), with a
/// margin. `max(0, d(a,p) - d(a,n) + margin)`.
#[must_use]
pub fn triplet_loss(anchor: &RfEmbedding, positive: &RfEmbedding, negative: &RfEmbedding, margin: f32) -> f32 {
(anchor.sq_dist(positive) - anchor.sq_dist(negative) + margin).max(0.0)
}
/// A contrastive training triplet over the shared embedding space.
#[derive(Debug, Clone)]
pub struct Triplet {
/// Anchor sample index.
pub anchor: usize,
/// Positive (same state, different environment) index.
pub positive: usize,
/// Negative (different state) index.
pub negative: usize,
}
/// Formalised contrastive pair/triplet sampler (ADR-146 §2.4): positives are the
/// *same physical state across different environments* (cross-room invariance,
/// ADR-027 MERIDIAN); negatives are *different states*.
#[derive(Debug, Clone)]
pub struct ContrastiveBatcher {
/// `state_of[i]` = the physical-state label of sample `i`.
state_of: Vec<u32>,
/// `env_of[i]` = the environment/room label of sample `i`.
env_of: Vec<u32>,
}
impl ContrastiveBatcher {
/// Build from per-sample (state, environment) labels.
#[must_use]
pub fn new(state_of: Vec<u32>, env_of: Vec<u32>) -> Self {
assert_eq!(state_of.len(), env_of.len(), "label vectors must align");
Self { state_of, env_of }
}
/// Deterministically enumerate triplets: for each anchor, the first sample
/// with the *same state but a different environment* is the positive, and
/// the first sample with a *different state* is the negative. Anchors with
/// no valid positive or negative are skipped. Determinism (lowest-index
/// choice) keeps the batch witnessable (ADR-136 §2.5).
#[must_use]
pub fn triplets(&self) -> Vec<Triplet> {
let n = self.state_of.len();
let mut out = Vec::new();
for a in 0..n {
let positive = (0..n).find(|&p| {
p != a && self.state_of[p] == self.state_of[a] && self.env_of[p] != self.env_of[a]
});
let negative = (0..n).find(|&q| self.state_of[q] != self.state_of[a]);
if let (Some(positive), Some(negative)) = (positive, negative) {
out.push(Triplet { anchor: a, positive, negative });
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn emb(fill: f32) -> RfEmbedding {
RfEmbedding::new(vec![fill; EMBEDDING_DIM])
}
/// ADR-155 M2 §8: the de-magicked softplus linear-threshold must equal the
/// prior inline `20.0` literal exactly (operating-value guard).
#[test]
fn softplus_threshold_unchanged_from_literal() {
assert_eq!(SOFTPLUS_LINEAR_THRESHOLD, 20.0_f32);
}
/// ADR-155 M2 §3: `try_new` accepts correctly-shaped weights and produces a
/// head byte-identical to `new`, but returns a typed error on a mismatched
/// (e.g. corrupt-checkpoint) shape instead of panicking.
#[test]
fn try_new_accepts_valid_and_rejects_each_bad_shape() {
let out_dim = 2;
let w = vec![0.0; out_dim * EMBEDDING_DIM];
let b = vec![0.0; out_dim];
let var_w = vec![0.0; EMBEDDING_DIM];
// Valid: try_new == new (forward identical on a probe embedding).
let head = LinearHead::try_new(TaskKind::Presence, out_dim, w.clone(), b.clone(), var_w.clone(), 0.0)
.expect("valid shapes must construct");
let reference = LinearHead::new(TaskKind::Presence, out_dim, w.clone(), b.clone(), var_w.clone(), 0.0);
assert_eq!(head.forward(&emb(0.5)).values, reference.forward(&emb(0.5)).values);
// Bad weight length.
assert_eq!(
LinearHead::try_new(TaskKind::Presence, out_dim, vec![0.0; 3], b.clone(), var_w.clone(), 0.0)
.unwrap_err(),
RfHeadError::WeightShape { expected: out_dim * EMBEDDING_DIM, got: 3 }
);
// Bad bias length.
assert_eq!(
LinearHead::try_new(TaskKind::Presence, out_dim, w.clone(), vec![0.0; 1], var_w.clone(), 0.0)
.unwrap_err(),
RfHeadError::BiasShape { expected: out_dim, got: 1 }
);
// Bad var-weight length.
assert_eq!(
LinearHead::try_new(TaskKind::Presence, out_dim, w, b, vec![0.0; 5], 0.0).unwrap_err(),
RfHeadError::VarWeightShape { expected: EMBEDDING_DIM, got: 5 }
);
}
#[test]
fn head_forward_produces_values_and_finite_uncertainty() {
let head = LinearHead::zeros(TaskKind::Presence, 2);
let out = head.forward(&emb(1.0));
assert_eq!(out.values, vec![0.0, 0.0]); // zero weights
assert!(out.uncertainty.is_finite() && out.uncertainty > 0.0);
assert!((out.confidence() - 1.0 / (1.0 + out.uncertainty)).abs() < 1e-6);
}
#[test]
fn uncertainty_responds_to_log_variance_weights() {
// var_w all 1 → log_var = sum(emb) = 256 → softplus ≈ 256 (clamped path).
let head = LinearHead::new(
TaskKind::Vitals,
1,
vec![0.0; EMBEDDING_DIM],
vec![0.0],
vec![1.0; EMBEDDING_DIM],
0.0,
);
let out = head.forward(&emb(1.0));
assert!(out.uncertainty > 100.0, "high log-var → high uncertainty");
assert!(out.confidence() < 0.02);
}
#[test]
fn calibration_robustness_loss_zero_for_identical() {
assert_eq!(calibration_robustness_loss(&emb(0.5), &emb(0.5)), 0.0);
assert!(calibration_robustness_loss(&emb(0.0), &emb(1.0)) > 0.0);
}
#[test]
fn triplet_loss_properties() {
let a = emb(0.0);
let p = emb(0.1); // close
let n = emb(5.0); // far
// d(a,p) << d(a,n) → loss should be 0 with a modest margin.
assert_eq!(triplet_loss(&a, &p, &n, 0.5), 0.0);
// Swap: positive far, negative close → positive loss.
assert!(triplet_loss(&a, &n, &p, 0.5) > 0.0);
}
#[test]
fn multitask_subset_ablation() {
let mut heads = MultiTaskHeads::new();
heads.push(LinearHead::zeros(TaskKind::Presence, 1));
heads.push(LinearHead::zeros(TaskKind::Pose, 51));
heads.push(LinearHead::zeros(TaskKind::Vitals, 2));
assert_eq!(heads.forward(&emb(1.0)).len(), 3);
// Ablate to just presence + vitals.
let sub = heads.forward_subset(&emb(1.0), &[TaskKind::Presence, TaskKind::Vitals]);
assert_eq!(sub.len(), 2);
assert!(sub.iter().all(|o| o.task != TaskKind::Pose));
}
#[test]
fn contrastive_batcher_samples_cross_env_positives() {
// samples: 0=(stateA,room0) 1=(stateA,room1) 2=(stateB,room0)
let b = ContrastiveBatcher::new(vec![0, 0, 1], vec![0, 1, 0]);
let trips = b.triplets();
// Anchor 0: positive=1 (same state, diff room), negative=2 (diff state).
let t0 = trips.iter().find(|t| t.anchor == 0).unwrap();
assert_eq!(t0.positive, 1);
assert_eq!(t0.negative, 2);
// Anchor 2 (stateB) has no same-state-diff-env positive → skipped.
assert!(trips.iter().all(|t| t.anchor != 2));
// Deterministic.
assert_eq!(b.triplets().len(), trips.len());
}
#[test]
fn seven_task_heads() {
assert_eq!(TaskKind::ALL.len(), 7);
}
}