use scirs2_core::ndarray::Array1;
use scirs2_core::numeric::Float;
use std::fmt::Debug;
#[derive(Debug, Clone, PartialEq)]
pub struct LandscapeStatistics {
pub mean_gradient_norm: f64,
pub gradient_norm_cv: f64,
pub direction_consistency: f64,
pub reversal_rate: f64,
pub loss_slope: f64,
pub loss_roughness: f64,
pub loss_increase_rate: f64,
pub samples: usize,
}
impl LandscapeStatistics {
pub fn empty() -> Self {
Self {
mean_gradient_norm: 0.0,
gradient_norm_cv: 0.0,
direction_consistency: 0.5,
reversal_rate: 0.0,
loss_slope: 0.0,
loss_roughness: 0.0,
loss_increase_rate: 0.0,
samples: 0,
}
}
pub fn from_history<T>(gradient_history: &[Array1<T>], loss_history: &[T]) -> Self
where
T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static,
{
let mut stats = Self::empty();
stats.samples = gradient_history.len().max(loss_history.len());
let norms: Vec<f64> = gradient_history
.iter()
.map(|g| {
g.iter()
.map(|&x| {
let v = x.to_f64().unwrap_or(0.0);
v * v
})
.sum::<f64>()
.sqrt()
})
.collect();
if !norms.is_empty() {
let n = norms.len() as f64;
let mean = norms.iter().sum::<f64>() / n;
stats.mean_gradient_norm = mean;
if mean > f64::EPSILON {
let var = norms.iter().map(|&x| (x - mean) * (x - mean)).sum::<f64>() / n;
stats.gradient_norm_cv = (var.sqrt() / mean).min(1.0);
}
}
if gradient_history.len() >= 2 {
let mut cos_sum = 0.0;
let mut reversals = 0usize;
let mut pairs = 0usize;
for window in gradient_history.windows(2) {
let (a, b) = (&window[0], &window[1]);
let width = a.len().min(b.len());
if width == 0 {
continue;
}
let mut dot = 0.0;
let mut na = 0.0;
let mut nb = 0.0;
for i in 0..width {
let av = a[i].to_f64().unwrap_or(0.0);
let bv = b[i].to_f64().unwrap_or(0.0);
dot += av * bv;
na += av * av;
nb += bv * bv;
}
if na <= f64::EPSILON || nb <= f64::EPSILON {
continue;
}
let cos = (dot / (na.sqrt() * nb.sqrt())).clamp(-1.0, 1.0);
cos_sum += cos;
if cos < 0.0 {
reversals += 1;
}
pairs += 1;
}
if pairs > 0 {
let mean_cos = cos_sum / pairs as f64;
stats.direction_consistency = (mean_cos + 1.0) / 2.0;
stats.reversal_rate = reversals as f64 / pairs as f64;
}
}
let losses: Vec<f64> = loss_history
.iter()
.map(|&l| l.to_f64().unwrap_or(0.0))
.collect();
if losses.len() >= 2 {
let first = losses[0];
let last = losses[losses.len() - 1];
if first.abs() > f64::EPSILON {
stats.loss_slope = ((first - last) / first.abs()).clamp(-1.0, 1.0);
}
let increases = losses.windows(2).filter(|w| w[1] > w[0]).count();
stats.loss_increase_rate = increases as f64 / (losses.len() - 1) as f64;
}
if losses.len() >= 3 {
let first_diff_mag: f64 = losses.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f64>()
/ (losses.len() - 1) as f64;
let second_diff_mag: f64 = losses
.windows(3)
.map(|w| (w[2] - 2.0 * w[1] + w[0]).abs())
.sum::<f64>()
/ (losses.len() - 2) as f64;
if first_diff_mag > f64::EPSILON {
let ratio = second_diff_mag / first_diff_mag;
stats.loss_roughness = ratio / (1.0 + ratio);
}
}
stats
}
pub fn complexity(&self) -> f64 {
let terms = [
(0.35, self.loss_roughness),
(0.30, self.gradient_norm_cv),
(0.20, self.reversal_rate),
(0.15, 1.0 - self.direction_consistency),
];
terms
.iter()
.map(|(w, v)| w * v)
.sum::<f64>()
.clamp(0.0, 1.0)
}
pub fn difficulty(&self) -> f64 {
let stalled = (1.0 - self.loss_slope).clamp(0.0, 1.0);
let terms = [
(0.45, stalled),
(0.30, self.loss_increase_rate),
(0.25, self.reversal_rate),
];
terms
.iter()
.map(|(w, v)| w * v)
.sum::<f64>()
.clamp(0.0, 1.0)
}
pub fn analysis_confidence(&self, horizon: usize) -> f64 {
if self.samples == 0 {
return 0.0;
}
let target = horizon.max(2) as f64;
let coverage = (self.samples as f64 / target).min(1.0);
let noise = (self.gradient_norm_cv + self.loss_increase_rate) / 2.0;
(coverage * (1.0 - 0.5 * noise)).clamp(0.0, 1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::Array1;
fn grads(vectors: &[[f64; 2]]) -> Vec<Array1<f64>> {
vectors
.iter()
.map(|v| Array1::from_vec(v.to_vec()))
.collect()
}
#[test]
fn empty_history_has_zero_confidence() {
let stats = LandscapeStatistics::from_history::<f64>(&[], &[]);
assert_eq!(stats.samples, 0);
assert_eq!(stats.analysis_confidence(10), 0.0);
}
#[test]
fn consistent_descent_is_easy_and_simple() {
let g = grads(&[[1.0, 0.0], [0.9, 0.0], [0.81, 0.0], [0.73, 0.0]]);
let losses = vec![1.0, 0.5, 0.25, 0.125];
let stats = LandscapeStatistics::from_history(&g, &losses);
assert!(
stats.direction_consistency > 0.99,
"consistency {}",
stats.direction_consistency
);
assert_eq!(stats.reversal_rate, 0.0);
assert!(stats.loss_slope > 0.8, "slope {}", stats.loss_slope);
assert!(
stats.difficulty() < 0.2,
"difficulty {}",
stats.difficulty()
);
}
#[test]
fn oscillating_history_is_hard_and_complex() {
let g = grads(&[[1.0, 0.0], [-1.0, 0.0], [1.0, 0.0], [-1.0, 0.0]]);
let losses = vec![1.0, 2.0, 1.0, 2.0];
let stats = LandscapeStatistics::from_history(&g, &losses);
assert_eq!(stats.reversal_rate, 1.0);
assert!(
stats.direction_consistency < 0.01,
"consistency {}",
stats.direction_consistency
);
assert!(
stats.difficulty() > 0.5,
"difficulty {}",
stats.difficulty()
);
assert!(
stats.complexity() > 0.3,
"complexity {}",
stats.complexity()
);
}
#[test]
fn complexity_and_difficulty_separate_concerns() {
let g = grads(&[[1.0, 1.0], [1.0, 1.0], [1.0, 1.0], [1.0, 1.0]]);
let flat = vec![1.0, 1.0, 1.0, 1.0];
let stuck = LandscapeStatistics::from_history(&g, &flat);
assert!(
stuck.complexity() < 0.1,
"complexity {}",
stuck.complexity()
);
assert!(
stuck.difficulty() > 0.4,
"difficulty {}",
stuck.difficulty()
);
}
#[test]
fn different_histories_give_different_scores() {
let easy = LandscapeStatistics::from_history(
&grads(&[[1.0, 0.0], [0.5, 0.0], [0.25, 0.0]]),
&[1.0, 0.4, 0.1],
);
let hard = LandscapeStatistics::from_history(
&grads(&[[1.0, 0.0], [-2.0, 0.5], [3.0, -1.0]]),
&[1.0, 3.0, 0.5],
);
assert!(
(easy.complexity() - hard.complexity()).abs() > 1e-6,
"complexity collapsed to a constant"
);
assert!(
(easy.difficulty() - hard.difficulty()).abs() > 1e-6,
"difficulty collapsed to a constant"
);
}
#[test]
fn confidence_grows_with_samples() {
let short =
LandscapeStatistics::from_history(&grads(&[[1.0, 0.0], [1.0, 0.0]]), &[1.0, 0.5]);
let long = LandscapeStatistics::from_history(&grads(&[[1.0, 0.0]; 20]), &[1.0; 20]);
assert!(
long.analysis_confidence(20) > short.analysis_confidence(20),
"{} !> {}",
long.analysis_confidence(20),
short.analysis_confidence(20)
);
}
}