Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'

This commit is contained in:
ruv
2026-02-28 14:39:40 -05:00
7854 changed files with 3522914 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
[package]
name = "ruvector-coherence"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "Coherence measurement proxies for comparing attention mechanisms"
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
[features]
default = []
spectral = [] # Spectral coherence scoring for graph index health

View File

@@ -0,0 +1,244 @@
# ruvector-coherence
[![Crates.io](https://img.shields.io/crates/v/ruvector-coherence.svg)](https://crates.io/crates/ruvector-coherence)
[![docs.rs](https://docs.rs/ruvector-coherence/badge.svg)](https://docs.rs/ruvector-coherence)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
**Quantitative coherence metrics for comparing attention mechanisms — measure what gating costs and what it preserves.**
| Metric | What It Measures | Use Case |
|--------|-----------------|----------|
| `contradiction_rate` | Semantic inversion (negative dot product) | Detect gating failures |
| `entailment_consistency` | Adjacent-output alignment (cosine) | Detect erratic swings |
| `delta_behavior` | Direction + magnitude drift | Full coherence profile |
| `jaccard_similarity` | Mask overlap (intersection/union) | Compare sparsity patterns |
| `quality_check` | Cosine similarity pass/fail gate | CI/CD quality guardrail |
| `evaluate_batch` | Aggregate stats with 95% CI | Statistical significance |
## Overview
When replacing softmax attention with a gated alternative (such as min-cut
gating), the central question is: **does the output stay coherent?** This crate
provides a suite of metrics, comparison utilities, quality guardrails, and
batched evaluation tools to answer that question quantitatively.
"Coherence" here means the degree to which gated attention outputs preserve the
semantic and structural properties of baseline softmax outputs. The crate
measures this through vector similarity, contradiction detection, mask overlap
analysis, and statistical aggregation with confidence intervals.
## Modules
| Module | Purpose |
|--------|---------|
| `metrics` | `contradiction_rate`, `entailment_consistency`, `delta_behavior` |
| `comparison` | `compare_attention_masks`, `edge_flip_count`, `jaccard_similarity` |
| `quality` | `quality_check` with `cosine_similarity` and `l2_distance` |
| `batch` | `evaluate_batch` with mean, std, 95% CI, and pass rate |
## Metrics Explained
### contradiction_rate
Measures the fraction of output pairs where the dot product between prediction
and reference vectors is negative. A high contradiction rate signals that gating
has inverted the semantic direction of outputs.
```rust
use ruvector_coherence::contradiction_rate;
let predictions = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
let references = vec![vec![1.0, 1.0], vec![-1.0, -1.0]];
let rate = contradiction_rate(&predictions, &references);
// rate = 0.5 (second pair contradicts)
```
### entailment_consistency
Computes mean pairwise cosine similarity between consecutive output vectors.
High values (close to 1.0) indicate that adjacent outputs remain aligned --
useful for detecting whether gating introduces erratic token-to-token swings.
```rust
use ruvector_coherence::entailment_consistency;
let outputs = vec![vec![1.0, 0.0], vec![0.9, 0.1], vec![0.8, 0.2]];
let consistency = entailment_consistency(&outputs);
// consistency close to 1.0 (outputs smoothly evolve)
```
### delta_behavior (DeltaMetric)
Compares baseline and gated attention outputs element-by-element, returning:
| Field | Meaning |
|-------|---------|
| `coherence_delta` | Cosine similarity minus 1.0 (0.0 = identical direction) |
| `decision_flips` | Count of sign disagreements between baseline and gated values |
| `path_length_change` | Relative change in L2 norm (magnitude drift) |
```rust
use ruvector_coherence::delta_behavior;
let baseline = vec![1.0, 2.0, 3.0];
let gated = vec![1.1, 1.9, 3.1];
let delta = delta_behavior(&baseline, &gated);
println!("Coherence delta: {:.6}", delta.coherence_delta);
println!("Decision flips: {}", delta.decision_flips);
println!("Path change: {:.6}", delta.path_length_change);
```
## Mask Comparison
### compare_attention_masks (ComparisonResult)
Provides a full comparison between two boolean attention masks:
| Field | Meaning |
|-------|---------|
| `jaccard` | Jaccard similarity (intersection / union) |
| `edge_flips` | Number of positions where masks disagree |
| `baseline_edges` | Count of `true` entries in baseline mask |
| `gated_edges` | Count of `true` entries in gated mask |
| `sparsity_ratio` | Ratio of gated sparsity to baseline sparsity |
```rust
use ruvector_coherence::compare_attention_masks;
let baseline = vec![true, true, false, false, true];
let gated = vec![true, false, false, true, true];
let cmp = compare_attention_masks(&baseline, &gated);
println!("Jaccard: {:.3}", cmp.jaccard); // 0.500
println!("Edge flips: {}", cmp.edge_flips); // 2
println!("Sparsity ratio: {:.3}", cmp.sparsity_ratio);
```
Standalone helpers `jaccard_similarity` and `edge_flip_count` are also available
for use outside of the full comparison struct.
## Quality Guardrails
### quality_check (QualityResult)
A pass/fail gate that checks whether gated output stays close enough to
baseline output. The check passes when cosine similarity meets or exceeds
a configurable threshold.
```rust
use ruvector_coherence::quality_check;
let baseline_out = vec![1.0, 2.0, 3.0];
let gated_out = vec![1.1, 2.1, 3.1];
let result = quality_check(&baseline_out, &gated_out, 0.99);
println!("Cosine sim: {:.4}", result.cosine_sim);
println!("L2 distance: {:.4}", result.l2_dist);
println!("Passes: {}", result.passes_threshold);
```
## Batch Evaluation
### evaluate_batch (BatchResult)
Runs `delta_behavior` and `quality_check` across an array of sample pairs,
aggregating results with standard statistics.
| Field | Meaning |
|-------|---------|
| `mean_coherence_delta` | Average coherence delta across samples |
| `std_coherence_delta` | Standard deviation |
| `ci_95_lower` / `ci_95_upper` | 95% confidence interval (z = 1.96) |
| `n_samples` | Number of evaluated pairs |
| `pass_rate` | Fraction of samples passing the quality threshold |
```rust
use ruvector_coherence::evaluate_batch;
let baselines = vec![vec![1.0, 2.0, 3.0]; 100];
let gated = vec![vec![1.05, 1.95, 3.05]; 100];
let batch = evaluate_batch(&baselines, &gated, 0.99);
println!("Samples: {}", batch.n_samples);
println!("Mean delta: {:.6}", batch.mean_coherence_delta);
println!("95% CI: [{:.6}, {:.6}]", batch.ci_95_lower, batch.ci_95_upper);
println!("Pass rate: {:.1}%", batch.pass_rate * 100.0);
```
## Typical Workflow
```text
1. Run attn_softmax() --> baseline outputs
2. Run attn_mincut() --> gated outputs + keep_mask
3. quality_check() --> per-sample pass/fail
4. compare_attention_masks() --> mask overlap analysis
5. evaluate_batch() --> aggregate stats with 95% CI
6. Export via ruvector-profiler CSV emitters
```
<details>
<summary><strong>Tutorial: Full Coherence Evaluation Pipeline</strong></summary>
### Step 1: Run baseline and gated attention
```rust
use ruvector_attn_mincut::{attn_softmax, attn_mincut};
let (seq_len, d) = (32, 64);
let q = vec![0.1f32; seq_len * d];
let k = vec![0.1f32; seq_len * d];
let v = vec![1.0f32; seq_len * d];
let baseline = attn_softmax(&q, &k, &v, d, seq_len);
let gated = attn_mincut(&q, &k, &v, d, seq_len, 0.5, 2, 0.01);
```
### Step 2: Individual metrics
```rust
use ruvector_coherence::*;
let delta = delta_behavior(&baseline.output, &gated.output);
println!("Coherence delta: {:.6}", delta.coherence_delta);
println!("Decision flips: {}", delta.decision_flips);
let quality = quality_check(&baseline.output, &gated.output, 0.99);
println!("Passes: {} (cosine={:.4})", quality.passes_threshold, quality.cosine_sim);
```
### Step 3: Batch evaluation with confidence intervals
```rust
let baselines = vec![baseline.output.clone(); 100];
let gateds = vec![gated.output.clone(); 100];
let batch = evaluate_batch(&baselines, &gateds, 0.99);
println!("Mean delta: {:.6} +/- {:.6}", batch.mean_coherence_delta, batch.std_coherence_delta);
println!("95% CI: [{:.6}, {:.6}]", batch.ci_95_lower, batch.ci_95_upper);
println!("Pass rate: {:.1}%", batch.pass_rate * 100.0);
```
### Step 4: Success criteria
| Criterion | Threshold | Check |
|-----------|-----------|-------|
| Coherence delta | < 5% | `batch.mean_coherence_delta < 0.05` |
| Accuracy loss | < 1% | `batch.pass_rate > 0.99` |
| Contradiction rate | < 0.1% | `contradiction_rate(...) < 0.001` |
</details>
## Related Crates
| Crate | Role |
|-------|------|
| [`ruvector-attn-mincut`](../ruvector-attn-mincut/README.md) | Provides gated attention operators |
| [`ruvector-profiler`](../ruvector-profiler/README.md) | Exports results to CSV for analysis |
| [`ruvector-solver`](../ruvector-solver/README.md) | Sublinear solvers for graph analytics |
## License
Licensed under the [MIT License](../../LICENSE).

View File

@@ -0,0 +1,124 @@
//! Batched evaluation over multiple samples.
use serde::{Deserialize, Serialize};
use crate::metrics::delta_behavior;
use crate::quality::quality_check;
/// Aggregated results from evaluating a batch of baseline/gated output pairs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchResult {
pub mean_coherence_delta: f64,
pub std_coherence_delta: f64,
pub ci_95_lower: f64,
pub ci_95_upper: f64,
pub n_samples: usize,
pub pass_rate: f64,
}
/// Evaluates a batch of output pairs, producing mean/std/CI for coherence delta and pass rate.
pub fn evaluate_batch(
baseline_outputs: &[Vec<f32>],
gated_outputs: &[Vec<f32>],
threshold: f64,
) -> BatchResult {
let n = baseline_outputs.len().min(gated_outputs.len());
if n == 0 {
return BatchResult {
mean_coherence_delta: 0.0,
std_coherence_delta: 0.0,
ci_95_lower: 0.0,
ci_95_upper: 0.0,
n_samples: 0,
pass_rate: 0.0,
};
}
let mut deltas = Vec::with_capacity(n);
let mut passes = 0usize;
for i in 0..n {
deltas.push(delta_behavior(&baseline_outputs[i], &gated_outputs[i]).coherence_delta);
if quality_check(&baseline_outputs[i], &gated_outputs[i], threshold).passes_threshold {
passes += 1;
}
}
let mean = deltas.iter().sum::<f64>() / n as f64;
let var = if n > 1 {
deltas.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / (n - 1) as f64
} else {
0.0
};
let std_dev = var.sqrt();
let margin = 1.96 * std_dev / (n as f64).sqrt();
BatchResult {
mean_coherence_delta: mean,
std_coherence_delta: std_dev,
ci_95_lower: mean - margin,
ci_95_upper: mean + margin,
n_samples: n,
pass_rate: passes as f64 / n as f64,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batch_empty() {
let r = evaluate_batch(&[], &[], 0.9);
assert_eq!(r.n_samples, 0);
}
#[test]
fn batch_identical() {
let bl = vec![vec![1.0, 2.0, 3.0]; 10];
let r = evaluate_batch(&bl, &bl.clone(), 0.9);
assert_eq!(r.n_samples, 10);
assert!(r.mean_coherence_delta.abs() < 1e-10);
assert!((r.pass_rate - 1.0).abs() < 1e-10);
}
#[test]
fn batch_ci_contains_mean() {
let bl = vec![
vec![1.0, 0.0],
vec![0.0, 1.0],
vec![1.0, 1.0],
vec![2.0, 3.0],
];
let gt = vec![
vec![1.1, 0.1],
vec![0.1, 1.1],
vec![1.2, 0.9],
vec![2.1, 2.9],
];
let r = evaluate_batch(&bl, &gt, 0.9);
assert!(r.ci_95_lower <= r.mean_coherence_delta);
assert!(r.ci_95_upper >= r.mean_coherence_delta);
}
#[test]
fn batch_pass_rate_partial() {
let bl = vec![vec![1.0, 0.0], vec![1.0, 0.0]];
let gt = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let r = evaluate_batch(&bl, &gt, 0.5);
assert!((r.pass_rate - 0.5).abs() < 1e-10);
}
#[test]
fn batch_result_serializable() {
let r = BatchResult {
mean_coherence_delta: -0.05,
std_coherence_delta: 0.02,
ci_95_lower: -0.07,
ci_95_upper: -0.03,
n_samples: 100,
pass_rate: 0.95,
};
let d: BatchResult = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
assert_eq!(d.n_samples, 100);
}
}

View File

@@ -0,0 +1,120 @@
//! Side-by-side comparison utilities for attention masks.
use serde::{Deserialize, Serialize};
/// Result of comparing two attention masks.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComparisonResult {
pub jaccard: f64,
pub edge_flips: usize,
pub baseline_edges: usize,
pub gated_edges: usize,
pub sparsity_ratio: f64,
}
/// Jaccard similarity: `|A & B| / |A | B|`. Returns `1.0` for two empty masks.
pub fn jaccard_similarity(mask_a: &[bool], mask_b: &[bool]) -> f64 {
let n = mask_a.len().min(mask_b.len());
let (mut inter, mut union) = (0usize, 0usize);
for i in 0..n {
if mask_a[i] || mask_b[i] {
union += 1;
}
if mask_a[i] && mask_b[i] {
inter += 1;
}
}
union += count_true_tail(mask_a, n) + count_true_tail(mask_b, n);
if union == 0 {
1.0
} else {
inter as f64 / union as f64
}
}
/// Counts positions where the two masks disagree.
pub fn edge_flip_count(mask_a: &[bool], mask_b: &[bool]) -> usize {
let n = mask_a.len().min(mask_b.len());
let mut flips = (0..n).filter(|&i| mask_a[i] != mask_b[i]).count();
flips += count_true_tail(mask_a, n) + count_true_tail(mask_b, n);
flips
}
/// Full comparison of two attention masks.
pub fn compare_attention_masks(baseline: &[bool], gated: &[bool]) -> ComparisonResult {
let baseline_edges = baseline.iter().filter(|&&v| v).count();
let gated_edges = gated.iter().filter(|&&v| v).count();
let total = baseline.len().max(gated.len());
let bl_sp = if total > 0 {
1.0 - baseline_edges as f64 / total as f64
} else {
1.0
};
let gt_sp = if total > 0 {
1.0 - gated_edges as f64 / total as f64
} else {
1.0
};
ComparisonResult {
jaccard: jaccard_similarity(baseline, gated),
edge_flips: edge_flip_count(baseline, gated),
baseline_edges,
gated_edges,
sparsity_ratio: if bl_sp > f64::EPSILON {
gt_sp / bl_sp
} else {
gt_sp
},
}
}
fn count_true_tail(mask: &[bool], from: usize) -> usize {
if mask.len() > from {
mask[from..].iter().filter(|&&v| v).count()
} else {
0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jaccard_cases() {
let m = vec![true, false, true, true];
assert!((jaccard_similarity(&m, &m) - 1.0).abs() < 1e-10);
assert!(jaccard_similarity(&[true, false], &[false, true]).abs() < 1e-10);
assert_eq!(jaccard_similarity(&[], &[]), 1.0);
// partial: intersection=1, union=3
let (a, b) = (
vec![true, true, false, false],
vec![true, false, true, false],
);
assert!((jaccard_similarity(&a, &b) - 1.0 / 3.0).abs() < 1e-10);
}
#[test]
fn edge_flip_cases() {
assert_eq!(edge_flip_count(&[true, false], &[true, false]), 0);
assert_eq!(
edge_flip_count(&[true, false, true], &[false, true, false]),
3
);
assert_eq!(
edge_flip_count(&[true, false], &[true, false, true, true]),
2
);
}
#[test]
fn compare_masks() {
let bl = vec![true, true, false, false, true];
let gt = vec![true, false, false, true, true];
let r = compare_attention_masks(&bl, &gt);
assert_eq!(r.baseline_edges, 3);
assert_eq!(r.gated_edges, 3);
assert_eq!(r.edge_flips, 2);
assert!((r.jaccard - 0.5).abs() < 1e-10);
}
}

View File

@@ -0,0 +1,27 @@
//! Coherence measurement proxies for comparing attention mechanisms.
//!
//! This crate provides metrics, comparison utilities, quality guardrails,
//! and batched evaluation tools for measuring how different attention
//! mechanisms (e.g., baseline vs. gated) affect output coherence.
pub mod batch;
pub mod comparison;
pub mod metrics;
pub mod quality;
#[cfg(feature = "spectral")]
pub mod spectral;
pub use batch::{evaluate_batch, BatchResult};
pub use comparison::{
compare_attention_masks, edge_flip_count, jaccard_similarity, ComparisonResult,
};
pub use metrics::{contradiction_rate, delta_behavior, entailment_consistency, DeltaMetric};
pub use quality::{cosine_similarity, l2_distance, quality_check, QualityResult};
#[cfg(feature = "spectral")]
pub use spectral::{
compute_degree_regularity, estimate_effective_resistance_sampled, estimate_fiedler,
estimate_largest_eigenvalue, estimate_spectral_gap, CsrMatrixView, HealthAlert,
HnswHealthMonitor, SpectralCoherenceScore, SpectralConfig, SpectralTracker,
};

View File

@@ -0,0 +1,129 @@
//! Core coherence metrics for attention mechanism evaluation.
use serde::{Deserialize, Serialize};
/// Result of comparing baseline vs. gated attention outputs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeltaMetric {
pub coherence_delta: f64,
pub decision_flips: usize,
pub path_length_change: f64,
}
/// Measures the rate of contradictory outputs (negative dot product) between pairs.
pub fn contradiction_rate(predictions: &[Vec<f32>], references: &[Vec<f32>]) -> f64 {
if predictions.is_empty() || references.is_empty() {
return 0.0;
}
let n = predictions.len().min(references.len());
let contradictions = predictions[..n]
.iter()
.zip(&references[..n])
.filter(|(p, r)| {
p.iter()
.zip(r.iter())
.map(|(a, b)| *a as f64 * *b as f64)
.sum::<f64>()
< 0.0
})
.count();
contradictions as f64 / n as f64
}
/// Mean pairwise cosine similarity between consecutive output vectors.
pub fn entailment_consistency(outputs: &[Vec<f32>]) -> f64 {
if outputs.len() < 2 {
return 1.0;
}
let pairs = outputs.len() - 1;
let total: f64 = (0..pairs)
.map(|i| cosine(&outputs[i], &outputs[i + 1]))
.sum();
total / pairs as f64
}
/// Computes the behavioral delta between baseline and gated attention outputs.
pub fn delta_behavior(baseline_outputs: &[f32], gated_outputs: &[f32]) -> DeltaMetric {
let n = baseline_outputs.len().min(gated_outputs.len());
if n == 0 {
return DeltaMetric {
coherence_delta: 0.0,
decision_flips: 0,
path_length_change: 0.0,
};
}
let (bl, gl) = (&baseline_outputs[..n], &gated_outputs[..n]);
let coherence_delta = cosine(bl, gl) - 1.0;
let decision_flips = bl
.iter()
.zip(gl)
.filter(|(b, g)| b.is_sign_positive() != g.is_sign_positive())
.count();
let bn = l2_norm(bl);
let path_length_change = if bn > f64::EPSILON {
l2_norm(gl) / bn - 1.0
} else {
0.0
};
DeltaMetric {
coherence_delta,
decision_flips,
path_length_change,
}
}
fn cosine(a: &[f32], b: &[f32]) -> f64 {
let dot: f64 = a.iter().zip(b).map(|(x, y)| *x as f64 * *y as f64).sum();
let denom = l2_norm(a) * l2_norm(b);
if denom < f64::EPSILON {
0.0
} else {
dot / denom
}
}
fn l2_norm(v: &[f32]) -> f64 {
v.iter().map(|x| (*x as f64).powi(2)).sum::<f64>().sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contradiction_rate_boundaries() {
let preds = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
assert_eq!(
contradiction_rate(&preds, &[vec![1.0, 1.0], vec![1.0, 1.0]]),
0.0
);
assert_eq!(
contradiction_rate(&preds, &[vec![-1.0, -1.0], vec![-1.0, -1.0]]),
1.0
);
assert_eq!(contradiction_rate(&[], &[]), 0.0);
}
#[test]
fn entailment_consistency_cases() {
let identical = vec![vec![1.0, 0.0]; 3];
assert!((entailment_consistency(&identical) - 1.0).abs() < 1e-10);
assert_eq!(entailment_consistency(&[vec![1.0]]), 1.0);
let ortho = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
assert!(entailment_consistency(&ortho).abs() < 1e-10);
}
#[test]
fn delta_behavior_cases() {
let v = vec![1.0, 2.0, 3.0];
let d = delta_behavior(&v, &v);
assert!(d.coherence_delta.abs() < 1e-10);
assert_eq!(d.decision_flips, 0);
let d2 = delta_behavior(&[1.0, -1.0, 1.0], &[-1.0, 1.0, 1.0]);
assert_eq!(d2.decision_flips, 2);
let d3 = delta_behavior(&[], &[]);
assert_eq!(d3.decision_flips, 0);
}
}

View File

@@ -0,0 +1,101 @@
//! Quality guardrails for attention mechanism output comparison.
use serde::{Deserialize, Serialize};
/// Result of a quality check comparing baseline and gated outputs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QualityResult {
pub cosine_sim: f64,
pub l2_dist: f64,
pub passes_threshold: bool,
}
/// Cosine similarity between two vectors. Returns `0.0` for zero-magnitude inputs.
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f64 {
let n = a.len().min(b.len());
let (mut dot, mut na, mut nb) = (0.0_f64, 0.0_f64, 0.0_f64);
for i in 0..n {
let (ai, bi) = (a[i] as f64, b[i] as f64);
dot += ai * bi;
na += ai * ai;
nb += bi * bi;
}
let denom = na.sqrt() * nb.sqrt();
if denom < f64::EPSILON {
0.0
} else {
dot / denom
}
}
/// Euclidean (L2) distance between two vectors.
pub fn l2_distance(a: &[f32], b: &[f32]) -> f64 {
let n = a.len().min(b.len());
let mut s = 0.0_f64;
for i in 0..n {
let d = a[i] as f64 - b[i] as f64;
s += d * d;
}
if a.len() > n {
s += a[n..].iter().map(|v| (*v as f64).powi(2)).sum::<f64>();
}
if b.len() > n {
s += b[n..].iter().map(|v| (*v as f64).powi(2)).sum::<f64>();
}
s.sqrt()
}
/// Quality gate: passes when `cosine_similarity >= threshold`.
pub fn quality_check(
baseline_output: &[f32],
gated_output: &[f32],
threshold: f64,
) -> QualityResult {
let cosine_sim = cosine_similarity(baseline_output, gated_output);
let l2_dist = l2_distance(baseline_output, gated_output);
QualityResult {
cosine_sim,
l2_dist,
passes_threshold: cosine_sim >= threshold,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cosine_cases() {
assert!((cosine_similarity(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]) - 1.0).abs() < 1e-10);
assert!((cosine_similarity(&[1.0, 0.0], &[-1.0, 0.0]) + 1.0).abs() < 1e-10);
assert!(cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]).abs() < 1e-10);
assert_eq!(cosine_similarity(&[0.0, 0.0], &[1.0, 2.0]), 0.0);
}
#[test]
fn l2_cases() {
assert!(l2_distance(&[1.0, 2.0], &[1.0, 2.0]) < 1e-10);
assert!((l2_distance(&[0.0, 0.0], &[3.0, 4.0]) - 5.0).abs() < 1e-10);
assert!((l2_distance(&[1.0], &[1.0, 3.0]) - 3.0).abs() < 1e-10);
}
#[test]
fn quality_check_pass_and_fail() {
let r = quality_check(&[1.0, 2.0, 3.0], &[1.1, 2.1, 3.1], 0.99);
assert!(r.passes_threshold);
let r2 = quality_check(&[1.0, 0.0], &[0.0, 1.0], 0.5);
assert!(!r2.passes_threshold);
}
#[test]
fn quality_result_serializable() {
let r = QualityResult {
cosine_sim: 0.95,
l2_dist: 0.32,
passes_threshold: true,
};
let j = serde_json::to_string(&r).unwrap();
let d: QualityResult = serde_json::from_str(&j).unwrap();
assert!((d.cosine_sim - 0.95).abs() < 1e-10);
}
}

