Skip to content
This repository was archived by the owner on Jun 18, 2026. It is now read-only.

Commit 9a7cf5a

Browse files
author
Repo Gardener
committed
perf(incidentReplay): cache lowercased evidence text in causal/pattern hot paths
detectCausalChains() previously called evidenceText() and toLowerCase() twice per (event, causal-pair) — for N events and 8 pairs that's 16N redundant string allocations. Now we precompute lowerTexts once per investigate() and the inner loop is a plain indexOf scan. matchPatterns() also rebuilt allText with += inside the loop and then re-lowercased the entire corpus on every pattern's keywordMatchScore call. Switched to array+join + a single allTextLower, with a new keywordMatchScoreLower() helper used by the hot path. Public keywordMatchScore() behavior is unchanged. Add __tests__/incidentReplay.perf.test.js (5 new tests, 62 total in the file's two suites): mixed-case keyword matching via custom patterns, no false positives when keywords are absent, causal chain detection when cause/effect strings only appear inside data values, rejection of effect-before-cause, and a 200-event scale smoke test. Verified: node -c on changed files; full jest suite green (182 files, 6601 tests passed, 46s).
1 parent 9d27a33 commit 9a7cf5a

2 files changed

Lines changed: 206 additions & 8 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
'use strict';
2+
3+
/**
4+
* Regression tests for incidentReplay perf optimizations and edge cases
5+
* around pattern matching + causal chain detection.
6+
*
7+
* These exercise behaviors the main suite doesn't cover:
8+
* - Mixed-case keyword matching (the optimized hot path lowercases the
9+
* corpus once; keywords themselves are still lowercased per-pair).
10+
* - Custom user-registered patterns (so the optimization doesn't regress
11+
* when customPatterns is non-empty).
12+
* - Causal chains where cause/effect substrings appear inside `data` keys
13+
* or values, not just `source`/`type`.
14+
* - Stability under bigger evidence sets (smoke for the precomputed
15+
* lowerTexts buffer in detectCausalChains).
16+
*/
17+
18+
var mod = require('../docs/shared/incidentReplay');
19+
20+
describe('Lab Incident Replay - perf regression + edge cases', function () {
21+
var engine;
22+
23+
beforeEach(function () {
24+
engine = mod.createIncidentReplay();
25+
});
26+
27+
describe('pattern matching - case insensitivity', function () {
28+
it('matches keywords regardless of case in the evidence corpus', function () {
29+
// Register a deterministic custom pattern so we control the keywords.
30+
engine.registerPattern({
31+
name: 'mixed_case_probe',
32+
label: 'Mixed Case Probe',
33+
signature: {
34+
sources: ['print_log'],
35+
keywords: ['CUSTOM_FAULT', 'StrangeWarning']
36+
},
37+
recommendations: ['probe-rec']
38+
});
39+
40+
engine.loadEvidence([
41+
{
42+
source: 'print_log',
43+
timestamp: '2026-04-28T14:00:00Z',
44+
type: 'note',
45+
description: 'Operator reported strangewarning during run'
46+
},
47+
{
48+
source: 'print_log',
49+
timestamp: '2026-04-28T14:05:00Z',
50+
type: 'fault',
51+
data: { reason: 'custom_fault triggered' }
52+
}
53+
]);
54+
55+
var report = engine.investigate({ incidentTime: '2026-04-28T14:05:00Z' });
56+
var probe = report.patternMatches.find(function (m) {
57+
return m.pattern === 'mixed_case_probe';
58+
});
59+
expect(probe).toBeDefined();
60+
// Both keywords present (case-insensitive) -> keywordMatch == 1.0
61+
expect(probe.keywordMatch).toBe(1);
62+
// Source matches -> sourceOverlap == 1.0
63+
expect(probe.sourceOverlap).toBe(1);
64+
// Combined similarity = 0.4*1 + 0.6*1 = 1.0 (rounded)
65+
expect(probe.similarity).toBe(1);
66+
});
67+
68+
it('produces no false positives when keywords are absent', function () {
69+
engine.registerPattern({
70+
name: 'absent_pattern',
71+
label: 'Absent',
72+
signature: {
73+
sources: ['quality'],
74+
keywords: ['this_string_should_never_appear_xyzzy']
75+
},
76+
recommendations: []
77+
});
78+
79+
engine.loadEvidence([
80+
{ source: 'quality', timestamp: '2026-04-28T14:00:00Z', type: 'reading', data: { ok: true } }
81+
]);
82+
83+
var report = engine.investigate({ incidentTime: '2026-04-28T14:00:00Z' });
84+
var match = report.patternMatches.find(function (m) {
85+
return m.pattern === 'absent_pattern';
86+
});
87+
// sourceOverlap = 1, kwScore = 0, similarity = 0.4 -> kept (>0.1)
88+
// but keywordMatch must be 0.
89+
if (match) {
90+
expect(match.keywordMatch).toBe(0);
91+
}
92+
});
93+
});
94+
95+
describe('causal chain detection - data field scans', function () {
96+
it('detects clog -> under_extrusion when terms only appear inside data values', function () {
97+
engine.loadEvidence([
98+
{
99+
source: 'equipment',
100+
timestamp: '2026-04-28T14:00:00Z',
101+
type: 'reading',
102+
data: { note: 'nozzle clog suspected', pressure: 220 }
103+
},
104+
{
105+
source: 'print_log',
106+
timestamp: '2026-04-28T14:10:00Z',
107+
type: 'alert',
108+
data: { warning: 'under_extrusion observed', flow: 0.1 }
109+
}
110+
]);
111+
112+
var report = engine.investigate({ incidentTime: '2026-04-28T14:10:00Z' });
113+
var chain = report.causalChains.find(function (c) {
114+
return c.label.indexOf('Nozzle clog') !== -1;
115+
});
116+
expect(chain).toBeDefined();
117+
expect(chain.delayMinutes).toBeCloseTo(10, 1);
118+
// Strength decays linearly over 1 hour; 10 min -> ~0.83
119+
expect(chain.strength).toBeGreaterThan(0.7);
120+
expect(chain.strength).toBeLessThanOrEqual(1);
121+
});
122+
123+
it('orders cause before effect (no chain when effect precedes cause)', function () {
124+
engine.loadEvidence([
125+
{
126+
source: 'quality',
127+
timestamp: '2026-04-28T14:00:00Z',
128+
type: 'excursion',
129+
data: { viability: 60 }
130+
},
131+
{
132+
source: 'environmental',
133+
timestamp: '2026-04-28T14:30:00Z',
134+
type: 'reading',
135+
data: { temperature: 42 }
136+
}
137+
]);
138+
139+
var report = engine.investigate({ incidentTime: '2026-04-28T14:30:00Z' });
140+
var bogus = report.causalChains.find(function (c) {
141+
return c.label.indexOf('Temperature') !== -1;
142+
});
143+
expect(bogus).toBeUndefined();
144+
});
145+
});
146+
147+
describe('scale - many events through optimized hot paths', function () {
148+
it('investigates 200 events without throwing and returns a coherent report', function () {
149+
var evidence = [];
150+
var base = Date.parse('2026-04-28T14:00:00Z');
151+
for (var i = 0; i < 200; i++) {
152+
evidence.push({
153+
source: i % 2 === 0 ? 'environmental' : 'quality',
154+
timestamp: new Date(base + i * 30000).toISOString(),
155+
type: i % 5 === 0 ? 'excursion' : 'reading',
156+
data: i % 3 === 0
157+
? { temperature: 30 + (i % 20), note: 'temperature drift detected' }
158+
: { viability: 70 + (i % 25) }
159+
});
160+
}
161+
engine.loadEvidence(evidence);
162+
163+
var report = engine.investigate({
164+
incidentTime: new Date(base + 200 * 30000).toISOString()
165+
});
166+
167+
expect(report).toBeDefined();
168+
expect(Array.isArray(report.causalChains)).toBe(true);
169+
expect(Array.isArray(report.patternMatches)).toBe(true);
170+
// We seeded clear temperature->viability signal, so at least one chain
171+
// for that pair must be found.
172+
var tempChain = report.causalChains.find(function (c) {
173+
return c.label.indexOf('Temperature') !== -1;
174+
});
175+
expect(tempChain).toBeDefined();
176+
});
177+
});
178+
});

docs/shared/incidentReplay.js

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,13 @@ function computeStats(values) {
193193

194194
function keywordMatchScore(text, keywords) {
195195
if (!text || !keywords || keywords.length === 0) return 0;
196-
var lower = text.toLowerCase();
196+
return keywordMatchScoreLower(text.toLowerCase(), keywords);
197+
}
198+
199+
// Variant that assumes `lower` is already lowercased. Used by the pattern
200+
// matcher hot path so the corpus is not re-lowercased per pattern.
201+
function keywordMatchScoreLower(lower, keywords) {
202+
if (!lower || !keywords || keywords.length === 0) return 0;
197203
var hits = 0;
198204
for (var i = 0; i < keywords.length; i++) {
199205
if (lower.indexOf(keywords[i].toLowerCase()) !== -1) hits++;
@@ -387,15 +393,24 @@ function createIncidentReplay() {
387393
{ cause: 'clog', effect: 'under_extrusion', label: 'Nozzle clog → Under-extrusion' }
388394
];
389395

396+
// PERF: Precompute lowercased evidenceText once per event so each
397+
// (event, pair) check is a single substring scan instead of rebuilding
398+
// the evidenceText string and re-lowercasing it twice. For N events
399+
// and 8 causal pairs this drops ~16N redundant string allocations.
400+
var lowerTexts = new Array(sorted.length);
401+
for (var li = 0; li < sorted.length; li++) {
402+
lowerTexts[li] = evidenceText(sorted[li]).toLowerCase();
403+
}
404+
390405
for (var p = 0; p < causalPairs.length; p++) {
391406
var pair = causalPairs[p];
392407
var causeEvents = [];
393408
var effectEvents = [];
394409

395410
for (var i = 0; i < sorted.length; i++) {
396-
var text = evidenceText(sorted[i]);
397-
if (text.toLowerCase().indexOf(pair.cause) !== -1) causeEvents.push(sorted[i]);
398-
if (text.toLowerCase().indexOf(pair.effect) !== -1) effectEvents.push(sorted[i]);
411+
var text = lowerTexts[i];
412+
if (text.indexOf(pair.cause) !== -1) causeEvents.push(sorted[i]);
413+
if (text.indexOf(pair.effect) !== -1) effectEvents.push(sorted[i]);
399414
}
400415

401416
for (var c = 0; c < causeEvents.length; c++) {
@@ -426,12 +441,17 @@ function createIncidentReplay() {
426441
var allPatterns = BUILT_IN_PATTERNS.concat(customPatterns);
427442
if (allPatterns.length === 0 || sorted.length === 0) return [];
428443

444+
// PERF: Build evidenceSources + concatenated text in a single pass
445+
// using an array+join (avoids quadratic-ish string growth) and
446+
// pre-lowercase once so keywordMatchScore doesn't re-lowercase the
447+
// entire corpus on every pattern iteration.
429448
var evidenceSources = {};
430-
var allText = '';
449+
var textParts = new Array(sorted.length);
431450
for (var i = 0; i < sorted.length; i++) {
432451
evidenceSources[sorted[i].source] = true;
433-
allText += ' ' + evidenceText(sorted[i]);
452+
textParts[i] = evidenceText(sorted[i]);
434453
}
454+
var allTextLower = textParts.join(' ').toLowerCase();
435455

436456
var matches = [];
437457
for (var p = 0; p < allPatterns.length; p++) {
@@ -447,8 +467,8 @@ function createIncidentReplay() {
447467
}
448468
var sourceScore = sig.sources && sig.sources.length > 0 ? sourceHits / sig.sources.length : 0;
449469

450-
// Keyword match score
451-
var kwScore = sig.keywords ? keywordMatchScore(allText, sig.keywords) : 0;
470+
// Keyword match score (allTextLower already lowercased)
471+
var kwScore = sig.keywords ? keywordMatchScoreLower(allTextLower, sig.keywords) : 0;
452472

453473
// Combined similarity
454474
var similarity = round((sourceScore * 0.4 + kwScore * 0.6), 2);

0 commit comments

Comments
 (0)