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

536 lines
20 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.
//! Subcarrier interpolation and selection utilities.
//!
//! This module provides functions to resample CSI subcarrier arrays between
//! different subcarrier counts using linear interpolation, and to select
//! the most informative subcarriers based on signal variance.
//!
//! # Example
//!
//! ```rust
//! use wifi_densepose_train::subcarrier::interpolate_subcarriers;
//! use ndarray::Array4;
//!
//! // Resample from 114 → 56 subcarriers
//! let arr = Array4::<f32>::zeros((100, 3, 3, 114));
//! let resampled = interpolate_subcarriers(&arr, 56);
//! assert_eq!(resampled.shape(), &[100, 3, 3, 56]);
//! ```
use ndarray::{s, Array4};
use ruvector_solver::neumann::NeumannSolver;
use ruvector_solver::types::CsrMatrix;
// --- Sparse-interpolation tuning constants (ADR-155 M2 §8: de-magicked from
// bare literals in `interpolate_subcarriers_sparse`; values bit-identical to the
// prior inline literals — documentation only, no behaviour change). ---
/// Gaussian-basis width (in the normalised `[0,1]` subcarrier position space)
/// for the sparse-interpolation kernel `exp(-Δ²/σ²)`. Wider σ ⇒ smoother fit.
const SPARSE_BASIS_SIGMA: f32 = 0.15;
/// Sparsity cutoff: basis entries below this magnitude are dropped from the
/// normal-equations assembly, keeping `AᵀA` sparse.
const SPARSE_BASIS_THRESHOLD: f32 = 1e-4;
/// Tikhonov regularisation strength `λ` added to the `AᵀA` diagonal for
/// numerical stability of the (possibly ill-conditioned) normal equations.
const SPARSE_REGULARIZATION_LAMBDA: f32 = 0.1;
/// Magnitude below which an assembled `AᵀA` entry is treated as structurally
/// zero and omitted from the COO triplet list.
const SPARSE_COO_PRUNE_EPS: f32 = 1e-8;
/// Convergence tolerance for the Neumann-series sparse solver (`f64` to match
/// [`NeumannSolver::new`]).
const SPARSE_SOLVER_TOL: f64 = 1e-5;
/// Maximum Neumann-series iterations before the solver returns (falls back to
/// linear interpolation on non-convergence).
const SPARSE_SOLVER_MAX_ITERS: usize = 500;
// ---------------------------------------------------------------------------
// interpolate_subcarriers
// ---------------------------------------------------------------------------
/// Resample a 4-D CSI array along the subcarrier axis (last dimension) to
/// `target_sc` subcarriers using linear interpolation.
///
/// # Arguments
///
/// - `arr`: Input array with shape `[T, n_tx, n_rx, n_sc]`.
/// - `target_sc`: Number of output subcarriers.
///
/// # Returns
///
/// A new array with shape `[T, n_tx, n_rx, target_sc]`.
///
/// # Panics
///
/// Panics if `target_sc == 0` or the input has no subcarrier dimension.
///
/// Non-contiguous inputs (e.g. a transposed or strided view) are handled
/// gracefully: the subcarrier lane is copied into a contiguous scratch buffer
/// when the underlying storage is not contiguous, so this function never
/// panics on layout (ADR-155 §Tier-2).
pub fn interpolate_subcarriers(arr: &Array4<f32>, target_sc: usize) -> Array4<f32> {
assert!(target_sc > 0, "target_sc must be > 0");
let shape = arr.shape();
let (n_t, n_tx, n_rx, n_sc) = (shape[0], shape[1], shape[2], shape[3]);
if n_sc == target_sc {
return arr.clone();
}
let mut out = Array4::<f32>::zeros((n_t, n_tx, n_rx, target_sc));
// Precompute interpolation weights once.
let weights = compute_interp_weights(n_sc, target_sc);
// Reusable scratch buffer for the non-contiguous fallback path.
let mut scratch: Vec<f32> = Vec::new();
for t in 0..n_t {
for tx in 0..n_tx {
for rx in 0..n_rx {
let src = arr.slice(s![t, tx, rx, ..]);
// Prefer the contiguous fast path; fall back to an owned copy
// for non-contiguous layouts instead of panicking.
let src_slice: &[f32] = match src.as_slice() {
Some(s) => s,
None => {
scratch.clear();
scratch.extend(src.iter().copied());
&scratch
}
};
for (k, &(i0, i1, w)) in weights.iter().enumerate() {
let v = src_slice[i0] * (1.0 - w) + src_slice[i1] * w;
out[[t, tx, rx, k]] = v;
}
}
}
}
out
}
// ---------------------------------------------------------------------------
// compute_interp_weights
// ---------------------------------------------------------------------------
/// Compute linear interpolation indices and fractional weights for resampling
/// from `src_sc` to `target_sc` subcarriers.
///
/// Returns a `Vec` of `(i0, i1, frac)` tuples where each output subcarrier `k`
/// is computed as `src[i0] * (1 - frac) + src[i1] * frac`.
///
/// # Arguments
///
/// - `src_sc`: Number of subcarriers in the source array.
/// - `target_sc`: Number of subcarriers in the output array.
///
/// # Panics
///
/// Panics if `src_sc == 0` or `target_sc == 0`.
pub fn compute_interp_weights(src_sc: usize, target_sc: usize) -> Vec<(usize, usize, f32)> {
assert!(src_sc > 0, "src_sc must be > 0");
assert!(target_sc > 0, "target_sc must be > 0");
let mut weights = Vec::with_capacity(target_sc);
for k in 0..target_sc {
// Map output index k to a continuous position in the source array.
// Scale so that index 0 maps to 0 and index (target_sc-1) maps to
// (src_sc-1) — i.e., endpoints are preserved.
let pos = if target_sc == 1 {
0.0f32
} else {
k as f32 * (src_sc - 1) as f32 / (target_sc - 1) as f32
};
let i0 = (pos.floor() as usize).min(src_sc - 1);
let i1 = (pos.ceil() as usize).min(src_sc - 1);
let frac = pos - pos.floor();
weights.push((i0, i1, frac));
}
weights
}
// ---------------------------------------------------------------------------
// interpolate_subcarriers_sparse
// ---------------------------------------------------------------------------
/// Resample CSI subcarriers using sparse regularized least-squares (ruvector-solver).
///
/// Models the CSI spectrum as a sparse combination of Gaussian basis functions
/// evaluated at source-subcarrier positions, physically motivated by multipath
/// propagation (each received component corresponds to a sparse set of delays).
///
/// The interpolation solves: `A·x ≈ b`
/// - `b`: CSI amplitude at source subcarrier positions `[src_sc]`
/// - `A`: Gaussian basis matrix `[src_sc, target_sc]` — each row j is the
/// Gaussian kernel `exp(-||target_k - src_j||^2 / sigma^2)` for each k
/// - `x`: target subcarrier values (to be solved)
///
/// A regularization term `λI` is added to A^T·A for numerical stability.
///
/// Falls back to linear interpolation on solver error.
///
/// # Performance
///
/// O(√n_sc) iterations for n_sc subcarriers via Neumann series solver.
pub fn interpolate_subcarriers_sparse(arr: &Array4<f32>, target_sc: usize) -> Array4<f32> {
assert!(target_sc > 0, "target_sc must be > 0");
let shape = arr.shape();
let (n_t, n_tx, n_rx, n_sc) = (shape[0], shape[1], shape[2], shape[3]);
if n_sc == target_sc {
return arr.clone();
}
// Build the Gaussian basis matrix A: [src_sc, target_sc]
// A[j, k] = exp(-((j/(n_sc-1) - k/(target_sc-1))^2) / sigma^2)
let sigma = SPARSE_BASIS_SIGMA;
let sigma_sq = sigma * sigma;
// Source and target normalized positions in [0, 1]
let src_pos: Vec<f32> = (0..n_sc)
.map(|j| {
if n_sc == 1 {
0.0
} else {
j as f32 / (n_sc - 1) as f32
}
})
.collect();
let tgt_pos: Vec<f32> = (0..target_sc)
.map(|k| {
if target_sc == 1 {
0.0
} else {
k as f32 / (target_sc - 1) as f32
}
})
.collect();
// Only include entries above a sparsity threshold
let threshold = SPARSE_BASIS_THRESHOLD;
// Build A^T A + λI regularized system for normal equations
// We solve: (A^T A + λI) x = A^T b
// A^T A is [target_sc × target_sc]
let lambda = SPARSE_REGULARIZATION_LAMBDA;
let mut ata_coo: Vec<(usize, usize, f32)> = Vec::new();
// Compute A^T A
// (A^T A)[k1, k2] = sum_j A[j,k1] * A[j,k2]
// This is dense but small (target_sc × target_sc, typically 56×56)
let mut ata = vec![vec![0.0_f32; target_sc]; target_sc];
#[allow(clippy::needless_range_loop)]
for j in 0..n_sc {
for k1 in 0..target_sc {
let diff1 = src_pos[j] - tgt_pos[k1];
let a_jk1 = (-diff1 * diff1 / sigma_sq).exp();
if a_jk1 < threshold {
continue;
}
for k2 in 0..target_sc {
let diff2 = src_pos[j] - tgt_pos[k2];
let a_jk2 = (-diff2 * diff2 / sigma_sq).exp();
if a_jk2 < threshold {
continue;
}
ata[k1][k2] += a_jk1 * a_jk2;
}
}
}
// Add λI regularization and convert to COO
for (k, row) in ata.iter().enumerate() {
for (k2, &cell) in row.iter().enumerate() {
let val = cell + if k == k2 { lambda } else { 0.0 };
if val.abs() > SPARSE_COO_PRUNE_EPS {
ata_coo.push((k, k2, val));
}
}
}
// Build CsrMatrix for the normal equations system (A^T A + λI)
let normal_matrix = CsrMatrix::<f32>::from_coo(target_sc, target_sc, ata_coo);
let solver = NeumannSolver::new(SPARSE_SOLVER_TOL, SPARSE_SOLVER_MAX_ITERS);
let mut out = Array4::<f32>::zeros((n_t, n_tx, n_rx, target_sc));
for t in 0..n_t {
for tx in 0..n_tx {
for rx in 0..n_rx {
let src_slice: Vec<f32> = (0..n_sc).map(|s| arr[[t, tx, rx, s]]).collect();
// Compute A^T b [target_sc]
let mut atb = vec![0.0_f32; target_sc];
for j in 0..n_sc {
let b_j = src_slice[j];
for k in 0..target_sc {
let diff = src_pos[j] - tgt_pos[k];
let a_jk = (-diff * diff / sigma_sq).exp();
if a_jk > threshold {
atb[k] += a_jk * b_j;
}
}
}
// Solve (A^T A + λI) x = A^T b
match solver.solve(&normal_matrix, &atb) {
Ok(result) => {
for k in 0..target_sc {
out[[t, tx, rx, k]] = result.solution[k];
}
}
Err(_) => {
// Fallback to linear interpolation
let weights = compute_interp_weights(n_sc, target_sc);
for (k, &(i0, i1, w)) in weights.iter().enumerate() {
out[[t, tx, rx, k]] = src_slice[i0] * (1.0 - w) + src_slice[i1] * w;
}
}
}
}
}
}
out
}
// ---------------------------------------------------------------------------
// select_subcarriers_by_variance
// ---------------------------------------------------------------------------
/// Select the `k` most informative subcarrier indices based on temporal variance.
///
/// Computes the variance of each subcarrier across the time and antenna
/// dimensions, then returns the indices of the `k` subcarriers with the
/// highest variance, sorted in ascending order.
///
/// # Arguments
///
/// - `arr`: Input array with shape `[T, n_tx, n_rx, n_sc]`.
/// - `k`: Number of subcarriers to select.
///
/// # Returns
///
/// A `Vec<usize>` of length `k` with the selected subcarrier indices (ascending).
///
/// # Panics
///
/// Panics if `k == 0` or `k > n_sc`.
pub fn select_subcarriers_by_variance(arr: &Array4<f32>, k: usize) -> Vec<usize> {
let shape = arr.shape();
let n_sc = shape[3];
assert!(k > 0, "k must be > 0");
assert!(k <= n_sc, "k ({k}) must be <= n_sc ({n_sc})");
let total_elems = shape[0] * shape[1] * shape[2];
// Compute mean per subcarrier.
let mut means = vec![0.0f64; n_sc];
for (sc, mean_sc) in means.iter_mut().enumerate() {
let col = arr.slice(s![.., .., .., sc]);
let sum: f64 = col.iter().map(|&v| v as f64).sum();
*mean_sc = sum / total_elems as f64;
}
// Compute variance per subcarrier.
let mut variances = vec![0.0f64; n_sc];
for sc in 0..n_sc {
let col = arr.slice(s![.., .., .., sc]);
let mean = means[sc];
let var: f64 =
col.iter().map(|&v| (v as f64 - mean).powi(2)).sum::<f64>() / total_elems as f64;
variances[sc] = var;
}
// Rank subcarriers by descending variance.
let mut ranked: Vec<usize> = (0..n_sc).collect();
ranked.sort_by(|&a, &b| {
variances[b]
.partial_cmp(&variances[a])
.unwrap_or(std::cmp::Ordering::Equal)
});
// Take top-k and sort ascending for a canonical representation.
let mut selected: Vec<usize> = ranked[..k].to_vec();
selected.sort_unstable();
selected
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
/// ADR-155 M2 §8: the de-magicked sparse-interpolation consts must equal the
/// prior inline literals exactly (operating-value guard).
#[test]
fn sparse_interp_consts_unchanged_from_literals() {
assert_eq!(SPARSE_BASIS_SIGMA, 0.15_f32);
assert_eq!(SPARSE_BASIS_THRESHOLD, 1e-4_f32);
assert_eq!(SPARSE_REGULARIZATION_LAMBDA, 0.1_f32);
assert_eq!(SPARSE_COO_PRUNE_EPS, 1e-8_f32);
assert_eq!(SPARSE_SOLVER_TOL, 1e-5_f64);
assert_eq!(SPARSE_SOLVER_MAX_ITERS, 500);
}
/// Characterize the `target_sc == 1` boundary of `compute_interp_weights`:
/// the single output maps to source index 0 with zero fraction (the special
/// branch that avoids dividing by `target_sc - 1 == 0`).
#[test]
fn compute_interp_weights_single_target_is_index_zero() {
let w = compute_interp_weights(7, 1);
assert_eq!(w.len(), 1);
let (i0, i1, frac) = w[0];
assert_eq!(i0, 0);
assert_eq!(i1, 0);
assert_abs_diff_eq!(frac, 0.0_f32, epsilon = 1e-6);
}
/// Characterize sparse interpolation to a single subcarrier: must produce
/// the right shape and a finite value (exercises the `target_sc == 1`
/// normalized-position branch).
#[test]
fn sparse_interp_single_target_is_finite() {
let arr = Array4::<f32>::from_shape_fn((2, 1, 1, 8), |(_, _, _, k)| k as f32);
let out = interpolate_subcarriers_sparse(&arr, 1);
assert_eq!(out.shape(), &[2, 1, 1, 1]);
assert!(out.iter().all(|v| v.is_finite()));
}
#[test]
fn identity_resample() {
let arr =
Array4::<f32>::from_shape_fn((4, 3, 3, 56), |(t, tx, rx, k)| (t + tx + rx + k) as f32);
let out = interpolate_subcarriers(&arr, 56);
assert_eq!(out.shape(), arr.shape());
// Identity resample must preserve all values exactly.
for v in arr.iter().zip(out.iter()) {
assert_abs_diff_eq!(v.0, v.1, epsilon = 1e-6);
}
}
#[test]
fn upsample_endpoints_preserved() {
// When resampling from 4 → 8 the first and last values are exact.
let arr = Array4::<f32>::from_shape_fn((1, 1, 1, 4), |(_, _, _, k)| k as f32);
let out = interpolate_subcarriers(&arr, 8);
assert_eq!(out.shape(), &[1, 1, 1, 8]);
assert_abs_diff_eq!(out[[0, 0, 0, 0]], 0.0_f32, epsilon = 1e-6);
assert_abs_diff_eq!(out[[0, 0, 0, 7]], 3.0_f32, epsilon = 1e-6);
}
#[test]
fn downsample_endpoints_preserved() {
// Downsample from 8 → 4.
let arr = Array4::<f32>::from_shape_fn((1, 1, 1, 8), |(_, _, _, k)| k as f32 * 2.0);
let out = interpolate_subcarriers(&arr, 4);
assert_eq!(out.shape(), &[1, 1, 1, 4]);
// First value: 0.0, last value: 14.0
assert_abs_diff_eq!(out[[0, 0, 0, 0]], 0.0_f32, epsilon = 1e-5);
assert_abs_diff_eq!(out[[0, 0, 0, 3]], 14.0_f32, epsilon = 1e-5);
}
#[test]
fn compute_interp_weights_identity() {
let w = compute_interp_weights(5, 5);
assert_eq!(w.len(), 5);
for (k, &(i0, i1, frac)) in w.iter().enumerate() {
assert_eq!(i0, k);
assert_eq!(i1, k);
assert_abs_diff_eq!(frac, 0.0_f32, epsilon = 1e-6);
}
}
#[test]
fn select_subcarriers_returns_correct_count() {
let arr = Array4::<f32>::from_shape_fn((10, 3, 3, 56), |(t, _, _, k)| (t * k) as f32);
let selected = select_subcarriers_by_variance(&arr, 8);
assert_eq!(selected.len(), 8);
}
#[test]
fn select_subcarriers_sorted_ascending() {
let arr = Array4::<f32>::from_shape_fn((10, 3, 3, 56), |(t, _, _, k)| (t * k) as f32);
let selected = select_subcarriers_by_variance(&arr, 10);
for w in selected.windows(2) {
assert!(w[0] < w[1], "Indices must be sorted ascending");
}
}
#[test]
fn select_subcarriers_all_same_returns_all() {
// When all subcarriers have zero variance, the function should still
// return k valid indices.
let arr = Array4::<f32>::ones((5, 2, 2, 20));
let selected = select_subcarriers_by_variance(&arr, 5);
assert_eq!(selected.len(), 5);
// All selected indices must be in [0, 19]
for &idx in &selected {
assert!(idx < 20);
}
}
#[test]
fn sparse_interpolation_114_to_56_shape() {
let arr = Array4::<f32>::from_shape_fn((4, 1, 3, 114), |(t, _, rx, k)| {
((t + rx + k) as f32).sin()
});
let out = interpolate_subcarriers_sparse(&arr, 56);
assert_eq!(out.shape(), &[4, 1, 3, 56]);
}
// ADR-155 §Tier-2: a non-contiguous input (subcarrier axis strided after an
// axis permutation) must NOT panic — the old `.as_slice().unwrap_or_else(||
// panic!(...))` path crashed on any non-contiguous layout.
#[test]
fn non_contiguous_input_does_not_panic() {
// Build a [t, sc, tx, rx] array, then permute so subcarriers land in the
// last axis. The resulting owned Array4 has non-standard strides, so its
// last-axis lanes are non-contiguous in memory.
let base =
Array4::<f32>::from_shape_fn((4, 8, 3, 3), |(t, sc, tx, rx)| (t + sc + tx + rx) as f32);
// permuted_axes consumes the owned array and returns an owned Array4
// with swapped strides: logical shape [t, tx, rx, sc], sc axis strided.
let strided: Array4<f32> = base.permuted_axes([0, 2, 3, 1]);
// Sanity: a last-axis lane really is non-contiguous.
assert!(strided.slice(s![0, 0, 0, ..]).as_slice().is_none());
let out = interpolate_subcarriers(&strided, 4);
assert_eq!(out.shape(), &[4, 3, 3, 4]);
// Endpoints preserved exactly even via the fallback copy path.
for tx in 0..3 {
for rx in 0..3 {
let first = strided[[0, tx, rx, 0]];
let last = strided[[0, tx, rx, 7]];
assert_abs_diff_eq!(out[[0, tx, rx, 0]], first, epsilon = 1e-5);
assert_abs_diff_eq!(out[[0, tx, rx, 3]], last, epsilon = 1e-5);
}
}
}
#[test]
fn sparse_interpolation_identity() {
// For same source and target count, should return same array
let arr = Array4::<f32>::from_shape_fn((2, 1, 1, 20), |(_, _, _, k)| k as f32);
let out = interpolate_subcarriers_sparse(&arr, 20);
assert_eq!(out.shape(), &[2, 1, 1, 20]);
}
}