#[cfg(feature = "cli")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
#[cfg_attr(feature = "cli", derive(Serialize, Deserialize))]
pub struct ActivationSnapshot {
pub name: String,
pub shape: Vec<usize>,
pub l2: f32,
pub mean: f32,
pub std_dev: f32,
pub min: f32,
pub max: f32,
pub first_n: Vec<f32>,
#[cfg_attr(feature = "cli", serde(skip_serializing_if = "Option::is_none"))]
pub full_data: Option<Vec<f32>>,
}
#[derive(Debug, Clone, Default)]
pub struct ActivationProbe {
pub snapshots: Vec<ActivationSnapshot>,
pub capture_full: bool,
pub first_n: usize,
pub stage_filter: Option<String>,
}
impl ActivationProbe {
#[must_use]
pub fn new() -> Self {
Self {
snapshots: Vec::new(),
capture_full: false,
first_n: 8,
stage_filter: None,
}
}
#[must_use]
pub fn with_full_capture(mut self, capture: bool) -> Self {
self.capture_full = capture;
self
}
#[must_use]
pub fn with_first_n(mut self, n: usize) -> Self {
self.first_n = n;
self
}
#[must_use]
pub fn with_stage_filter(mut self, filter: String) -> Self {
self.stage_filter = Some(filter);
self
}
pub fn record(&mut self, name: &str, data: &[f32], shape: &[usize]) {
if let Some(ref filter) = self.stage_filter {
if !name.starts_with(filter) {
return;
}
}
let n = data.len();
if n == 0 {
self.snapshots.push(ActivationSnapshot {
name: name.to_string(),
shape: shape.to_vec(),
l2: 0.0,
mean: 0.0,
std_dev: 0.0,
min: 0.0,
max: 0.0,
first_n: Vec::new(),
full_data: None,
});
return;
}
let sum: f32 = data.iter().sum();
let mean = sum / n as f32;
let variance: f32 = data.iter().map(|&x| (x - mean).powi(2)).sum::<f32>() / n as f32;
let std_dev = variance.sqrt();
let l2: f32 = data.iter().map(|&x| x * x).sum::<f32>().sqrt();
let mut min_val = f32::INFINITY;
let mut max_val = f32::NEG_INFINITY;
for &x in data {
if x < min_val {
min_val = x;
}
if x > max_val {
max_val = x;
}
}
let first_n_vals: Vec<f32> = data.iter().take(self.first_n).copied().collect();
let full_data = if self.capture_full {
Some(data.to_vec())
} else {
None
};
self.snapshots.push(ActivationSnapshot {
name: name.to_string(),
shape: shape.to_vec(),
l2,
mean,
std_dev,
min: min_val,
max: max_val,
first_n: first_n_vals,
full_data,
});
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "cli", derive(Serialize, Deserialize))]
pub struct ProbeOutput {
pub model: String,
pub audio: String,
pub model_family: String,
pub checkpoints: Vec<ActivationSnapshot>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_probe_new() {
let probe = ActivationProbe::new();
assert!(probe.snapshots.is_empty());
assert!(!probe.capture_full);
assert_eq!(probe.first_n, 8);
assert!(probe.stage_filter.is_none());
}
#[test]
fn test_probe_record_basic() {
let mut probe = ActivationProbe::new();
let data = vec![1.0, 2.0, 3.0, 4.0];
probe.record("test.checkpoint", &data, &[2, 2]);
assert_eq!(probe.snapshots.len(), 1);
let snap = &probe.snapshots[0];
assert_eq!(snap.name, "test.checkpoint");
assert_eq!(snap.shape, vec![2, 2]);
assert!((snap.mean - 2.5).abs() < 1e-5);
assert!((snap.min - 1.0).abs() < 1e-5);
assert!((snap.max - 4.0).abs() < 1e-5);
assert!(snap.l2 > 0.0);
assert!(snap.std_dev > 0.0);
assert_eq!(snap.first_n.len(), 4); assert!(snap.full_data.is_none());
}
#[test]
fn test_probe_record_full_capture() {
let mut probe = ActivationProbe::new().with_full_capture(true);
let data = vec![1.0, 2.0, 3.0];
probe.record("test", &data, &[3]);
assert!(probe.snapshots[0].full_data.is_some());
assert_eq!(
probe.snapshots[0].full_data.as_ref().map(|d| d.len()),
Some(3)
);
}
#[test]
fn test_probe_record_first_n() {
let mut probe = ActivationProbe::new().with_first_n(2);
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
probe.record("test", &data, &[5]);
assert_eq!(probe.snapshots[0].first_n.len(), 2);
assert!((probe.snapshots[0].first_n[0] - 1.0).abs() < 1e-5);
assert!((probe.snapshots[0].first_n[1] - 2.0).abs() < 1e-5);
}
#[test]
fn test_probe_stage_filter() {
let mut probe = ActivationProbe::new().with_stage_filter("encoder".to_string());
probe.record("conv_stem.conv1_out", &[1.0, 2.0], &[2]);
probe.record("encoder.block_0.ln1_out", &[3.0, 4.0], &[2]);
probe.record("decoder.token_emb", &[5.0, 6.0], &[2]);
assert_eq!(probe.snapshots.len(), 1);
assert_eq!(probe.snapshots[0].name, "encoder.block_0.ln1_out");
}
#[test]
fn test_probe_record_empty() {
let mut probe = ActivationProbe::new();
probe.record("empty", &[], &[0]);
assert_eq!(probe.snapshots.len(), 1);
assert!((probe.snapshots[0].l2).abs() < 1e-5);
assert!((probe.snapshots[0].mean).abs() < 1e-5);
}
#[test]
fn test_probe_l2_norm() {
let mut probe = ActivationProbe::new();
probe.record("test", &[3.0, 4.0], &[2]);
assert!((probe.snapshots[0].l2 - 5.0).abs() < 1e-5);
}
#[test]
fn test_probe_std_dev() {
let mut probe = ActivationProbe::new();
probe.record("const", &[1.0, 1.0, 1.0, 1.0], &[4]);
assert!((probe.snapshots[0].std_dev).abs() < 1e-5);
}
#[test]
fn test_probe_output_structure() {
let output = ProbeOutput {
model: "moonshine-tiny.apr".to_string(),
audio: "test.wav".to_string(),
model_family: "moonshine".to_string(),
checkpoints: vec![],
};
assert_eq!(output.model, "moonshine-tiny.apr");
assert!(output.checkpoints.is_empty());
}
#[test]
fn test_probe_multiple_records() {
let mut probe = ActivationProbe::new();
for i in 0..5 {
probe.record(&format!("layer_{i}"), &[i as f32; 10], &[10]);
}
assert_eq!(probe.snapshots.len(), 5);
assert_eq!(probe.snapshots[3].name, "layer_3");
}
#[test]
fn test_probe_builder_chain() {
let probe = ActivationProbe::new()
.with_full_capture(true)
.with_first_n(16)
.with_stage_filter("decoder".to_string());
assert!(probe.capture_full);
assert_eq!(probe.first_n, 16);
assert_eq!(probe.stage_filter.as_deref(), Some("decoder"));
}
}