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:
co-authored by
Claude Sonnet 4.6
parent
4aee2fa610
commit
fdc4572ab7
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user