go-i18n/reversal/anomaly_test.go
Snider c3e9153cf3 feat(reversal): Phase 2b — reference distributions, comparator, anomaly detection
Reference distribution builder:
- BuildReferences() tokenises samples, computes per-domain centroid imprints
- Per-key variance for Mahalanobis distance normalisation

Imprint comparator:
- Compare() returns cosine, KL divergence, Mahalanobis per domain
- Classify() picks best domain with confidence margin
- Symmetric KL with epsilon smoothing, component-weighted

Cross-domain anomaly detection:
- DetectAnomalies() flags model vs imprint domain disagreements
- AnomalyStats tracks rate and confusion pair counts

17 new tests, all race-clean. Phase 2b complete.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-20 13:57:51 +00:00

169 lines
5.4 KiB
Go

package reversal
import (
"testing"
)
func TestDetectAnomalies_NoAnomalies(t *testing.T) {
tok := initI18n(t)
// Build references from the same domain samples.
refSamples := []ClassifiedText{
{Text: "Delete the configuration file", Domain: "technical"},
{Text: "Build the project from source", Domain: "technical"},
{Text: "Update the dependencies", Domain: "technical"},
{Text: "Format the source files", Domain: "technical"},
}
rs, err := BuildReferences(tok, refSamples)
if err != nil {
t.Fatalf("BuildReferences: %v", err)
}
// Test samples that should match the reference.
testSamples := []ClassifiedText{
{Text: "Push the changes to the branch", Domain: "technical"},
{Text: "Reset the branch to the previous version", Domain: "technical"},
}
results, stats := rs.DetectAnomalies(tok, testSamples)
if stats.Total != 2 {
t.Errorf("Total = %d, want 2", stats.Total)
}
// With only one domain reference, everything classifies as that domain.
// So no anomalies expected.
if stats.Anomalies != 0 {
t.Errorf("Anomalies = %d, want 0", stats.Anomalies)
}
if len(results) != 2 {
t.Fatalf("Results len = %d, want 2", len(results))
}
for _, r := range results {
if r.IsAnomaly {
t.Errorf("unexpected anomaly: model=%s imprint=%s text=%q", r.ModelDomain, r.ImprintDomain, r.Text)
}
}
}
func TestDetectAnomalies_WithMismatch(t *testing.T) {
tok := initI18n(t)
// Build references with two well-separated domains.
refSamples := []ClassifiedText{
// Technical: imperatives.
{Text: "Delete the configuration file", Domain: "technical"},
{Text: "Build the project from source", Domain: "technical"},
{Text: "Update the dependencies now", Domain: "technical"},
{Text: "Format the source files", Domain: "technical"},
{Text: "Reset the branch to the previous version", Domain: "technical"},
// Creative: past tense narratives.
{Text: "She wrote the story by candlelight", Domain: "creative"},
{Text: "He drew a map of forgotten places", Domain: "creative"},
{Text: "The river froze under the winter moon", Domain: "creative"},
{Text: "They sang the old songs by the fire", Domain: "creative"},
{Text: "She painted the sky with broad strokes", Domain: "creative"},
}
rs, err := BuildReferences(tok, refSamples)
if err != nil {
t.Fatalf("BuildReferences: %v", err)
}
// A past-tense narrative labelled as "technical" by the model —
// the imprint should say "creative", creating an anomaly.
testSamples := []ClassifiedText{
{Text: "She painted the sunset over the mountains", Domain: "technical"},
}
results, stats := rs.DetectAnomalies(tok, testSamples)
t.Logf("Total=%d Anomalies=%d Rate=%.2f", stats.Total, stats.Anomalies, stats.Rate)
for _, r := range results {
t.Logf(" model=%s imprint=%s anomaly=%v conf=%.4f text=%q",
r.ModelDomain, r.ImprintDomain, r.IsAnomaly, r.Confidence, r.Text)
}
if stats.Total != 1 {
t.Errorf("Total = %d, want 1", stats.Total)
}
// This may or may not be flagged as anomaly depending on grammar overlap.
// We just verify the pipeline runs without error and returns valid data.
if len(results) != 1 {
t.Fatalf("Results len = %d, want 1", len(results))
}
if results[0].ModelDomain != "technical" {
t.Errorf("ModelDomain = %q, want technical", results[0].ModelDomain)
}
}
func TestDetectAnomalies_SkipsEmptyDomain(t *testing.T) {
tok := initI18n(t)
refSamples := []ClassifiedText{
{Text: "Delete the file", Domain: "technical"},
}
rs, _ := BuildReferences(tok, refSamples)
testSamples := []ClassifiedText{
{Text: "Some text without domain", Domain: ""},
{Text: "Build the project", Domain: "technical"},
}
_, stats := rs.DetectAnomalies(tok, testSamples)
if stats.Total != 1 {
t.Errorf("Total = %d, want 1 (empty domain skipped)", stats.Total)
}
}
func TestDetectAnomalies_ByPairTracking(t *testing.T) {
tok := initI18n(t)
refSamples := []ClassifiedText{
{Text: "Delete the configuration file", Domain: "technical"},
{Text: "Build the project from source", Domain: "technical"},
{Text: "Format the source files", Domain: "technical"},
{Text: "She wrote the story by candlelight", Domain: "creative"},
{Text: "He drew a map of forgotten places", Domain: "creative"},
{Text: "The river froze under the winter moon", Domain: "creative"},
}
rs, err := BuildReferences(tok, refSamples)
if err != nil {
t.Fatalf("BuildReferences: %v", err)
}
// Force some mislabelled samples.
testSamples := []ClassifiedText{
{Text: "Push the changes now", Domain: "technical"},
{Text: "She sang an old song by the fire", Domain: "creative"},
}
_, stats := rs.DetectAnomalies(tok, testSamples)
t.Logf("Anomalies=%d ByPair=%v", stats.Anomalies, stats.ByPair)
// ByPair should only contain entries for actual disagreements.
for pair, count := range stats.ByPair {
if count <= 0 {
t.Errorf("ByPair[%s] = %d, want > 0", pair, count)
}
}
}
func TestAnomalyStats_Rate(t *testing.T) {
tok := initI18n(t)
// Single domain reference — everything maps to it.
refSamples := []ClassifiedText{
{Text: "Delete the file", Domain: "technical"},
{Text: "Build the project", Domain: "technical"},
}
rs, _ := BuildReferences(tok, refSamples)
// Two samples claiming "creative" — both should be anomalies.
testSamples := []ClassifiedText{
{Text: "Update the code", Domain: "creative"},
{Text: "Fix the build", Domain: "creative"},
}
_, stats := rs.DetectAnomalies(tok, testSamples)
if stats.Rate < 0.99 {
t.Errorf("Rate = %.2f, want ~1.0 (all should be anomalies)", stats.Rate)
}
}