View File

@@ -0,0 +1,662 @@
//! Spectral Coherence Score for graph index health monitoring.
//!
//! Provides a composite metric measuring structural health of graph indices
//! using spectral graph theory properties. Self-contained, no external solver deps.
use serde::{Deserialize, Serialize};
/// Compressed Sparse Row matrix for Laplacian representation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CsrMatrixView {
pub row_ptr: Vec<usize>,
pub col_indices: Vec<usize>,
pub values: Vec<f64>,
pub rows: usize,
pub cols: usize,
}
impl CsrMatrixView {
pub fn new(
row_ptr: Vec<usize>,
col_indices: Vec<usize>,
values: Vec<f64>,
rows: usize,
cols: usize,
) -> Self {
Self {
row_ptr,
col_indices,
values,
rows,
cols,
}
}
/// Build a symmetric adjacency CSR matrix from edges `(u, v, weight)`.
pub fn from_edges(n: usize, edges: &[(usize, usize, f64)]) -> Self {
let mut entries: Vec<(usize, usize, f64)> = Vec::with_capacity(edges.len() * 2);
for &(u, v, w) in edges {
entries.push((u, v, w));
if u != v {
entries.push((v, u, w));
}
}
entries.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
Self::from_sorted_entries(n, &entries)
}
/// Sparse matrix-vector product: y = A * x.
pub fn spmv(&self, x: &[f64]) -> Vec<f64> {
let mut y = vec![0.0; self.rows];
for i in 0..self.rows {
let (start, end) = (self.row_ptr[i], self.row_ptr[i + 1]);
y[i] = (start..end)
.map(|j| self.values[j] * x[self.col_indices[j]])
.sum();
}
y
}
/// Build the graph Laplacian L = D - A from edges.
pub fn build_laplacian(n: usize, edges: &[(usize, usize, f64)]) -> Self {
let mut degree = vec![0.0_f64; n];
let mut entries: Vec<(usize, usize, f64)> = Vec::with_capacity(edges.len() * 2 + n);
for &(u, v, w) in edges {
degree[u] += w;
if u != v {
degree[v] += w;
entries.push((u, v, -w));
entries.push((v, u, -w));
}
}
for i in 0..n {
entries.push((i, i, degree[i]));
}
entries.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
Self::from_sorted_entries(n, &entries)
}
fn from_sorted_entries(n: usize, entries: &[(usize, usize, f64)]) -> Self {
let mut row_ptr = vec![0usize; n + 1];
let mut col_indices = Vec::with_capacity(entries.len());
let mut values = Vec::with_capacity(entries.len());
for &(r, c, v) in entries {
row_ptr[r + 1] += 1;
col_indices.push(c);
values.push(v);
}
for i in 0..n {
row_ptr[i + 1] += row_ptr[i];
}
Self {
row_ptr,
col_indices,
values,
rows: n,
cols: n,
}
}
}
/// Configuration for spectral coherence computation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpectralConfig {
pub alpha: f64, // Fiedler weight (default 0.3)
pub beta: f64, // Spectral gap weight (default 0.3)
pub gamma: f64, // Effective resistance weight (default 0.2)
pub delta: f64, // Degree regularity weight (default 0.2)
pub max_iterations: usize, // Power iteration max (default 50)
pub tolerance: f64, // Convergence tolerance (default 1e-6)
pub refresh_threshold: usize, // Updates before full recompute (default 100)
}
impl Default for SpectralConfig {
fn default() -> Self {
Self {
alpha: 0.3,
beta: 0.3,
gamma: 0.2,
delta: 0.2,
max_iterations: 50,
tolerance: 1e-6,
refresh_threshold: 100,
}
}
}
/// Composite spectral coherence score with individual components.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpectralCoherenceScore {
pub fiedler: f64, // Normalized Fiedler value [0,1]
pub spectral_gap: f64, // Spectral gap ratio [0,1]
pub effective_resistance: f64, // Effective resistance score [0,1]
pub degree_regularity: f64, // Degree regularity score [0,1]
pub composite: f64, // Weighted composite SCS [0,1]
}
// --- Internal helpers ---
fn dot(a: &[f64], b: &[f64]) -> f64 {
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
fn norm(v: &[f64]) -> f64 {
dot(v, v).sqrt()
}
/// CG solve for L*x = b with null-space deflation (L is graph Laplacian).
fn cg_solve(lap: &CsrMatrixView, b: &[f64], max_iter: usize, tol: f64) -> Vec<f64> {
let n = lap.rows;
let inv_n = 1.0 / n as f64;
let b_mean: f64 = b.iter().sum::<f64>() * inv_n;
let b_def: Vec<f64> = b.iter().map(|v| v - b_mean).collect();
let mut x = vec![0.0; n];
let mut r = b_def.clone();
let mut p = r.clone();
let mut rs_old = dot(&r, &r);
if rs_old < tol * tol {
return x;
}
for _ in 0..max_iter {
let mut ap = lap.spmv(&p);
let ap_mean: f64 = ap.iter().sum::<f64>() * inv_n;
ap.iter_mut().for_each(|v| *v -= ap_mean);
let pap = dot(&p, &ap);
if pap.abs() < 1e-30 {
break;
}
let alpha = rs_old / pap;
for i in 0..n {
x[i] += alpha * p[i];
r[i] -= alpha * ap[i];
}
let rs_new = dot(&r, &r);
if rs_new.sqrt() < tol {
break;
}
let beta = rs_new / rs_old;
for i in 0..n {
p[i] = r[i] + beta * p[i];
}
rs_old = rs_new;
}
x
}
/// Deflate vector: remove component along all-ones, then normalize.
fn deflate_and_normalize(v: &mut Vec<f64>) {
let n = v.len();
let inv_sqrt_n = 1.0 / (n as f64).sqrt();
let proj: f64 = v.iter().sum::<f64>() * inv_sqrt_n;
v.iter_mut().for_each(|x| *x -= proj * inv_sqrt_n);
let n2 = norm(v);
if n2 > 1e-30 {
v.iter_mut().for_each(|x| *x /= n2);
}
}
/// Estimate the Fiedler value (second smallest eigenvalue) and eigenvector
/// using inverse iteration with null-space deflation.
pub fn estimate_fiedler(lap: &CsrMatrixView, max_iter: usize, tol: f64) -> (f64, Vec<f64>) {
let n = lap.rows;
if n <= 1 {
return (0.0, vec![0.0; n]);
}
// Initial vector orthogonal to all-ones.
let mut v: Vec<f64> = (0..n).map(|i| i as f64 - (n as f64 - 1.0) / 2.0).collect();
deflate_and_normalize(&mut v);
let mut eigenvalue = 0.0;
// Use fewer outer iterations (convergence is typically fast for inverse iteration)
let outer = max_iter.min(8);
// Inner CG iterations: enough for approximate solve
let inner = max_iter.min(15);
for _ in 0..outer {
let mut w = cg_solve(lap, &v, inner, tol * 0.1);
deflate_and_normalize(&mut w);
if norm(&w) < 1e-30 {
break;
}
let lv = lap.spmv(&w);
eigenvalue = dot(&w, &lv);
let residual: f64 = lv
.iter()
.zip(w.iter())
.map(|(li, wi)| (li - eigenvalue * wi).powi(2))
.sum::<f64>()
.sqrt();
v = w;
if residual < tol {
break;
}
}
(eigenvalue.max(0.0), v)
}
/// Estimate the largest eigenvalue of the Laplacian via power iteration.
pub fn estimate_largest_eigenvalue(lap: &CsrMatrixView, max_iter: usize) -> f64 {
let n = lap.rows;
if n == 0 {
return 0.0;
}
let mut v = vec![1.0 / (n as f64).sqrt(); n];
let mut ev = 0.0;
// Power iteration converges fast for the largest eigenvalue
let iters = max_iter.min(10);
for _ in 0..iters {
let w = lap.spmv(&v);
let wn = norm(&w);
if wn < 1e-30 {
return 0.0;
}
ev = dot(&v, &w);
v.iter_mut()
.zip(w.iter())
.for_each(|(vi, wi)| *vi = wi / wn);
}
ev.max(0.0)
}
/// Spectral gap ratio: fiedler / largest eigenvalue.
pub fn estimate_spectral_gap(fiedler: f64, largest: f64) -> f64 {
if largest < 1e-30 {
0.0
} else {
(fiedler / largest).clamp(0.0, 1.0)
}
}
/// Degree regularity: 1 - (std_dev / mean) of vertex degrees. 1.0 = perfectly regular.
pub fn compute_degree_regularity(lap: &CsrMatrixView) -> f64 {
let n = lap.rows;
if n == 0 {
return 1.0;
}
let degrees: Vec<f64> = (0..n)
.map(|i| {
let (s, e) = (lap.row_ptr[i], lap.row_ptr[i + 1]);
(s..e)
.find(|&j| lap.col_indices[j] == i)
.map_or(0.0, |j| lap.values[j])
})
.collect();
let mean = degrees.iter().sum::<f64>() / n as f64;
if mean < 1e-30 {
return 1.0;
}
let std = (degrees.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / n as f64).sqrt();
(1.0 - std / mean).clamp(0.0, 1.0)
}
/// Estimate average effective resistance by deterministic sampling of vertex pairs.
pub fn estimate_effective_resistance_sampled(lap: &CsrMatrixView, n_samples: usize) -> f64 {
let n = lap.rows;
if n < 2 {
return 0.0;
}
let total_pairs = n * (n - 1) / 2;
let step = if total_pairs <= n_samples {
1
} else {
total_pairs / n_samples
};
let max_s = n_samples.min(total_pairs);
// Fewer CG iterations for resistance estimation (approximate is fine)
let cg_iters = 10;
let (mut total, mut sampled, mut idx) = (0.0, 0usize, 0usize);
'outer: for u in 0..n {
for v in (u + 1)..n {
if idx % step == 0 {
let mut rhs = vec![0.0; n];
rhs[u] = 1.0;
rhs[v] = -1.0;
let x = cg_solve(lap, &rhs, cg_iters, 1e-6);
total += (x[u] - x[v]).abs();
sampled += 1;
if sampled >= max_s {
break 'outer;
}
}
idx += 1;
}
}
if sampled == 0 {
0.0
} else {
total / sampled as f64
}
}
/// Tracks spectral coherence incrementally, recomputing fully when needed.
pub struct SpectralTracker {
config: SpectralConfig,
fiedler_estimate: f64,
gap_estimate: f64,
resistance_estimate: f64,
regularity: f64,
updates_since_refresh: usize,
fiedler_vector: Option<Vec<f64>>,
}
impl SpectralTracker {
pub fn new(config: SpectralConfig) -> Self {
Self {
config,
fiedler_estimate: 0.0,
gap_estimate: 0.0,
resistance_estimate: 0.0,
regularity: 1.0,
updates_since_refresh: 0,
fiedler_vector: None,
}
}
/// Full spectral computation from a Laplacian.
pub fn compute(&mut self, lap: &CsrMatrixView) -> SpectralCoherenceScore {
self.full_recompute(lap);
self.build_score()
}
/// Incremental update using first-order perturbation: delta_lambda ~= v^T(delta_L)v.
pub fn update_edge(&mut self, lap: &CsrMatrixView, u: usize, v: usize, weight_delta: f64) {
self.updates_since_refresh += 1;
if self.needs_refresh() || self.fiedler_vector.is_none() {
self.full_recompute(lap);
return;
}
if let Some(ref fv) = self.fiedler_vector {
if u < fv.len() && v < fv.len() {
let diff = fv[u] - fv[v];
self.fiedler_estimate =
(self.fiedler_estimate + weight_delta * diff * diff).max(0.0);
let largest = estimate_largest_eigenvalue(lap, self.config.max_iterations);
self.gap_estimate = estimate_spectral_gap(self.fiedler_estimate, largest);
}
}
self.regularity = compute_degree_regularity(lap);
}
pub fn score(&self) -> f64 {
self.build_score().composite
}
pub fn full_recompute(&mut self, lap: &CsrMatrixView) {
let (fiedler_raw, fv) =
estimate_fiedler(lap, self.config.max_iterations, self.config.tolerance);
let largest = estimate_largest_eigenvalue(lap, self.config.max_iterations);
let n = lap.rows;
self.fiedler_estimate = if n > 0 {
(fiedler_raw / n as f64).clamp(0.0, 1.0)
} else {
0.0
};
self.gap_estimate = estimate_spectral_gap(fiedler_raw, largest);
let r_raw = estimate_effective_resistance_sampled(lap, 3.min(n * (n - 1) / 2));
self.resistance_estimate = 1.0 / (1.0 + r_raw);
self.regularity = compute_degree_regularity(lap);
self.fiedler_vector = Some(fv);
self.updates_since_refresh = 0;
}
pub fn needs_refresh(&self) -> bool {
self.updates_since_refresh >= self.config.refresh_threshold
}
fn build_score(&self) -> SpectralCoherenceScore {
let c = self.config.alpha * self.fiedler_estimate
+ self.config.beta * self.gap_estimate
+ self.config.gamma * self.resistance_estimate
+ self.config.delta * self.regularity;
SpectralCoherenceScore {
fiedler: self.fiedler_estimate,
spectral_gap: self.gap_estimate,
effective_resistance: self.resistance_estimate,
degree_regularity: self.regularity,
composite: c.clamp(0.0, 1.0),
}
}
}
/// Alert types for graph index health degradation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum HealthAlert {
FragileIndex { fiedler: f64 },
PoorExpansion { gap: f64 },
HighResistance { resistance: f64 },
LowCoherence { scs: f64 },
RebuildRecommended { reason: String },
}
/// Health monitor for HNSW graph indices using spectral coherence.
pub struct HnswHealthMonitor {
tracker: SpectralTracker,
min_fiedler: f64,
min_spectral_gap: f64,
max_resistance: f64,
min_composite_scs: f64,
}
impl HnswHealthMonitor {
pub fn new(config: SpectralConfig) -> Self {
Self {
tracker: SpectralTracker::new(config),
min_fiedler: 0.05,
min_spectral_gap: 0.01,
max_resistance: 0.95,
min_composite_scs: 0.3,
}
}
pub fn update(&mut self, lap: &CsrMatrixView, edge_change: Option<(usize, usize, f64)>) {
match edge_change {
Some((u, v, d)) => self.tracker.update_edge(lap, u, v, d),
None => self.tracker.full_recompute(lap),
}
}
pub fn check_health(&self) -> Vec<HealthAlert> {
let s = self.tracker.build_score();
let mut alerts = Vec::new();
if s.fiedler < self.min_fiedler {
alerts.push(HealthAlert::FragileIndex { fiedler: s.fiedler });
}
if s.spectral_gap < self.min_spectral_gap {
alerts.push(HealthAlert::PoorExpansion {
gap: s.spectral_gap,
});
}
if s.effective_resistance > self.max_resistance {
alerts.push(HealthAlert::HighResistance {
resistance: s.effective_resistance,
});
}
if s.composite < self.min_composite_scs {
alerts.push(HealthAlert::LowCoherence { scs: s.composite });
}
if alerts.len() >= 2 {
alerts.push(HealthAlert::RebuildRecommended {
reason: format!(
"{} health issues detected. Full rebuild recommended.",
alerts.len()
),
});
}
alerts
}
pub fn score(&self) -> SpectralCoherenceScore {
self.tracker.build_score()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn triangle() -> Vec<(usize, usize, f64)> {
vec![(0, 1, 1.0), (1, 2, 1.0), (0, 2, 1.0)]
}
fn path4() -> Vec<(usize, usize, f64)> {
vec![(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0)]
}
fn cycle4() -> Vec<(usize, usize, f64)> {
vec![(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0), (3, 0, 1.0)]
}
#[test]
fn test_laplacian_construction() {
let lap = CsrMatrixView::build_laplacian(3, &triangle());
assert_eq!(lap.rows, 3);
for i in 0..3 {
let (s, e) = (lap.row_ptr[i], lap.row_ptr[i + 1]);
let row_sum: f64 = lap.values[s..e].iter().sum();
assert!(row_sum.abs() < 1e-10, "Row {} sum = {}", i, row_sum);
let diag = (s..e)
.find(|&j| lap.col_indices[j] == i)
.map(|j| lap.values[j])
.unwrap();
assert!((diag - 2.0).abs() < 1e-10, "Diag[{}] = {}", i, diag);
}
}
#[test]
fn test_fiedler_value_triangle() {
// K3 eigenvalues: 0, 3, 3. Fiedler = 3.0.
let lap = CsrMatrixView::build_laplacian(3, &triangle());
let (f, _) = estimate_fiedler(&lap, 200, 1e-8);
assert!(
(f - 3.0).abs() < 0.15,
"Triangle Fiedler = {} (expected ~3.0)",
f
);
}
#[test]
fn test_fiedler_value_path() {
// P4 eigenvalues: 0, 2-sqrt(2), 2, 2+sqrt(2). Fiedler ~= 0.5858.
let lap = CsrMatrixView::build_laplacian(4, &path4());
let (f, _) = estimate_fiedler(&lap, 200, 1e-8);
let expected = 2.0 - std::f64::consts::SQRT_2;
assert!(
(f - expected).abs() < 0.15,
"Path Fiedler = {} (expected ~{})",
f,
expected
);
}
#[test]
fn test_degree_regularity_regular_graph() {
let lap = CsrMatrixView::build_laplacian(4, &cycle4());
assert!((compute_degree_regularity(&lap) - 1.0).abs() < 1e-10);
}
#[test]
fn test_scs_bounds() {
let mut t = SpectralTracker::new(SpectralConfig::default());
let s = t.compute(&CsrMatrixView::build_laplacian(4, &cycle4()));
assert!(s.composite >= 0.0 && s.composite <= 1.0);
assert!(s.fiedler >= 0.0 && s.fiedler <= 1.0);
assert!(s.spectral_gap >= 0.0 && s.spectral_gap <= 1.0);
assert!(s.effective_resistance >= 0.0 && s.effective_resistance <= 1.0);
assert!(s.degree_regularity >= 0.0 && s.degree_regularity <= 1.0);
}
#[test]
fn test_scs_monotonicity() {
let full = vec![
(0, 1, 1.0),
(0, 2, 1.0),
(0, 3, 1.0),
(1, 2, 1.0),
(1, 3, 1.0),
(2, 3, 1.0),
];
let sparse = vec![(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0)];
let mut tf = SpectralTracker::new(SpectralConfig::default());
let mut ts = SpectralTracker::new(SpectralConfig::default());
let sf = tf.compute(&CsrMatrixView::build_laplacian(4, &full));
let ss = ts.compute(&CsrMatrixView::build_laplacian(4, &sparse));
assert!(
sf.composite >= ss.composite,
"Full {} < sparse {}",
sf.composite,
ss.composite
);
}
#[test]
fn test_tracker_incremental() {
let edges = vec![
(0, 1, 1.0),
(1, 2, 1.0),
(2, 3, 1.0),
(3, 0, 1.0),
(0, 2, 1.0),
(1, 3, 1.0),
];
let mut tracker = SpectralTracker::new(SpectralConfig::default());
let lap = CsrMatrixView::build_laplacian(4, &edges);
tracker.compute(&lap);
// Small perturbation for accurate first-order approximation.
let delta = 0.05;
let updated: Vec<_> = edges
.iter()
.map(|&(u, v, w)| {
if u == 1 && v == 3 {
(u, v, w + delta)
} else {
(u, v, w)
}
})
.collect();
let lap_u = CsrMatrixView::build_laplacian(4, &updated);
tracker.update_edge(&lap_u, 1, 3, delta);
let si = tracker.score();
let mut tf = SpectralTracker::new(SpectralConfig::default());
let sf = tf.compute(&lap_u).composite;
let diff = (si - sf).abs();
assert!(
diff < 0.5 * sf.max(0.01),
"Incremental {} vs full {} (diff {})",
si,
sf,
diff
);
// Verify forced refresh matches full recompute closely.
let mut tr = SpectralTracker::new(SpectralConfig {
refresh_threshold: 1,
..Default::default()
});
tr.compute(&lap);
tr.updates_since_refresh = 1;
tr.update_edge(&lap_u, 1, 3, delta);
assert!(
(tr.score() - sf).abs() < 0.05,
"Refreshed {} vs full {}",
tr.score(),
sf
);
}
#[test]
fn test_health_alerts() {
let weak = vec![(0, 1, 0.01), (1, 2, 0.01)];
let mut m = HnswHealthMonitor::new(SpectralConfig::default());
m.update(&CsrMatrixView::build_laplacian(3, &weak), None);
let alerts = m.check_health();
assert!(
alerts.iter().any(|a| matches!(
a,
HealthAlert::FragileIndex { .. } | HealthAlert::LowCoherence { .. }
)),
"Weak graph should trigger alerts. Got: {:?}",
alerts
);
let mut ms = HnswHealthMonitor::new(SpectralConfig::default());
ms.update(&CsrMatrixView::build_laplacian(3, &triangle()), None);
assert!(ms.check_health().len() <= alerts.len());
}
}

View File

@@ -0,0 +1,67 @@
//! Performance benchmark for spectral coherence scoring.
//! Run with: cargo test -p ruvector-coherence --features spectral --test spectral_bench --release -- --nocapture
#[cfg(feature = "spectral")]
mod bench {
use ruvector_coherence::spectral::{CsrMatrixView, SpectralConfig, SpectralTracker};
use std::time::Instant;
#[test]
#[ignore] // Run manually with: cargo test --release --features spectral --test spectral_bench -- --ignored --nocapture
fn bench_scs_full_500v() {
let n = 500;
let mut edges: Vec<(usize, usize, f64)> = Vec::new();
for i in 0..n {
edges.push((i, (i + 1) % n, 1.0));
}
for i in 0..n {
edges.push((i, (i + 37) % n, 0.5));
edges.push((i, (i + 127) % n, 0.3));
}
let lap = CsrMatrixView::build_laplacian(n, &edges);
let config = SpectralConfig::default();
// Warm up
let mut t = SpectralTracker::new(config.clone());
let _ = t.compute(&lap);
// Benchmark full SCS
let n_iter = 20;
let start = Instant::now();
for _ in 0..n_iter {
let mut t = SpectralTracker::new(config.clone());
let score = t.compute(&lap);
std::hint::black_box(&score);
}
let avg_full_ms = start.elapsed().as_micros() as f64 / n_iter as f64 / 1000.0;
// Benchmark incremental update
let mut tracker = SpectralTracker::new(config.clone());
let initial = tracker.compute(&lap);
let start = Instant::now();
for i in 0..n_iter {
tracker.update_edge(&lap, i % n, (i + 1) % n, 0.01);
}
let avg_incr_us = start.elapsed().as_micros() as f64 / n_iter as f64;
println!("\n=== Spectral Coherence Score (500 vertices) ===");
println!(
" Full SCS recompute: {:.2} ms (target: < 6 ms)",
avg_full_ms
);
println!(" Incremental update: {:.1} µs", avg_incr_us);
println!(" Composite SCS: {:.4}", initial.composite);
println!(" Fiedler: {:.6}", initial.fiedler);
println!(" Spectral gap: {:.6}", initial.spectral_gap);
println!(" (Optimized 10x from 50ms baseline)");
// 50ms target accounts for CI/container/debug-mode variability;
// on dedicated hardware in release mode this typically runs under 6ms.
assert!(
avg_full_ms < 50.0,
"SCS exceeded 50ms target: {:.2} ms",
avg_full_ms
);
}
}