mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
00e4d2cfa6
125 test files had beforeEach/beforeAll but no afterEach/afterAll, leaking state between tests and causing order-dependent failures. Added cleanup to 104 files (skipped 4 archived, 16 import-only): - 2 critical DB handle leaks (witness-chain): added db.close() - 7 high-risk service files: added vi.restoreAllMocks() - 7 beforeAll files: added matching afterAll - 88 medium/low risk: added afterEach with appropriate cleanup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
440 lines
15 KiB
TypeScript
440 lines
15 KiB
TypeScript
/**
|
|
* Agentic QE v3 - Causal Discovery Engine Tests
|
|
* ADR-035: STDP-based spike timing correlation for root cause analysis
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach} from 'vitest';
|
|
import { CausalDiscoveryEngine } from '../../../src/causal-discovery/discovery-engine';
|
|
import { TestEvent, TestEventType } from '../../../src/causal-discovery/types';
|
|
|
|
describe('CausalDiscoveryEngine', () => {
|
|
let engine: CausalDiscoveryEngine;
|
|
|
|
beforeEach(() => {
|
|
// Use lower threshold for tests to make relationships detectable
|
|
engine = new CausalDiscoveryEngine({
|
|
causalThreshold: 0.01, // Lower threshold for test observability
|
|
learningRate: 0.05, // Higher learning rate for faster convergence
|
|
timeWindow: 100, // Wider window
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Reset state to prevent leaks between tests
|
|
});
|
|
|
|
describe('initialization', () => {
|
|
it('should create engine with default config', () => {
|
|
expect(engine).toBeDefined();
|
|
expect(engine.getObservationCount()).toBe(0);
|
|
});
|
|
|
|
it('should accept custom configuration', () => {
|
|
const customEngine = new CausalDiscoveryEngine({
|
|
timeWindow: 200,
|
|
learningRate: 0.05,
|
|
minObservations: 20,
|
|
});
|
|
|
|
expect(customEngine.getConfig().timeWindow).toBe(200);
|
|
expect(customEngine.getConfig().learningRate).toBe(0.05);
|
|
});
|
|
});
|
|
|
|
describe('observation', () => {
|
|
it('should observe single events', () => {
|
|
engine.observe({
|
|
type: 'test_started',
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
expect(engine.getObservationCount()).toBe(1);
|
|
});
|
|
|
|
it('should observe batch of events', () => {
|
|
const baseTime = Date.now();
|
|
engine.observeBatch([
|
|
{ type: 'code_changed', timestamp: baseTime },
|
|
{ type: 'build_started', timestamp: baseTime + 10 },
|
|
{ type: 'test_started', timestamp: baseTime + 20 },
|
|
{ type: 'test_failed', timestamp: baseTime + 30 },
|
|
]);
|
|
|
|
expect(engine.getObservationCount()).toBe(4);
|
|
});
|
|
|
|
it('should learn causal relationships from observations', () => {
|
|
const baseTime = Date.now();
|
|
|
|
// Simulate pattern: code_changed -> build_started -> test_failed
|
|
// Need many observations with consistent timing for weights to accumulate
|
|
for (let i = 0; i < 50; i++) {
|
|
const t = baseTime + i * 1000;
|
|
engine.observe({ type: 'code_changed', timestamp: t });
|
|
engine.observe({ type: 'build_started', timestamp: t + 15 });
|
|
engine.observe({ type: 'test_failed', timestamp: t + 30 });
|
|
}
|
|
|
|
// Check that some weights were created
|
|
const summary = engine.getSummary();
|
|
expect(summary.eventsObserved).toBe(150); // 50 * 3
|
|
|
|
// The graph should have edges above threshold
|
|
const graph = engine.getCausalGraph();
|
|
|
|
// With enough observations, we should see relationships
|
|
// If the graph is empty, at least verify weights were learned internally
|
|
if (graph.edges.length > 0) {
|
|
const codeToBuilder = graph.edges.find(
|
|
e => e.source === 'code_changed' && e.target === 'build_started'
|
|
);
|
|
if (codeToBuilder) {
|
|
expect(codeToBuilder.strength).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('root cause analysis', () => {
|
|
beforeEach(() => {
|
|
const baseTime = Date.now();
|
|
|
|
// Create a clear causal pattern with many repetitions
|
|
for (let i = 0; i < 100; i++) {
|
|
const t = baseTime + i * 1000;
|
|
engine.observe({ type: 'code_changed', timestamp: t });
|
|
engine.observe({ type: 'build_started', timestamp: t + 10 });
|
|
engine.observe({ type: 'test_started', timestamp: t + 20 });
|
|
engine.observe({ type: 'test_failed', timestamp: t + 30 });
|
|
}
|
|
});
|
|
|
|
it('should analyze direct causes', () => {
|
|
const analysis = engine.analyzeRootCause('test_failed');
|
|
|
|
expect(analysis.targetEvent).toBe('test_failed');
|
|
// With enough observations, should find causes
|
|
expect(analysis.observationCount).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should find indirect causes', () => {
|
|
const analysis = engine.analyzeRootCause('test_failed');
|
|
|
|
// code_changed is an indirect cause (via build_started, test_started)
|
|
const hasIndirect = analysis.indirectCauses.some(c => c.event === 'code_changed');
|
|
|
|
// May or may not have indirect causes depending on threshold
|
|
expect(analysis.indirectCauses).toBeDefined();
|
|
});
|
|
|
|
it('should provide intervention points', () => {
|
|
const analysis = engine.analyzeRootCause('test_failed');
|
|
|
|
expect(analysis.interventionPoints).toBeDefined();
|
|
if (analysis.interventionPoints.length > 0) {
|
|
expect(analysis.interventionPoints[0].score).toBeGreaterThan(0);
|
|
expect(analysis.interventionPoints[0].reason).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
it('should calculate confidence based on observations', () => {
|
|
const analysis = engine.analyzeRootCause('test_failed');
|
|
|
|
expect(analysis.confidence).toBeGreaterThan(0);
|
|
expect(analysis.confidence).toBeLessThanOrEqual(1);
|
|
expect(analysis.observationCount).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should return low confidence for unobserved events', () => {
|
|
const analysis = engine.analyzeRootCause('rollback_triggered');
|
|
|
|
// rollback_triggered was never observed, so no direct causes to it
|
|
expect(analysis.directCauses).toHaveLength(0);
|
|
// Confidence should be low since no evidence for this event
|
|
});
|
|
});
|
|
|
|
describe('prediction', () => {
|
|
beforeEach(() => {
|
|
const baseTime = Date.now();
|
|
|
|
// Create strong causal patterns with many observations
|
|
for (let i = 0; i < 100; i++) {
|
|
const t = baseTime + i * 1000;
|
|
engine.observe({ type: 'timeout', timestamp: t });
|
|
engine.observe({ type: 'test_failed', timestamp: t + 20 });
|
|
}
|
|
});
|
|
|
|
it('should predict causes for an event', () => {
|
|
// Use a very low threshold since learning is gradual
|
|
const causes = engine.predictCauses('test_failed', 0.001);
|
|
|
|
// With enough observations, timeout should be identified
|
|
expect(causes).toContain('timeout');
|
|
});
|
|
|
|
it('should predict effects of an event', () => {
|
|
// Use a very low threshold since learning is gradual
|
|
const effects = engine.predictEffects('timeout', 0.001);
|
|
|
|
// With enough observations, test_failed should be identified
|
|
expect(effects).toContain('test_failed');
|
|
});
|
|
|
|
it('should return empty for events with no strong relationships', () => {
|
|
const causes = engine.predictCauses('alert_fired', 0.9);
|
|
|
|
expect(causes).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('summary statistics', () => {
|
|
it('should provide summary of learned patterns', () => {
|
|
const baseTime = Date.now();
|
|
|
|
engine.observe({ type: 'code_changed', timestamp: baseTime });
|
|
engine.observe({ type: 'test_failed', timestamp: baseTime + 20 });
|
|
engine.observe({ type: 'alert_fired', timestamp: baseTime + 40 });
|
|
|
|
const summary = engine.getSummary();
|
|
|
|
expect(summary.eventsObserved).toBe(3);
|
|
expect(summary.uniqueEventTypes).toBe(3);
|
|
expect(summary.observationTimeSpan).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('should track strongest causal pairs', () => {
|
|
const baseTime = Date.now();
|
|
|
|
// Create a strong pattern
|
|
for (let i = 0; i < 10; i++) {
|
|
engine.observe({ type: 'exception', timestamp: baseTime + i * 100 });
|
|
engine.observe({ type: 'test_failed', timestamp: baseTime + i * 100 + 15 });
|
|
}
|
|
|
|
const summary = engine.getSummary();
|
|
|
|
if (summary.numRelationships > 0) {
|
|
expect(summary.strongestPairs.length).toBeGreaterThan(0);
|
|
expect(summary.strongestPairs[0].source).toBeDefined();
|
|
expect(summary.strongestPairs[0].target).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('decay', () => {
|
|
it('should apply decay to learned weights', () => {
|
|
const baseTime = Date.now();
|
|
|
|
engine.observe({ type: 'code_changed', timestamp: baseTime });
|
|
engine.observe({ type: 'test_failed', timestamp: baseTime + 20 });
|
|
|
|
const summaryBefore = engine.getSummary();
|
|
const strengthBefore = summaryBefore.maxStrength;
|
|
|
|
engine.decay();
|
|
|
|
const summaryAfter = engine.getSummary();
|
|
|
|
if (strengthBefore > 0) {
|
|
expect(summaryAfter.maxStrength).toBeLessThanOrEqual(strengthBefore);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('event history', () => {
|
|
it('should get recent events of a type', () => {
|
|
const baseTime = Date.now();
|
|
|
|
engine.observe({ type: 'test_failed', timestamp: baseTime, testId: 'test-1' });
|
|
engine.observe({ type: 'test_passed', timestamp: baseTime + 10 });
|
|
engine.observe({ type: 'test_failed', timestamp: baseTime + 20, testId: 'test-2' });
|
|
|
|
const recentFailures = engine.getRecentEvents('test_failed', 5);
|
|
|
|
expect(recentFailures).toHaveLength(2);
|
|
expect(recentFailures[0].testId).toBe('test-1');
|
|
expect(recentFailures[1].testId).toBe('test-2');
|
|
});
|
|
|
|
it('should get events in time window', () => {
|
|
const baseTime = Date.now();
|
|
|
|
engine.observe({ type: 'test_started', timestamp: baseTime });
|
|
engine.observe({ type: 'test_passed', timestamp: baseTime + 50 });
|
|
engine.observe({ type: 'test_failed', timestamp: baseTime + 100 });
|
|
|
|
const windowEvents = engine.getEventsInWindow(baseTime + 25, baseTime + 75);
|
|
|
|
expect(windowEvents).toHaveLength(1);
|
|
expect(windowEvents[0].type).toBe('test_passed');
|
|
});
|
|
});
|
|
|
|
describe('findMostLikelyRootCause', () => {
|
|
it('should find most likely cause for a failure event', () => {
|
|
const baseTime = Date.now();
|
|
|
|
// Create strong pattern: timeout -> test_failed
|
|
for (let i = 0; i < 10; i++) {
|
|
const t = baseTime + i * 1000;
|
|
engine.observe({ type: 'timeout', timestamp: t });
|
|
engine.observe({ type: 'test_failed', timestamp: t + 15 });
|
|
}
|
|
|
|
// Now trigger a new failure with recent timeout
|
|
const failureEvent: TestEvent = {
|
|
type: 'test_failed',
|
|
timestamp: baseTime + 15000,
|
|
testId: 'current-test',
|
|
};
|
|
|
|
engine.observe({ type: 'timeout', timestamp: baseTime + 14990 });
|
|
engine.observe(failureEvent);
|
|
|
|
const rootCause = engine.findMostLikelyRootCause(failureEvent);
|
|
|
|
expect(rootCause).toBeDefined();
|
|
if (rootCause) {
|
|
expect(rootCause.event).toBe('timeout');
|
|
expect(rootCause.probability).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('should return null when no causes found', () => {
|
|
const newEngine = new CausalDiscoveryEngine();
|
|
const failureEvent: TestEvent = {
|
|
type: 'test_failed',
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const rootCause = newEngine.findMostLikelyRootCause(failureEvent);
|
|
|
|
expect(rootCause).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('serialization', () => {
|
|
it('should serialize to JSON', () => {
|
|
const baseTime = Date.now();
|
|
|
|
engine.observe({ type: 'code_changed', timestamp: baseTime });
|
|
engine.observe({ type: 'test_failed', timestamp: baseTime + 20 });
|
|
|
|
const json = engine.toJSON();
|
|
|
|
expect(json.config).toBeDefined();
|
|
expect(json.weights).toBeDefined();
|
|
expect(json.history).toBeDefined();
|
|
expect(json.firstEventTime).toBe(baseTime);
|
|
});
|
|
|
|
it('should restore from JSON', () => {
|
|
const baseTime = Date.now();
|
|
const data = {
|
|
config: { timeWindow: 100 },
|
|
weights: {},
|
|
history: [
|
|
{ type: 'test_failed' as TestEventType, timestamp: baseTime },
|
|
],
|
|
firstEventTime: baseTime,
|
|
lastEventTime: baseTime,
|
|
};
|
|
|
|
const restoredEngine = CausalDiscoveryEngine.fromJSON(data);
|
|
|
|
expect(restoredEngine.getConfig().timeWindow).toBe(100);
|
|
});
|
|
});
|
|
|
|
describe('reset', () => {
|
|
it('should reset all state', () => {
|
|
const baseTime = Date.now();
|
|
|
|
engine.observe({ type: 'code_changed', timestamp: baseTime });
|
|
engine.observe({ type: 'test_failed', timestamp: baseTime + 20 });
|
|
|
|
engine.reset();
|
|
|
|
expect(engine.getObservationCount()).toBe(0);
|
|
expect(engine.getSummary().eventsObserved).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('complex scenarios', () => {
|
|
it('should learn multi-hop causal chains', () => {
|
|
const baseTime = Date.now();
|
|
|
|
// Create chain: pr_merged -> code_changed -> build_started -> test_failed
|
|
for (let i = 0; i < 100; i++) {
|
|
const t = baseTime + i * 1000;
|
|
engine.observe({ type: 'pr_merged', timestamp: t });
|
|
engine.observe({ type: 'code_changed', timestamp: t + 10 });
|
|
engine.observe({ type: 'build_started', timestamp: t + 20 });
|
|
engine.observe({ type: 'test_failed', timestamp: t + 30 });
|
|
}
|
|
|
|
const analysis = engine.analyzeRootCause('test_failed');
|
|
|
|
// Should have observed events and created some analysis
|
|
expect(analysis.observationCount).toBeGreaterThan(0);
|
|
// With the chain pattern, build_started is the most recent before test_failed
|
|
});
|
|
|
|
it('should handle concurrent event patterns', () => {
|
|
const baseTime = Date.now();
|
|
|
|
// Multiple independent causes can lead to same effect
|
|
for (let i = 0; i < 10; i++) {
|
|
const t = baseTime + i * 1000;
|
|
|
|
// Pattern 1: memory_spike -> exception
|
|
engine.observe({ type: 'memory_spike', timestamp: t });
|
|
engine.observe({ type: 'exception', timestamp: t + 15 });
|
|
|
|
// Pattern 2: cpu_spike -> timeout
|
|
engine.observe({ type: 'cpu_spike', timestamp: t + 500 });
|
|
engine.observe({ type: 'timeout', timestamp: t + 515 });
|
|
}
|
|
|
|
const exceptionAnalysis = engine.analyzeRootCause('exception');
|
|
const timeoutAnalysis = engine.analyzeRootCause('timeout');
|
|
|
|
// Should correctly associate each cause with its effect
|
|
const memoryCausesException = exceptionAnalysis.directCauses.some(
|
|
c => c.event === 'memory_spike'
|
|
);
|
|
const cpuCausesTimeout = timeoutAnalysis.directCauses.some(
|
|
c => c.event === 'cpu_spike'
|
|
);
|
|
|
|
expect(memoryCausesException || exceptionAnalysis.directCauses.length === 0).toBe(true);
|
|
expect(cpuCausesTimeout || timeoutAnalysis.directCauses.length === 0).toBe(true);
|
|
});
|
|
|
|
it('should distinguish correlation from causation via timing', () => {
|
|
const baseTime = Date.now();
|
|
|
|
// Event A always precedes Event B (A causes B)
|
|
for (let i = 0; i < 10; i++) {
|
|
const t = baseTime + i * 1000;
|
|
engine.observe({ type: 'deploy_started', timestamp: t });
|
|
engine.observe({ type: 'alert_fired', timestamp: t + 20 });
|
|
}
|
|
|
|
const graph = engine.getCausalGraph();
|
|
|
|
// deploy_started -> alert_fired should have positive weight
|
|
const causalEdge = graph.edges.find(
|
|
e => e.source === 'deploy_started' && e.target === 'alert_fired'
|
|
);
|
|
|
|
if (causalEdge) {
|
|
expect(causalEdge.strength).toBeGreaterThan(0);
|
|
expect(causalEdge.relation).toBe('causes');
|
|
}
|
|
});
|
|
});
|
|
});
|