Skip to content

Commit f1f3fe9

Browse files
Merge pull request #606 from erikdarlingdata/feature/anomaly-detection-589
Add anomaly detection — baseline comparison for acute deviations (#589)
2 parents 5c08471 + 3b43644 commit f1f3fe9

6 files changed

Lines changed: 883 additions & 2 deletions

File tree

Lite.Tests/ScenarioTests.cs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,110 @@ public async Task EverythingOnFire_BlockingAndDeadlocksPresent()
359359
return (stories, factsByKey);
360360
}
361361

362+
/* ── Anomaly Detection: CPU Spike ── */
363+
364+
[Fact]
365+
public async Task CpuSpikeAnomaly_DetectsCpuDeviation()
366+
{
367+
var (stories, facts) = await RunFullPipelineWithAnomaliesAsync(s => s.SeedCpuSpikeAnomalyAsync());
368+
PrintStories("CPU SPIKE ANOMALY", stories);
369+
370+
Assert.True(facts.ContainsKey("ANOMALY_CPU_SPIKE"), "Should detect CPU anomaly");
371+
Assert.True(facts["ANOMALY_CPU_SPIKE"].Severity >= 0.5, "CPU anomaly severity should be significant");
372+
}
373+
374+
[Fact]
375+
public async Task CpuSpikeAnomaly_HighDeviation()
376+
{
377+
var (_, facts) = await RunFullPipelineWithAnomaliesAsync(s => s.SeedCpuSpikeAnomalyAsync());
378+
379+
var deviation = facts["ANOMALY_CPU_SPIKE"].Metadata["deviation_sigma"];
380+
Assert.True(deviation > 5.0, $"Expected large deviation (>5σ), got {deviation:F1}σ");
381+
}
382+
383+
[Fact]
384+
public async Task CpuSpikeAnomaly_AppearsAsStory()
385+
{
386+
var (stories, _) = await RunFullPipelineWithAnomaliesAsync(s => s.SeedCpuSpikeAnomalyAsync());
387+
388+
Assert.Contains(stories, s => s.RootFactKey == "ANOMALY_CPU_SPIKE");
389+
}
390+
391+
/* ── Anomaly Detection: Blocking Spike ── */
392+
393+
[Fact]
394+
public async Task BlockingSpikeAnomaly_DetectsBlockingBurst()
395+
{
396+
var (stories, facts) = await RunFullPipelineWithAnomaliesAsync(s => s.SeedBlockingSpikeAnomalyAsync());
397+
PrintStories("BLOCKING SPIKE ANOMALY", stories);
398+
399+
Assert.True(facts.ContainsKey("ANOMALY_BLOCKING_SPIKE"), "Should detect blocking spike");
400+
Assert.True(facts["ANOMALY_BLOCKING_SPIKE"].Severity >= 0.5, "Blocking spike should be significant");
401+
}
402+
403+
[Fact]
404+
public async Task BlockingSpikeAnomaly_DetectsDeadlockSpike()
405+
{
406+
var (_, facts) = await RunFullPipelineWithAnomaliesAsync(s => s.SeedBlockingSpikeAnomalyAsync());
407+
408+
Assert.True(facts.ContainsKey("ANOMALY_DEADLOCK_SPIKE"), "Should detect deadlock spike");
409+
}
410+
411+
/* ── Anomaly Detection: Wait Spike ── */
412+
413+
[Fact]
414+
public async Task WaitSpikeAnomaly_DetectsPageiolatchFlood()
415+
{
416+
var (stories, facts) = await RunFullPipelineWithAnomaliesAsync(s => s.SeedWaitSpikeAnomalyAsync());
417+
PrintStories("WAIT SPIKE ANOMALY", stories);
418+
419+
Assert.True(facts.ContainsKey("ANOMALY_WAIT_PAGEIOLATCH_SH"), "Should detect PAGEIOLATCH spike");
420+
Assert.True(facts["ANOMALY_WAIT_PAGEIOLATCH_SH"].Severity >= 0.5, "PAGEIOLATCH anomaly should be significant");
421+
}
422+
423+
[Fact]
424+
public async Task WaitSpikeAnomaly_HighRatio()
425+
{
426+
var (_, facts) = await RunFullPipelineWithAnomaliesAsync(s => s.SeedWaitSpikeAnomalyAsync());
427+
428+
var ratio = facts["ANOMALY_WAIT_PAGEIOLATCH_SH"].Metadata["ratio"];
429+
Assert.True(ratio >= 5.0, $"Expected >= 5x increase, got {ratio:F1}x");
430+
}
431+
432+
/* ── Helpers ── */
433+
434+
private async Task<(List<AnalysisStory> Stories, Dictionary<string, Fact> Facts)> RunFullPipelineWithAnomaliesAsync(
435+
Func<TestDataSeeder, Task> seedAction)
436+
{
437+
await _duckDb.InitializeAsync();
438+
await _duckDb.InitializeAnalysisSchemaAsync();
439+
440+
var seeder = new TestDataSeeder(_duckDb);
441+
await seedAction(seeder);
442+
443+
var collector = new DuckDbFactCollector(_duckDb);
444+
var context = TestDataSeeder.CreateTestContext();
445+
var facts = await collector.CollectFactsAsync(context);
446+
447+
// Run anomaly detection (compares analysis window against baseline)
448+
var anomalyDetector = new AnomalyDetector(_duckDb);
449+
var anomalies = await anomalyDetector.DetectAnomaliesAsync(context);
450+
facts.AddRange(anomalies);
451+
452+
var scorer = new FactScorer();
453+
scorer.ScoreAll(facts);
454+
455+
var graph = new RelationshipGraph();
456+
var engine = new InferenceEngine(graph);
457+
var stories = engine.BuildStories(facts);
458+
459+
var factsByKey = facts
460+
.Where(f => f.Severity > 0)
461+
.ToDictionary(f => f.Key, f => f);
462+
463+
return (stories, factsByKey);
464+
}
465+
362466
private static void PrintStories(string scenario, List<AnalysisStory> stories)
363467
{
364468
var output = TestContext.Current.TestOutputHelper!;

Lite/Analysis/AnalysisService.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public class AnalysisService
2222
private readonly RelationshipGraph _graph;
2323
private readonly InferenceEngine _engine;
2424
private readonly DrillDownCollector _drillDown;
25+
private readonly AnomalyDetector _anomalyDetector;
2526
/// <summary>
2627
/// Minimum hours of collected data required before analysis will run.
2728
/// Short collection windows distort fraction-of-period calculations —
@@ -59,6 +60,7 @@ public AnalysisService(DuckDbInitializer duckDb)
5960
_graph = new RelationshipGraph();
6061
_engine = new InferenceEngine(_graph);
6162
_drillDown = new DrillDownCollector(duckDb);
63+
_anomalyDetector = new AnomalyDetector(duckDb);
6264
}
6365

6466
/// <summary>
@@ -126,6 +128,10 @@ public async Task<List<AnalysisFinding>> AnalyzeAsync(AnalysisContext context)
126128
return [];
127129
}
128130

131+
// 1.5. Detect anomalies (compare analysis window against baseline)
132+
var anomalies = await _anomalyDetector.DetectAnomaliesAsync(context);
133+
facts.AddRange(anomalies);
134+
129135
// 2. Score facts (base severity + amplifiers)
130136
_scorer.ScoreAll(facts);
131137

0 commit comments

Comments
 (0)