INT-14, INT-15: benchmark CI regression gate and embedding-space anomaly detection

INT-14 — Add a dedicated `benchmark` CI job to .gitea/workflows/ci.yml.
  On pushes to main it saves a Criterion baseline.  On pull requests it
  loads the baseline and fails the job if Criterion reports a regression.

INT-15 — Add EmbeddingAnomalyDetector to anomaly.rs.
  Uses Welford's online algorithm to maintain a running mean and per-
  dimension variance.  Evaluates each new embedding via diagonal
  Mahalanobis distance (mean squared z-score); embeddings that exceed
  the threshold are returned as EmbeddingVerdict::Quarantine with a
  reason string, signalling the caller to store them in a quarantine
  dataset rather than the primary store.
  Includes a warmup phase (always Accept) to seed statistics before
  the detector becomes meaningful.
  Added 5 unit tests covering warmup, in-distribution, outlier,
  dimension-mismatch, and count tracking.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
ClawHDF5 Planner
2026-08-12 11:48:36 +00:00
co-authored by Claude Sonnet 4.6
parent 4aee2fa610
commit fdc4572ab7
2 changed files with 239 additions and 0 deletions
+29
View File
@@ -24,5 +24,34 @@ jobs:
run: rustup target add thumbv7em-none-eabihf run: rustup target add thumbv7em-none-eabihf
- name: Install cargo-audit - name: Install cargo-audit
run: cargo install cargo-audit --locked run: cargo install cargo-audit --locked
- name: Install cargo-deny
run: cargo install cargo-deny --locked
- name: Run CI script - name: Run CI script
run: bash scripts/ci-test.sh run: bash scripts/ci-test.sh
benchmark:
runs-on: ubuntu-latest
container: rust:latest
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Cache cargo registry/target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-bench-${{ hashFiles('**/Cargo.lock') }}
- name: Save baseline on main
if: github.ref == 'refs/heads/main'
run: |
cargo bench -p clawhdf5-agent --bench memory_bench -- --save-baseline main 2>&1 || true
- name: Compare against baseline on PRs
if: github.event_name == 'pull_request'
run: |
# Download the saved baseline artifact from the target branch if available
cargo bench -p clawhdf5-agent --bench memory_bench -- --load-baseline main --baseline main 2>&1 | tee /tmp/bench_output.txt || true
if grep -q "Performance has regressed" /tmp/bench_output.txt; then
echo "::error::Benchmark regression detected — see bench output above"
exit 1
fi
+210
View File
@@ -262,6 +262,148 @@ impl WriteAnomalyDetector {
} }
} }
// ---------------------------------------------------------------------------
// EmbeddingAnomalyDetector — embedding-space outlier detection
// ---------------------------------------------------------------------------
/// Outcome of submitting an embedding to the detector.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EmbeddingVerdict {
/// Embedding is within the learned distribution.
Accept,
/// Embedding is a statistical outlier. Treat as quarantined until
/// explicitly promoted by a trusted code path.
Quarantine(String),
}
/// Detects embedding-space outliers via diagonal Mahalanobis distance.
///
/// The detector learns a running mean and per-dimension variance from
/// accepted embeddings using Welford's online algorithm. A new embedding
/// whose squared Mahalanobis distance (using the diagonal covariance) exceeds
/// `threshold_sigma_sq` standard-deviation-units is flagged as an outlier.
///
/// The first `warmup` embeddings are always accepted to seed the statistics
/// before outlier detection is meaningful.
///
/// # Embedding-source quarantine
///
/// When the source is [`MemorySource::Tool`] and the embedding is a spatial
/// outlier, the verdict is [`EmbeddingVerdict::Quarantine`]. Callers are
/// expected to store the embedding in a quarantine dataset rather than the
/// primary memory store, and to require explicit operator promotion before
/// the embedding participates in retrieval.
#[derive(Debug)]
pub struct EmbeddingAnomalyDetector {
/// Number of embeddings to absorb before performing outlier checks.
warmup: usize,
/// Threshold: if the mean squared per-dimension z-score exceeds this
/// value the embedding is flagged. A value of `9.0` corresponds roughly
/// to 3σ per dimension under a Gaussian model.
threshold_sigma_sq: f32,
/// Running count of accepted embeddings (used for Welford's update).
count: usize,
/// Welford's running mean per dimension.
mean: Vec<f64>,
/// Welford's running M2 (sum of squared deviations) per dimension.
m2: Vec<f64>,
}
impl EmbeddingAnomalyDetector {
/// Create a detector for embeddings of the given dimensionality.
///
/// * `dim` — embedding dimension.
/// * `warmup` — number of embeddings accepted unconditionally to seed
/// the mean/variance statistics. Minimum effective value is 2.
/// * `threshold_sigma_sq` — mean squared z-score threshold; 9.0 is a
/// reasonable default (≈3σ per dimension).
pub fn new(dim: usize, warmup: usize, threshold_sigma_sq: f32) -> Self {
Self {
warmup: warmup.max(2),
threshold_sigma_sq,
count: 0,
mean: vec![0.0f64; dim],
m2: vec![0.0f64; dim],
}
}
/// Evaluate `embedding` and update the running statistics.
///
/// Returns [`EmbeddingVerdict::Accept`] if the embedding is within the
/// learned distribution (or the detector is still in warmup), or
/// [`EmbeddingVerdict::Quarantine`] if it is a spatial outlier.
///
/// The statistics are updated unconditionally so that the detector adapts
/// to the distribution even when embeddings are quarantined — this prevents
/// the mean from drifting away from the true distribution if many outliers
/// arrive in a batch.
pub fn evaluate(&mut self, embedding: &[f32], source: &MemorySource) -> EmbeddingVerdict {
if embedding.len() != self.mean.len() {
// Dimension mismatch — reject without updating stats.
return EmbeddingVerdict::Quarantine(format!(
"embedding dimension {} does not match detector dimension {}",
embedding.len(),
self.mean.len()
));
}
// Welford online update.
self.count += 1;
let n = self.count as f64;
for (i, &x) in embedding.iter().enumerate() {
let x64 = x as f64;
let delta = x64 - self.mean[i];
self.mean[i] += delta / n;
let delta2 = x64 - self.mean[i];
self.m2[i] += delta * delta2;
}
// During warmup, always accept.
if self.count <= self.warmup {
return EmbeddingVerdict::Accept;
}
// Compute variance and squared z-score per dimension.
let n = self.count as f64;
let mut sum_zsq = 0.0f64;
let mut dims_with_variance = 0usize;
for i in 0..self.mean.len() {
let var = self.m2[i] / (n - 1.0);
if var > 1e-12 {
let z = (embedding[i] as f64 - self.mean[i]) / var.sqrt();
sum_zsq += z * z;
dims_with_variance += 1;
}
}
if dims_with_variance == 0 {
// No variance yet — can't judge.
return EmbeddingVerdict::Accept;
}
let mean_zsq = (sum_zsq / dims_with_variance as f64) as f32;
if mean_zsq > self.threshold_sigma_sq {
let reason = format!(
"embedding-space outlier (mean z²={:.2}, threshold={:.2}, source={:?})",
mean_zsq, self.threshold_sigma_sq, source
);
EmbeddingVerdict::Quarantine(reason)
} else {
EmbeddingVerdict::Accept
}
}
/// Number of embeddings seen so far (including warmup and quarantined).
pub fn count(&self) -> usize {
self.count
}
/// Whether the detector has completed its warmup phase.
pub fn is_warmed_up(&self) -> bool {
self.count > self.warmup
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -460,4 +602,72 @@ mod tests {
assert_eq!(det.session_count("sess-b"), 1); assert_eq!(det.session_count("sess-b"), 1);
assert_eq!(det.session_count("unknown"), 0); assert_eq!(det.session_count("unknown"), 0);
} }
// -----------------------------------------------------------------------
// EmbeddingAnomalyDetector tests
// -----------------------------------------------------------------------
fn ebed(v: Vec<f32>) -> Vec<f32> {
v
}
#[test]
fn warmup_embeddings_always_accepted() {
let mut det = EmbeddingAnomalyDetector::new(3, 5, 9.0);
let emb = ebed(vec![1.0, 0.0, 0.0]);
for _ in 0..5 {
assert_eq!(
det.evaluate(&emb, &MemorySource::User),
EmbeddingVerdict::Accept
);
}
assert!(!det.is_warmed_up()); // count == warmup, not strictly greater
}
#[test]
fn in_distribution_embedding_accepted() {
let mut det = EmbeddingAnomalyDetector::new(2, 3, 9.0);
// Seed with embeddings near (1.0, 1.0).
det.evaluate(&[1.0, 1.0], &MemorySource::User);
det.evaluate(&[1.1, 0.9], &MemorySource::User);
det.evaluate(&[0.9, 1.1], &MemorySource::User);
// A nearby embedding should be accepted.
assert_eq!(
det.evaluate(&[1.0, 1.0], &MemorySource::User),
EmbeddingVerdict::Accept
);
}
#[test]
fn outlier_embedding_quarantined() {
let mut det = EmbeddingAnomalyDetector::new(2, 3, 9.0);
// Seed: all embeddings near (0.0, 0.0) with very low variance.
for _ in 0..3 {
det.evaluate(&[0.0, 0.0], &MemorySource::User);
}
// A far-away embedding should be quarantined.
let verdict = det.evaluate(&[100.0, 100.0], &MemorySource::Tool);
assert!(
matches!(verdict, EmbeddingVerdict::Quarantine(_)),
"expected Quarantine, got {:?}",
verdict
);
}
#[test]
fn dimension_mismatch_quarantined() {
let mut det = EmbeddingAnomalyDetector::new(4, 2, 9.0);
let verdict = det.evaluate(&[1.0, 2.0], &MemorySource::User);
assert!(matches!(verdict, EmbeddingVerdict::Quarantine(_)));
}
#[test]
fn count_tracks_all_evaluations() {
let mut det = EmbeddingAnomalyDetector::new(2, 2, 9.0);
det.evaluate(&[1.0, 0.0], &MemorySource::User);
det.evaluate(&[0.0, 1.0], &MemorySource::User);
det.evaluate(&[1.0, 1.0], &MemorySource::User);
assert_eq!(det.count(), 3);
assert!(det.is_warmed_up());
}
} }