@iris-eval/mcp-server 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -34
- package/dist/config/defaults.js +3 -1
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +33 -7
- package/dist/dashboard/assets/index-CKs2Wbd_.js +10 -0
- package/dist/dashboard/assets/{index-UffZ-aEJ.css → index-D0cFfBqn.css} +1 -1
- package/dist/dashboard/index.html +4 -3
- package/dist/dashboard/routes/health.js +10 -3
- package/dist/dashboard/routes/moments.js +1 -1
- package/dist/dashboard/routes/preferences.d.ts +1 -0
- package/dist/dashboard/routes/preferences.js +31 -3
- package/dist/dashboard/routes/rules.d.ts +18 -0
- package/dist/dashboard/routes/rules.js +160 -6
- package/dist/dashboard/routes/traces.js +27 -3
- package/dist/dashboard/seed-demo-data.js +11 -0
- package/dist/dashboard/server.js +13 -3
- package/dist/dashboard/session-auth.d.ts +8 -0
- package/dist/dashboard/session-auth.js +237 -0
- package/dist/dashboard/validation.d.ts +10 -4
- package/dist/dashboard/validation.js +73 -11
- package/dist/eval/engine.d.ts +79 -1
- package/dist/eval/engine.js +216 -82
- package/dist/eval/rules/relevance.d.ts +13 -0
- package/dist/eval/rules/relevance.js +185 -21
- package/dist/eval/rules/safety.d.ts +19 -0
- package/dist/eval/rules/safety.js +236 -24
- package/dist/index.js +102 -16
- package/dist/middleware/rate-limit.d.ts +25 -0
- package/dist/middleware/rate-limit.js +54 -2
- package/dist/self-test.d.ts +14 -0
- package/dist/self-test.js +97 -13
- package/dist/storage/demo-guard.d.ts +8 -0
- package/dist/storage/demo-guard.js +53 -0
- package/dist/storage/sqlite-adapter.d.ts +6 -0
- package/dist/storage/sqlite-adapter.js +72 -1
- package/dist/tools/delete-rule.js +49 -11
- package/dist/tools/deploy-rule.d.ts +33 -0
- package/dist/tools/deploy-rule.js +130 -27
- package/dist/tools/evaluate-output.js +54 -33
- package/dist/tools/evaluate-with-llm-judge.js +10 -3
- package/dist/tools/get-traces.d.ts +27 -0
- package/dist/tools/get-traces.js +60 -8
- package/dist/tools/list-rules.js +2 -2
- package/dist/tools/log-trace.js +4 -3
- package/dist/tools/strict-input.d.ts +1 -0
- package/dist/tools/strict-input.js +27 -2
- package/dist/tools/trace-link.d.ts +7 -0
- package/dist/tools/trace-link.js +39 -0
- package/dist/tools/verify-citations.d.ts +19 -0
- package/dist/tools/verify-citations.js +41 -4
- package/dist/types/eval.d.ts +52 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/query.d.ts +25 -0
- package/package.json +8 -1
- package/server.json +2 -2
- package/dist/dashboard/assets/index-VI_nbMfN.js +0 -10
package/dist/eval/engine.js
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
import { getRulesForType, createCustomRule } from './rules/index.js';
|
|
2
2
|
import { generateEvalId } from '../utils/ids.js';
|
|
3
|
+
/**
|
|
4
|
+
* Every bundle eval_type="all" walks, in the order their categories are
|
|
5
|
+
* reported. 'custom' is last: it holds only deployed rules registered under
|
|
6
|
+
* evalType "custom" plus the call's inline custom_rules, so it is absent
|
|
7
|
+
* from the breakdown when neither exists.
|
|
8
|
+
*/
|
|
9
|
+
export const ALL_EVAL_TYPES = ['completeness', 'relevance', 'safety', 'cost', 'custom'];
|
|
10
|
+
/**
|
|
11
|
+
* What runs when a caller never chose a bundle. It used to be
|
|
12
|
+
* 'completeness', so a CI gate keyed on `passed` skipped PII and injection
|
|
13
|
+
* unless the caller knew to set eval_type — six of seven UAT personas read
|
|
14
|
+
* passed:true on PII-laden text with nothing in the payload saying the
|
|
15
|
+
* safety bundle had not run. Every bundle is the only default under which
|
|
16
|
+
* an omitted argument cannot silently narrow the verdict. The MCP tool and
|
|
17
|
+
* the HTTP ingest route both read this constant, so the two surfaces
|
|
18
|
+
* cannot default differently.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_EVAL_TYPE = 'all';
|
|
21
|
+
/**
|
|
22
|
+
* The one-line note both surfaces attach when the default ran, so a reader
|
|
23
|
+
* of the response knows the bundle was chosen for them and how to narrow it.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_EVAL_TYPE_NOTE = 'eval_type was omitted, so the default ran every bundle — completeness, relevance, safety, cost and any custom rules — the same as eval_type="all"; pass a single bundle name to narrow the run.';
|
|
3
26
|
export class EvalEngine {
|
|
4
27
|
additionalRules = new Map();
|
|
5
28
|
/**
|
|
@@ -9,18 +32,34 @@ export class EvalEngine {
|
|
|
9
32
|
* share a name with different definitions.
|
|
10
33
|
*/
|
|
11
34
|
rulesById = new Map();
|
|
35
|
+
/**
|
|
36
|
+
* Reverse index, so each rule's result can carry its deployed id
|
|
37
|
+
* (EvalRuleResult.ruleId) without mutating the rule object itself.
|
|
38
|
+
*/
|
|
39
|
+
idByRule = new Map();
|
|
12
40
|
threshold;
|
|
13
41
|
ruleThresholds;
|
|
14
42
|
constructor(threshold = 0.7, ruleThresholds) {
|
|
15
43
|
this.threshold = threshold;
|
|
16
44
|
this.ruleThresholds = ruleThresholds;
|
|
17
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Register a rule under a bundle. When `ruleId` is given the registration
|
|
48
|
+
* is IDEMPOTENT by id: registering an id that is already live replaces the
|
|
49
|
+
* earlier instance instead of adding a second one. That is what re-enable
|
|
50
|
+
* (delete_rule enabled:true) and a reload after edit need — without it,
|
|
51
|
+
* every toggle stacked another copy that fired alongside the first.
|
|
52
|
+
*/
|
|
18
53
|
registerRule(evalType, rule, ruleId) {
|
|
54
|
+
if (ruleId !== undefined && this.rulesById.has(ruleId)) {
|
|
55
|
+
this.unregisterRule(ruleId);
|
|
56
|
+
}
|
|
19
57
|
const existing = this.additionalRules.get(evalType) ?? [];
|
|
20
58
|
existing.push(rule);
|
|
21
59
|
this.additionalRules.set(evalType, existing);
|
|
22
60
|
if (ruleId !== undefined) {
|
|
23
61
|
this.rulesById.set(ruleId, { evalType, rule });
|
|
62
|
+
this.idByRule.set(rule, ruleId);
|
|
24
63
|
}
|
|
25
64
|
}
|
|
26
65
|
/**
|
|
@@ -34,6 +73,7 @@ export class EvalEngine {
|
|
|
34
73
|
if (!entry)
|
|
35
74
|
return false;
|
|
36
75
|
this.rulesById.delete(ruleId);
|
|
76
|
+
this.idByRule.delete(entry.rule);
|
|
37
77
|
const rules = this.additionalRules.get(entry.evalType);
|
|
38
78
|
if (rules) {
|
|
39
79
|
const idx = rules.indexOf(entry.rule);
|
|
@@ -42,14 +82,11 @@ export class EvalEngine {
|
|
|
42
82
|
}
|
|
43
83
|
return true;
|
|
44
84
|
}
|
|
85
|
+
/** Whether a deployed rule id is currently registered (and therefore firing). */
|
|
86
|
+
hasRule(ruleId) {
|
|
87
|
+
return this.rulesById.has(ruleId);
|
|
88
|
+
}
|
|
45
89
|
evaluate(evalType, context, customRules) {
|
|
46
|
-
// Merge system-level thresholds into customConfig (user-provided values take precedence)
|
|
47
|
-
if (this.ruleThresholds) {
|
|
48
|
-
context = {
|
|
49
|
-
...context,
|
|
50
|
-
customConfig: { ...this.ruleThresholds, ...context.customConfig },
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
90
|
/*
|
|
54
91
|
* Inline custom_rules are ADDITIVE, which is what evaluate_output's
|
|
55
92
|
* description promises in two places: "fires REGARDLESS of eval_type"
|
|
@@ -75,6 +112,40 @@ export class EvalEngine {
|
|
|
75
112
|
...(this.additionalRules.get(evalType) ?? []),
|
|
76
113
|
...(customRules ?? []).map((def) => createCustomRule(def)),
|
|
77
114
|
];
|
|
115
|
+
return this.run(evalType, rules, undefined, context);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* eval_type="all" (#370): every built-in bundle, each with the deployed
|
|
119
|
+
* rules registered under it, plus the rules deployed under "custom" and
|
|
120
|
+
* the call's inline custom_rules — in ONE pass, sharing one regex budget,
|
|
121
|
+
* so the whole call is bounded exactly like a single bundle. The overall
|
|
122
|
+
* verdict is the same arithmetic as a single bundle applied to every rule
|
|
123
|
+
* that ran (weighted score against the threshold, critical veto across
|
|
124
|
+
* all bundles); `categories` carries the same arithmetic per bundle.
|
|
125
|
+
*/
|
|
126
|
+
evaluateAll(context, customRules) {
|
|
127
|
+
const rules = [];
|
|
128
|
+
const categories = [];
|
|
129
|
+
for (const type of ALL_EVAL_TYPES) {
|
|
130
|
+
for (const rule of [...getRulesForType(type), ...(this.additionalRules.get(type) ?? [])]) {
|
|
131
|
+
rules.push(rule);
|
|
132
|
+
categories.push(type);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const def of customRules ?? []) {
|
|
136
|
+
rules.push(createCustomRule(def));
|
|
137
|
+
categories.push('custom');
|
|
138
|
+
}
|
|
139
|
+
return this.run('all', rules, categories, context);
|
|
140
|
+
}
|
|
141
|
+
run(evalType, rules, categories, context) {
|
|
142
|
+
// Merge system-level thresholds into customConfig (user-provided values take precedence)
|
|
143
|
+
if (this.ruleThresholds) {
|
|
144
|
+
context = {
|
|
145
|
+
...context,
|
|
146
|
+
customConfig: { ...this.ruleThresholds, ...context.customConfig },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
78
149
|
if (rules.length === 0) {
|
|
79
150
|
return {
|
|
80
151
|
id: generateEvalId(),
|
|
@@ -99,22 +170,26 @@ export class EvalEngine {
|
|
|
99
170
|
* rule it carries.
|
|
100
171
|
*/
|
|
101
172
|
const evalContext = { ...context, regexBudget: { breaches: 0 } };
|
|
102
|
-
const ruleResults = rules.map((rule) =>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
173
|
+
const ruleResults = rules.map((rule, i) => {
|
|
174
|
+
const raw = rule.evaluate(evalContext);
|
|
175
|
+
const ruleId = this.idByRule.get(rule);
|
|
176
|
+
const category = categories?.[i];
|
|
177
|
+
if (ruleId === undefined && category === undefined)
|
|
178
|
+
return raw;
|
|
179
|
+
// ruleId / category sit right after the name so a reader scanning
|
|
180
|
+
// rule_results sees WHICH deployed rule (and which bundle) spoke.
|
|
181
|
+
const { ruleName, ...rest } = raw;
|
|
182
|
+
return {
|
|
183
|
+
ruleName,
|
|
184
|
+
...(ruleId !== undefined ? { ruleId } : {}),
|
|
185
|
+
...(category !== undefined ? { category } : {}),
|
|
186
|
+
...rest,
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
const overall = this.summarize(rules, ruleResults);
|
|
190
|
+
const perCategory = categories ? this.categorize(rules, ruleResults, categories) : undefined;
|
|
116
191
|
// Handle "all rules skipped" — insufficient data
|
|
117
|
-
if (rulesEvaluated === 0) {
|
|
192
|
+
if (overall.rulesEvaluated === 0) {
|
|
118
193
|
const skipMessages = ruleResults
|
|
119
194
|
.filter((r) => r.skipped)
|
|
120
195
|
.map((r) => `[${r.ruleName}] ${r.skipReason ?? r.message}`);
|
|
@@ -122,9 +197,6 @@ export class EvalEngine {
|
|
|
122
197
|
// that EVERY critical rule that skipped is named here, and a caller
|
|
123
198
|
// whose only rules were critical ones should not have to infer that
|
|
124
199
|
// from insufficient_data alone.
|
|
125
|
-
const criticalSkippedAll = skippedIndices
|
|
126
|
-
.filter((i) => rules[i].critical === true)
|
|
127
|
-
.map((i) => ruleResults[i].ruleName);
|
|
128
200
|
return {
|
|
129
201
|
id: generateEvalId(),
|
|
130
202
|
eval_type: evalType,
|
|
@@ -138,64 +210,22 @@ export class EvalEngine {
|
|
|
138
210
|
...skipMessages,
|
|
139
211
|
],
|
|
140
212
|
rules_evaluated: 0,
|
|
141
|
-
rules_skipped: rulesSkipped,
|
|
213
|
+
rules_skipped: overall.rulesSkipped,
|
|
142
214
|
insufficient_data: true,
|
|
143
|
-
...(
|
|
215
|
+
...(overall.criticalSkipped.length > 0 ? { critical_skipped: overall.criticalSkipped } : {}),
|
|
216
|
+
...(perCategory ? { categories: perCategory } : {}),
|
|
144
217
|
};
|
|
145
218
|
}
|
|
146
|
-
// Weighted average across evaluated rules only (exclude skipped)
|
|
147
|
-
const totalWeight = evaluatedIndices.reduce((sum, i) => sum + rules[i].weight, 0);
|
|
148
|
-
const weightedScore = evaluatedIndices.reduce((sum, i) => {
|
|
149
|
-
const ruleScore = Number.isFinite(ruleResults[i].score) ? ruleResults[i].score : 0;
|
|
150
|
-
return sum + ruleScore * rules[i].weight;
|
|
151
|
-
}, 0);
|
|
152
|
-
const rawScore = totalWeight > 0 ? weightedScore / totalWeight : 0;
|
|
153
|
-
const score = Number.isFinite(rawScore) ? rawScore : 0;
|
|
154
|
-
/*
|
|
155
|
-
* Critical rules hard-fail. Before this existed, the weighted average
|
|
156
|
-
* routinely outvoted a genuine violation: an output containing a real
|
|
157
|
-
* SSN failed no_pii while the other safety rules passed, landing at
|
|
158
|
-
* ~0.765 — over the 0.7 threshold — so `passed`, the one field every
|
|
159
|
-
* automated gate keys on, said true about the product's flagship
|
|
160
|
-
* failure scenario. A detection that reports an all-clear is worse
|
|
161
|
-
* than no detection.
|
|
162
|
-
*
|
|
163
|
-
* Only EVALUATED failures count: a critical rule that skipped (missing
|
|
164
|
-
* context, broken config) has not judged the output and must not veto
|
|
165
|
-
* it. The score is left as-is — it stays a quality gradient; `passed`
|
|
166
|
-
* is the verdict, and the two answer different questions.
|
|
167
|
-
*/
|
|
168
|
-
const criticalFailures = evaluatedIndices
|
|
169
|
-
.filter((i) => rules[i].critical === true && !ruleResults[i].passed)
|
|
170
|
-
.map((i) => ruleResults[i].ruleName);
|
|
171
|
-
/*
|
|
172
|
-
* The other half of that sentence, surfaced as a field.
|
|
173
|
-
*
|
|
174
|
-
* A critical rule that SKIPPED is the fail-open seam between this
|
|
175
|
-
* release's two headline features: an adversary who knows a deployed
|
|
176
|
-
* critical regex can craft output that stalls it past the sandbox
|
|
177
|
-
* budget, and the rule then neither judges nor vetoes — so the eval
|
|
178
|
-
* returns passed=true with an EMPTY critical_failures on output that
|
|
179
|
-
* nobody actually cleared. The trade-off is deliberate (failing closed
|
|
180
|
-
* would let the same adversary force false violations on benign
|
|
181
|
-
* output), but before this field the only trace of it was a suggestions
|
|
182
|
-
* line — prose. A gate that must fail closed should not have to walk
|
|
183
|
-
* rule_results[].budgetExceeded to discover it was defeated.
|
|
184
|
-
*/
|
|
185
|
-
const criticalSkipped = skippedIndices
|
|
186
|
-
.filter((i) => rules[i].critical === true)
|
|
187
|
-
.map((i) => ruleResults[i].ruleName);
|
|
188
|
-
const passed = score >= this.threshold && criticalFailures.length === 0;
|
|
189
219
|
const suggestions = [];
|
|
190
220
|
for (const result of ruleResults) {
|
|
191
221
|
if (!result.passed && !result.skipped) {
|
|
192
222
|
suggestions.push(`[${result.ruleName}] ${result.message}`);
|
|
193
223
|
}
|
|
194
224
|
}
|
|
195
|
-
if (criticalFailures.length > 0 && score >= this.threshold) {
|
|
196
|
-
suggestions.push(`Critical rule(s) failed (${criticalFailures.join(', ')}) — passed=false regardless of the weighted score`);
|
|
225
|
+
if (overall.criticalFailures.length > 0 && overall.score >= this.threshold) {
|
|
226
|
+
suggestions.push(`Critical rule(s) failed (${overall.criticalFailures.join(', ')}) — passed=false regardless of the weighted score`);
|
|
197
227
|
}
|
|
198
|
-
if (rulesSkipped > 0) {
|
|
228
|
+
if (overall.rulesSkipped > 0) {
|
|
199
229
|
/*
|
|
200
230
|
* Say WHY each rule skipped. The old line hardcoded "(missing
|
|
201
231
|
* context)" — but a rule whose regex was killed at the sandbox budget
|
|
@@ -207,10 +237,10 @@ export class EvalEngine {
|
|
|
207
237
|
const skippedParts = ruleResults
|
|
208
238
|
.filter((r) => r.skipped)
|
|
209
239
|
.map((r) => `${r.ruleName} (${r.skipReason ?? 'missing context'})`);
|
|
210
|
-
suggestions.push(`${rulesSkipped} rule(s) skipped — excluded from the weighted score: ${skippedParts.join('; ')}`);
|
|
240
|
+
suggestions.push(`${overall.rulesSkipped} rule(s) skipped — excluded from the weighted score: ${skippedParts.join('; ')}`);
|
|
211
241
|
}
|
|
212
|
-
if (criticalSkipped.length > 0) {
|
|
213
|
-
suggestions.push(`Critical rule(s) did NOT judge this output (${criticalSkipped.join(', ')}) — ` +
|
|
242
|
+
if (overall.criticalSkipped.length > 0) {
|
|
243
|
+
suggestions.push(`Critical rule(s) did NOT judge this output (${overall.criticalSkipped.join(', ')}) — ` +
|
|
214
244
|
'they skipped, so they could not veto. This evaluation is "unknown" on those ' +
|
|
215
245
|
'checks, not "clean"; a gate that must fail closed should treat critical_skipped ' +
|
|
216
246
|
'as a failure.');
|
|
@@ -220,15 +250,119 @@ export class EvalEngine {
|
|
|
220
250
|
eval_type: evalType,
|
|
221
251
|
output_text: context.output,
|
|
222
252
|
expected_text: context.expected,
|
|
223
|
-
score: Math.round(score * 1000) / 1000,
|
|
224
|
-
passed,
|
|
253
|
+
score: Math.round(overall.score * 1000) / 1000,
|
|
254
|
+
passed: overall.passed,
|
|
225
255
|
rule_results: ruleResults,
|
|
226
256
|
suggestions,
|
|
227
|
-
rules_evaluated: rulesEvaluated,
|
|
228
|
-
rules_skipped: rulesSkipped,
|
|
257
|
+
rules_evaluated: overall.rulesEvaluated,
|
|
258
|
+
rules_skipped: overall.rulesSkipped,
|
|
229
259
|
insufficient_data: false,
|
|
230
|
-
...(criticalFailures.length > 0 ? { critical_failures: criticalFailures } : {}),
|
|
231
|
-
...(criticalSkipped.length > 0 ? { critical_skipped: criticalSkipped } : {}),
|
|
260
|
+
...(overall.criticalFailures.length > 0 ? { critical_failures: overall.criticalFailures } : {}),
|
|
261
|
+
...(overall.criticalSkipped.length > 0 ? { critical_skipped: overall.criticalSkipped } : {}),
|
|
262
|
+
...(perCategory ? { categories: perCategory } : {}),
|
|
232
263
|
};
|
|
233
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Weighted average over the rules that ran, plus the critical veto.
|
|
267
|
+
*
|
|
268
|
+
* Critical rules hard-fail. Before this existed, the weighted average
|
|
269
|
+
* routinely outvoted a genuine violation: an output containing a real
|
|
270
|
+
* SSN failed no_pii while the other safety rules passed, landing at
|
|
271
|
+
* ~0.765 — over the 0.7 threshold — so `passed`, the one field every
|
|
272
|
+
* automated gate keys on, said true about the product's flagship
|
|
273
|
+
* failure scenario. A detection that reports an all-clear is worse
|
|
274
|
+
* than no detection.
|
|
275
|
+
*
|
|
276
|
+
* Only EVALUATED failures count: a critical rule that skipped (missing
|
|
277
|
+
* context, broken config) has not judged the output and must not veto
|
|
278
|
+
* it. The score is left as-is — it stays a quality gradient; `passed`
|
|
279
|
+
* is the verdict, and the two answer different questions.
|
|
280
|
+
*
|
|
281
|
+
* A critical rule that SKIPPED is the fail-open seam between the
|
|
282
|
+
* release's two headline features: an adversary who knows a deployed
|
|
283
|
+
* critical regex can craft output that stalls it past the sandbox
|
|
284
|
+
* budget, and the rule then neither judges nor vetoes — so the eval
|
|
285
|
+
* returns passed=true with an EMPTY critical_failures on output that
|
|
286
|
+
* nobody actually cleared. The trade-off is deliberate (failing closed
|
|
287
|
+
* would let the same adversary force false violations on benign
|
|
288
|
+
* output), but before `criticalSkipped` the only trace of it was a
|
|
289
|
+
* suggestions line — prose. A gate that must fail closed should not
|
|
290
|
+
* have to walk rule_results[].budgetExceeded to discover it was defeated.
|
|
291
|
+
*/
|
|
292
|
+
summarize(rules, ruleResults) {
|
|
293
|
+
const evaluatedIndices = [];
|
|
294
|
+
const skippedIndices = [];
|
|
295
|
+
for (let i = 0; i < ruleResults.length; i++) {
|
|
296
|
+
if (ruleResults[i].skipped) {
|
|
297
|
+
skippedIndices.push(i);
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
evaluatedIndices.push(i);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const criticalSkipped = skippedIndices
|
|
304
|
+
.filter((i) => rules[i].critical === true)
|
|
305
|
+
.map((i) => ruleResults[i].ruleName);
|
|
306
|
+
if (evaluatedIndices.length === 0) {
|
|
307
|
+
return {
|
|
308
|
+
score: 0,
|
|
309
|
+
passed: false,
|
|
310
|
+
rulesEvaluated: 0,
|
|
311
|
+
rulesSkipped: skippedIndices.length,
|
|
312
|
+
criticalFailures: [],
|
|
313
|
+
criticalSkipped,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
// Weighted average across evaluated rules only (exclude skipped)
|
|
317
|
+
const totalWeight = evaluatedIndices.reduce((sum, i) => sum + rules[i].weight, 0);
|
|
318
|
+
const weightedScore = evaluatedIndices.reduce((sum, i) => {
|
|
319
|
+
const ruleScore = Number.isFinite(ruleResults[i].score) ? ruleResults[i].score : 0;
|
|
320
|
+
return sum + ruleScore * rules[i].weight;
|
|
321
|
+
}, 0);
|
|
322
|
+
const rawScore = totalWeight > 0 ? weightedScore / totalWeight : 0;
|
|
323
|
+
const score = Number.isFinite(rawScore) ? rawScore : 0;
|
|
324
|
+
const criticalFailures = evaluatedIndices
|
|
325
|
+
.filter((i) => rules[i].critical === true && !ruleResults[i].passed)
|
|
326
|
+
.map((i) => ruleResults[i].ruleName);
|
|
327
|
+
return {
|
|
328
|
+
score,
|
|
329
|
+
passed: score >= this.threshold && criticalFailures.length === 0,
|
|
330
|
+
rulesEvaluated: evaluatedIndices.length,
|
|
331
|
+
rulesSkipped: skippedIndices.length,
|
|
332
|
+
criticalFailures,
|
|
333
|
+
criticalSkipped,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
/** The per-bundle breakdown for eval_type="all": summarize() over each bundle's slice. */
|
|
337
|
+
categorize(rules, ruleResults, categories) {
|
|
338
|
+
const breakdown = {};
|
|
339
|
+
for (const type of ALL_EVAL_TYPES) {
|
|
340
|
+
const indices = categories.flatMap((c, i) => (c === type ? [i] : []));
|
|
341
|
+
if (indices.length === 0)
|
|
342
|
+
continue;
|
|
343
|
+
const verdict = this.summarize(indices.map((i) => rules[i]), indices.map((i) => ruleResults[i]));
|
|
344
|
+
/*
|
|
345
|
+
* A bundle whose every rule skipped was not judged (#406). Reporting
|
|
346
|
+
* it as passed:false / score:0 read as "failing" to anyone regrouping
|
|
347
|
+
* by category — cost "failed" on a call that carried no cost data.
|
|
348
|
+
* Inside the breakdown, null is the honest value: neither passing
|
|
349
|
+
* nor failing, and it never counted toward the overall verdict
|
|
350
|
+
* (summarize() already excludes skipped rules). The TOP-LEVEL
|
|
351
|
+
* `passed` is deliberately not made nullable — it is the verdict a
|
|
352
|
+
* gate keys on, and a gate must fail closed when nothing was judged;
|
|
353
|
+
* `insufficient_data: true` is the "unknown" marker at that level.
|
|
354
|
+
*/
|
|
355
|
+
const judged = verdict.rulesEvaluated > 0;
|
|
356
|
+
breakdown[type] = {
|
|
357
|
+
score: judged ? Math.round(verdict.score * 1000) / 1000 : null,
|
|
358
|
+
passed: judged ? verdict.passed : null,
|
|
359
|
+
rules_evaluated: verdict.rulesEvaluated,
|
|
360
|
+
rules_skipped: verdict.rulesSkipped,
|
|
361
|
+
insufficient_data: !judged,
|
|
362
|
+
...(verdict.criticalFailures.length > 0 ? { critical_failures: verdict.criticalFailures } : {}),
|
|
363
|
+
...(verdict.criticalSkipped.length > 0 ? { critical_skipped: verdict.criticalSkipped } : {}),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
return breakdown;
|
|
367
|
+
}
|
|
234
368
|
}
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import type { EvalRule } from '../../types/eval.js';
|
|
2
|
+
/**
|
|
3
|
+
* Light stemmer: plurals, -ing/-ed/-ly, -ation/-ator/-ate/-ion, a trailing
|
|
4
|
+
* e, and a doubled final consonant. Crude on purpose (see the header):
|
|
5
|
+
* both sides are stemmed identically.
|
|
6
|
+
*/
|
|
7
|
+
export declare function stemTerm(word: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Content terms of a text: fenced code removed, camelCase split, everything
|
|
10
|
+
* that is not a run of three or more letters treated as a separator (so
|
|
11
|
+
* paths, flags, snake_case and dotted identifiers fall apart into their
|
|
12
|
+
* words and numbers vanish), stopwords dropped, the rest stemmed.
|
|
13
|
+
*/
|
|
14
|
+
export declare function contentTerms(text: string): string[];
|
|
2
15
|
export declare const keywordOverlap: EvalRule;
|
|
3
16
|
export declare const topicConsistency: EvalRule;
|
|
4
17
|
export declare const relevanceRules: EvalRule[];
|