@iris-eval/mcp-server 0.9.0 → 0.10.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 +1 -1
- package/dist/config/defaults.js +15 -0
- package/dist/dashboard/assets/{index-Cz8_oOqG.js → index-CeJbaq6m.js} +1 -1
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/routes/traces.js +2 -2
- package/dist/dashboard/seed-demo-data.js +1 -1
- package/dist/eval/citation-verify/verifier.d.ts +16 -1
- package/dist/eval/citation-verify/verifier.js +14 -4
- package/dist/eval/compose.d.ts +57 -0
- package/dist/eval/compose.js +179 -0
- package/dist/eval/criticality.d.ts +7 -0
- package/dist/eval/decision-moment.js +33 -4
- package/dist/eval/engine.d.ts +5 -2
- package/dist/eval/engine.js +81 -13
- package/dist/eval/llm-judge/evaluator.d.ts +20 -0
- package/dist/eval/llm-judge/evaluator.js +10 -1
- package/dist/eval/published-accuracy.d.ts +22 -22
- package/dist/eval/published-accuracy.js +11 -11
- package/dist/eval/risk.d.ts +60 -0
- package/dist/eval/risk.js +187 -0
- package/dist/eval/rules/completeness.js +5 -1
- package/dist/eval/rules/cost.d.ts +1 -1
- package/dist/eval/rules/cost.js +6 -6
- package/dist/eval/rules/custom.js +1 -0
- package/dist/eval/rules/relevance.js +7 -2
- package/dist/eval/rules/safety.d.ts +6 -2
- package/dist/eval/rules/safety.js +55 -59
- package/dist/eval/seeded-random.d.ts +4 -0
- package/dist/eval/seeded-random.js +36 -0
- package/dist/eval/stamp.d.ts +1 -1
- package/dist/eval/stamp.js +1 -0
- package/dist/eval/text/checksums.d.ts +23 -0
- package/dist/eval/text/checksums.js +97 -0
- package/dist/eval/text/normalise.d.ts +30 -0
- package/dist/eval/text/normalise.js +265 -0
- package/dist/eval/text/sentences.d.ts +15 -0
- package/dist/eval/text/sentences.js +149 -0
- package/dist/self-test.js +3 -3
- package/dist/storage/sqlite-adapter.js +16 -2
- package/dist/tools/evaluate-output.js +2 -2
- package/dist/tools/evaluate-with-llm-judge.d.ts +3 -0
- package/dist/tools/evaluate-with-llm-judge.js +29 -1
- package/dist/tools/verify-citations.d.ts +2 -1
- package/dist/tools/verify-citations.js +25 -4
- package/dist/types/config.d.ts +35 -0
- package/dist/types/eval.d.ts +51 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<!-- Stop shipping agents on vibes is filled from .claims.json brand.tagline at build time (vite.config.ts) — never restate the tagline here. -->
|
|
8
8
|
<title>Iris — Stop shipping agents on vibes</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-CeJbaq6m.js"></script>
|
|
10
10
|
<link rel="stylesheet" crossorigin href="/assets/index-D0cFfBqn.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
@@ -82,8 +82,8 @@ export function registerTraceRoutes(router, storage, options) {
|
|
|
82
82
|
const evalTypeOmitted = body.eval_type === undefined;
|
|
83
83
|
const evalType = body.eval_type ?? DEFAULT_EVAL_TYPE;
|
|
84
84
|
const evaluation = evalType === 'all'
|
|
85
|
-
? options.evalEngine.evaluateAll(context)
|
|
86
|
-
: options.evalEngine.evaluate(evalType, context);
|
|
85
|
+
? await options.evalEngine.evaluateAll(context)
|
|
86
|
+
: await options.evalEngine.evaluate(evalType, context);
|
|
87
87
|
evaluation.trace_id = traceId;
|
|
88
88
|
await storage.insertEvalResult(tenantId, evaluation);
|
|
89
89
|
// The same serializer as evaluate_output (src/eval/response.ts): the
|
|
@@ -574,7 +574,7 @@ function simulateCostEval(costUsd, tokenUsage, shouldPass) {
|
|
|
574
574
|
: `Cost ($${costUsd.toFixed(4)}) exceeds threshold ($${threshold.toFixed(4)})`,
|
|
575
575
|
};
|
|
576
576
|
const r2 = {
|
|
577
|
-
ruleName: '
|
|
577
|
+
ruleName: 'verbosity_ratio',
|
|
578
578
|
passed: ratio <= maxRatio,
|
|
579
579
|
score: ratio <= maxRatio ? 1 : Math.max(0, 1 - (ratio - maxRatio) / maxRatio),
|
|
580
580
|
message: ratio <= maxRatio
|
|
@@ -33,7 +33,22 @@ export interface VerifiedCitation {
|
|
|
33
33
|
}
|
|
34
34
|
export interface VerifyCitationsResult {
|
|
35
35
|
overallScore: number | null;
|
|
36
|
-
|
|
36
|
+
/**
|
|
37
|
+
* The verdict, and it is **null when nothing was judged**.
|
|
38
|
+
*
|
|
39
|
+
* Until 0.10.0 this was `true` in that case: no citation resolved, or the
|
|
40
|
+
* judge failed on every one, and the tool said the output passed. A
|
|
41
|
+
* caller reading `passed` shipped an answer whose sources had not been
|
|
42
|
+
* checked at all. There is no verdict when nothing was verified, and null
|
|
43
|
+
* is what says so.
|
|
44
|
+
*
|
|
45
|
+
* When citations WERE judged, the rule is counts and not a proportion:
|
|
46
|
+
* every judged citation must be supported. A proportion let one
|
|
47
|
+
* fabricated source among three real ones score 0.67 and pass.
|
|
48
|
+
*/
|
|
49
|
+
passed: boolean | null;
|
|
50
|
+
/** Judged citations the judge ruled unsupported. The number the verdict turns on. */
|
|
51
|
+
totalUnsupported: number;
|
|
37
52
|
citations: VerifiedCitation[];
|
|
38
53
|
totalCostUsd: number;
|
|
39
54
|
totalCitationsFound: number;
|
|
@@ -247,13 +247,23 @@ export async function verifyCitations(params) {
|
|
|
247
247
|
// unsupported would make a judge outage on 5 of 10 supported citations
|
|
248
248
|
// score 0.5, indistinguishable from fabrication.
|
|
249
249
|
const overallScore = totalJudged > 0 ? Math.round((totalSupported / totalJudged) * 100) / 100 : null;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
250
|
+
const totalUnsupported = totalJudged - totalSupported;
|
|
251
|
+
/*
|
|
252
|
+
* Counts, not a proportion, and null when nothing was judged.
|
|
253
|
+
*
|
|
254
|
+
* The old rule was `overallScore >= 0.5`, with `true` when the score was
|
|
255
|
+
* null. Both halves were wrong. A proportion let one fabricated source
|
|
256
|
+
* among three real ones score 0.67 and pass — a citation either supports
|
|
257
|
+
* the claim or it does not, and one that does not is the finding. And
|
|
258
|
+
* "nothing was judged" was reported as a pass, so an output whose sources
|
|
259
|
+
* never resolved, or whose every judge call failed, came back looking
|
|
260
|
+
* verified. There is no verdict when nothing was verified.
|
|
261
|
+
*/
|
|
262
|
+
const passed = totalJudged === 0 ? null : totalUnsupported === 0;
|
|
254
263
|
return {
|
|
255
264
|
overallScore,
|
|
256
265
|
passed,
|
|
266
|
+
totalUnsupported,
|
|
257
267
|
citations: out,
|
|
258
268
|
totalCostUsd: Math.round(totalCost * 1_000_000) / 1_000_000,
|
|
259
269
|
totalCitationsFound: totalFound,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { EvalResult, EvalRuleResult, Interpretation, Need, Verdict } from '../types/eval.js';
|
|
2
|
+
import { type PriorMode } from './risk.js';
|
|
3
|
+
export interface ComposeConfig {
|
|
4
|
+
/** `risk` composes by kind; `legacy` runs the pre-0.10.0 arithmetic. */
|
|
5
|
+
composer: 'risk' | 'legacy';
|
|
6
|
+
/** How many wrongly blocked builds one shipped failure is worth. τ = 1 / (1 + c). */
|
|
7
|
+
falsePassCost: number;
|
|
8
|
+
/** What a critical rule that could not answer does to the verdict. */
|
|
9
|
+
onCriticalSkipped: 'unknown' | 'fail' | 'pass';
|
|
10
|
+
/** Inputs the deployment insists every evaluation carries; absent ones make the verdict unknown. */
|
|
11
|
+
requiredEvidence: readonly Need[];
|
|
12
|
+
/** Whether a shipped default threshold decides the verdict, or only advises. */
|
|
13
|
+
defaultsGate: boolean;
|
|
14
|
+
/** The prior that an output is bad, before any rule speaks. */
|
|
15
|
+
prior: number;
|
|
16
|
+
/** How that prior is spread over the failure classes the detectors examine. */
|
|
17
|
+
priorMode: PriorMode;
|
|
18
|
+
}
|
|
19
|
+
export declare const DEFAULT_COMPOSE: ComposeConfig;
|
|
20
|
+
/** The risk threshold a loss ratio implies: block when the expected loss of passing exceeds that of blocking. */
|
|
21
|
+
export declare function tau(falsePassCost: number): number;
|
|
22
|
+
/**
|
|
23
|
+
* Whether a policy rule DECIDES the verdict here, or only advises.
|
|
24
|
+
*
|
|
25
|
+
* "A default is not your policy." A shipped threshold — a cost ceiling of
|
|
26
|
+
* $0.50, a length floor of 50 characters — is our guess about a deployment
|
|
27
|
+
* we have never seen, and stopping someone's build on it is presumptuous.
|
|
28
|
+
* A threshold the deployment SET is their decision and gates.
|
|
29
|
+
*
|
|
30
|
+
* The distinction is not a list of rule names. For a BUILT-IN policy it is
|
|
31
|
+
* whether the number the rule compared against is one we chose, which every
|
|
32
|
+
* result already records as `thresholdSource` on its count evidence (0.9.0);
|
|
33
|
+
* a policy with no number at all — "the output is empty" — is structural,
|
|
34
|
+
* has no guess in it, and gates.
|
|
35
|
+
*
|
|
36
|
+
* A CUSTOM rule is different: its severity is the deployment's own statement
|
|
37
|
+
* of how much it matters, made when the rule was deployed. High and critical
|
|
38
|
+
* gate (they resolve to critical); medium and low advise, which is the
|
|
39
|
+
* contract `deploy_rule` has always had. An inline rule passed in the call
|
|
40
|
+
* carries no severity and advises, for the same reason.
|
|
41
|
+
*/
|
|
42
|
+
export declare function decides(r: EvalRuleResult, defaultsGate: boolean): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The verdict for one evaluation. The weighted mean is never consulted: it
|
|
45
|
+
* survives as a quality gradient on the score field and is never re-meant.
|
|
46
|
+
*/
|
|
47
|
+
export declare function compose(result: Pick<EvalResult, 'rule_results' | 'score' | 'insufficient_data' | 'rules_evaluated'>, cfg: ComposeConfig): Verdict;
|
|
48
|
+
/**
|
|
49
|
+
* The sentences a reader needs that the verdict alone does not carry.
|
|
50
|
+
*
|
|
51
|
+
* The one that must exist: when a rule visibly FIRED and the verdict still
|
|
52
|
+
* passed, say why and name the one setting that would change it. Without
|
|
53
|
+
* it, "cost_under_threshold failed" beside "passed: true" reads as a bug,
|
|
54
|
+
* and that is the first thing a builder who never opens a config file will
|
|
55
|
+
* meet.
|
|
56
|
+
*/
|
|
57
|
+
export declare function interpretations(result: Pick<EvalResult, 'rule_results'>, verdict: Verdict, cfg: ComposeConfig): Interpretation[];
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { riskEstimate, DEFAULT_PRIOR, DEFAULT_PRIOR_MODE, DEFAULT_FALSE_PASS_COST } from './risk.js';
|
|
2
|
+
export const DEFAULT_COMPOSE = {
|
|
3
|
+
composer: 'risk',
|
|
4
|
+
falsePassCost: DEFAULT_FALSE_PASS_COST,
|
|
5
|
+
onCriticalSkipped: 'unknown',
|
|
6
|
+
requiredEvidence: [],
|
|
7
|
+
defaultsGate: false,
|
|
8
|
+
prior: DEFAULT_PRIOR,
|
|
9
|
+
priorMode: DEFAULT_PRIOR_MODE,
|
|
10
|
+
};
|
|
11
|
+
/** The risk threshold a loss ratio implies: block when the expected loss of passing exceeds that of blocking. */
|
|
12
|
+
export function tau(falsePassCost) {
|
|
13
|
+
return 1 / (1 + falsePassCost);
|
|
14
|
+
}
|
|
15
|
+
const isCritical = (r) => r.critical === true;
|
|
16
|
+
const fired = (r) => !r.skipped && r.passed === false;
|
|
17
|
+
/**
|
|
18
|
+
* Whether a policy rule DECIDES the verdict here, or only advises.
|
|
19
|
+
*
|
|
20
|
+
* "A default is not your policy." A shipped threshold — a cost ceiling of
|
|
21
|
+
* $0.50, a length floor of 50 characters — is our guess about a deployment
|
|
22
|
+
* we have never seen, and stopping someone's build on it is presumptuous.
|
|
23
|
+
* A threshold the deployment SET is their decision and gates.
|
|
24
|
+
*
|
|
25
|
+
* The distinction is not a list of rule names. For a BUILT-IN policy it is
|
|
26
|
+
* whether the number the rule compared against is one we chose, which every
|
|
27
|
+
* result already records as `thresholdSource` on its count evidence (0.9.0);
|
|
28
|
+
* a policy with no number at all — "the output is empty" — is structural,
|
|
29
|
+
* has no guess in it, and gates.
|
|
30
|
+
*
|
|
31
|
+
* A CUSTOM rule is different: its severity is the deployment's own statement
|
|
32
|
+
* of how much it matters, made when the rule was deployed. High and critical
|
|
33
|
+
* gate (they resolve to critical); medium and low advise, which is the
|
|
34
|
+
* contract `deploy_rule` has always had. An inline rule passed in the call
|
|
35
|
+
* carries no severity and advises, for the same reason.
|
|
36
|
+
*/
|
|
37
|
+
export function decides(r, defaultsGate) {
|
|
38
|
+
if (isCritical(r))
|
|
39
|
+
return true;
|
|
40
|
+
if (defaultsGate)
|
|
41
|
+
return true;
|
|
42
|
+
if (r.origin === 'custom')
|
|
43
|
+
return false;
|
|
44
|
+
const ourDefault = (r.evidence ?? []).some((e) => e.type === 'count' && e.threshold !== undefined && (e.thresholdSource ?? 'default') === 'default');
|
|
45
|
+
return !ourDefault;
|
|
46
|
+
}
|
|
47
|
+
/** The inputs at least one evaluated rule actually read. */
|
|
48
|
+
function inputsSeen(rows) {
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
for (const r of rows)
|
|
51
|
+
if (!r.skipped)
|
|
52
|
+
for (const n of r.saw ?? [])
|
|
53
|
+
seen.add(n);
|
|
54
|
+
return seen;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The verdict for one evaluation. The weighted mean is never consulted: it
|
|
58
|
+
* survives as a quality gradient on the score field and is never re-meant.
|
|
59
|
+
*/
|
|
60
|
+
export function compose(result, cfg) {
|
|
61
|
+
const rows = result.rule_results;
|
|
62
|
+
const evaluated = result.rules_evaluated ?? rows.filter((r) => !r.skipped).length;
|
|
63
|
+
if (result.insufficient_data || evaluated === 0) {
|
|
64
|
+
return { state: 'unknown', passed: false, basis: 'no_rules', by: [], risk: null };
|
|
65
|
+
}
|
|
66
|
+
/*
|
|
67
|
+
* 1. Gates: a policy whose author has already decided — and a JUDGMENT,
|
|
68
|
+
* for the same reason. Nobody runs a judge by accident: the caller chose
|
|
69
|
+
* the template, supplied the key and paid for the answer, so a failing
|
|
70
|
+
* judgment decides rather than being weighed against anything. It also
|
|
71
|
+
* cannot be weighed: a judgment carries no published error rate until a
|
|
72
|
+
* measured run exists for its template and model, so the risk layer would
|
|
73
|
+
* drop it silently and a paid-for "fail" would read as clean.
|
|
74
|
+
*/
|
|
75
|
+
const gates = rows.filter((r) => fired(r) && ((r.kind === 'policy' && decides(r, cfg.defaultsGate)) || r.kind === 'judgment'));
|
|
76
|
+
if (gates.length > 0) {
|
|
77
|
+
return { state: 'fail', passed: false, basis: 'policy_gate', by: gates.map((r) => r.ruleName), risk: null };
|
|
78
|
+
}
|
|
79
|
+
/*
|
|
80
|
+
* 2. Vetoes: an effectively-critical rule that is not a policy. Keyed on
|
|
81
|
+
* "not a policy" rather than on the two detecting kinds, so a rule built
|
|
82
|
+
* by hand without metadata — a test double, an embedder's own rule —
|
|
83
|
+
* still vetoes when it is marked critical. Silently ignoring a critical
|
|
84
|
+
* rule because it forgot to declare its kind is the failure mode this
|
|
85
|
+
* composer exists to remove, not one to introduce.
|
|
86
|
+
*/
|
|
87
|
+
const vetoes = rows.filter((r) => r.kind !== 'policy' && fired(r) && isCritical(r));
|
|
88
|
+
if (vetoes.length > 0) {
|
|
89
|
+
return { state: 'fail', passed: false, basis: 'detector_veto', by: vetoes.map((r) => r.ruleName), risk: null };
|
|
90
|
+
}
|
|
91
|
+
/*
|
|
92
|
+
* 3. Asked and could not answer. `not_applicable` is NEVER this: a
|
|
93
|
+
* trajectory rule with no tool calls was not asked, and treating that as
|
|
94
|
+
* unknown would make every text-only evaluation unknown, which is worse
|
|
95
|
+
* than the fail-open it replaces.
|
|
96
|
+
*/
|
|
97
|
+
const unknown = rows.filter((r) => isCritical(r) && r.skipped === true && r.skipClass !== undefined && r.skipClass !== 'not_applicable');
|
|
98
|
+
if (unknown.length > 0 && cfg.onCriticalSkipped !== 'pass') {
|
|
99
|
+
const by = unknown.map((r) => r.ruleName);
|
|
100
|
+
return cfg.onCriticalSkipped === 'fail'
|
|
101
|
+
? { state: 'fail', passed: false, basis: 'critical_unknown', by, risk: null }
|
|
102
|
+
: { state: 'unknown', passed: false, basis: 'critical_unknown', by, risk: null };
|
|
103
|
+
}
|
|
104
|
+
// 4. Evidence the deployment insists on.
|
|
105
|
+
if (cfg.requiredEvidence.length > 0) {
|
|
106
|
+
const seen = inputsSeen(rows);
|
|
107
|
+
const missing = cfg.requiredEvidence.filter((n) => !seen.has(n));
|
|
108
|
+
if (missing.length > 0) {
|
|
109
|
+
return { state: 'unknown', passed: false, basis: 'required_evidence_missing', by: [...missing], risk: null };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// 5. Everything that carries a published error rate, as one probability.
|
|
113
|
+
const risk = riskEstimate(result, cfg.prior, cfg.priorMode);
|
|
114
|
+
if (risk === null) {
|
|
115
|
+
return { state: 'pass', passed: true, basis: 'clean', by: [], risk: null };
|
|
116
|
+
}
|
|
117
|
+
const t = tau(cfg.falsePassCost);
|
|
118
|
+
const confidence = risk.lo <= t && t <= risk.hi ? 'marginal' : 'decisive';
|
|
119
|
+
if (risk.pBad > t) {
|
|
120
|
+
const by = Object.entries(risk.perClass)
|
|
121
|
+
.filter(([, q]) => q !== null && q !== undefined && q > 0.5)
|
|
122
|
+
.map(([cls]) => cls);
|
|
123
|
+
return { state: 'fail', passed: false, basis: 'risk_over_loss', by, risk, confidence };
|
|
124
|
+
}
|
|
125
|
+
return { state: 'pass', passed: true, basis: 'clean', by: [], risk, confidence };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The sentences a reader needs that the verdict alone does not carry.
|
|
129
|
+
*
|
|
130
|
+
* The one that must exist: when a rule visibly FIRED and the verdict still
|
|
131
|
+
* passed, say why and name the one setting that would change it. Without
|
|
132
|
+
* it, "cost_under_threshold failed" beside "passed: true" reads as a bug,
|
|
133
|
+
* and that is the first thing a builder who never opens a config file will
|
|
134
|
+
* meet.
|
|
135
|
+
*/
|
|
136
|
+
export function interpretations(result, verdict, cfg) {
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const r of result.rule_results) {
|
|
139
|
+
if (!fired(r))
|
|
140
|
+
continue;
|
|
141
|
+
if (verdict.by.includes(r.ruleName))
|
|
142
|
+
continue;
|
|
143
|
+
if (r.kind === 'policy' && !decides(r, cfg.defaultsGate)) {
|
|
144
|
+
out.push({
|
|
145
|
+
severity: 'warn',
|
|
146
|
+
addressee: 'operator',
|
|
147
|
+
rule: r.ruleName,
|
|
148
|
+
text: `${r.ruleName} failed against a threshold Iris ships, not one you set, so it did not decide this verdict. Set it in your configuration to make it a gate, or set eval.defaultsGate to true to make every shipped default gate.`,
|
|
149
|
+
configKey: 'eval.defaultsGate',
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (verdict.state === 'pass') {
|
|
154
|
+
out.push({
|
|
155
|
+
severity: 'note',
|
|
156
|
+
addressee: 'operator',
|
|
157
|
+
rule: r.ruleName,
|
|
158
|
+
text: `${r.ruleName} failed but the verdict passed: on its published accuracy this rule alone does not carry the risk past your loss threshold. Lower eval.falsePassCost to block on weaker evidence.`,
|
|
159
|
+
configKey: 'eval.falsePassCost',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (verdict.basis === 'critical_unknown') {
|
|
164
|
+
out.push({
|
|
165
|
+
severity: 'block',
|
|
166
|
+
addressee: 'operator',
|
|
167
|
+
text: `A critical check was asked and could not answer (${verdict.by.join(', ')}), so this verdict is unknown rather than clean. Set eval.onCriticalSkipped to "pass" to accept that risk, or to "fail" to treat it as a failure.`,
|
|
168
|
+
configKey: 'eval.onCriticalSkipped',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (verdict.confidence === 'marginal') {
|
|
172
|
+
out.push({
|
|
173
|
+
severity: 'note',
|
|
174
|
+
addressee: 'operator',
|
|
175
|
+
text: 'The credible interval on this risk estimate straddles your threshold, so this verdict could go either way on the evidence available. Treat it as a close call rather than a clear one.',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
@@ -6,6 +6,13 @@ export interface CriticalityOverrides {
|
|
|
6
6
|
criticalRules?: string[];
|
|
7
7
|
/** Built-in rule names demoted from critical. */
|
|
8
8
|
nonCriticalRules?: string[];
|
|
9
|
+
composer?: 'risk' | 'legacy';
|
|
10
|
+
falsePassCost?: number;
|
|
11
|
+
onCriticalSkipped?: 'unknown' | 'fail' | 'pass';
|
|
12
|
+
requiredEvidence?: string[];
|
|
13
|
+
defaultsGate?: boolean;
|
|
14
|
+
prior?: number;
|
|
15
|
+
priorMode?: 'per-output' | 'per-class';
|
|
9
16
|
}
|
|
10
17
|
export interface EffectiveCriticality {
|
|
11
18
|
critical: boolean;
|
|
@@ -104,14 +104,34 @@ function computeRuleSnapshot(evals) {
|
|
|
104
104
|
}
|
|
105
105
|
return { failed, skipped, passedCount, totalCount };
|
|
106
106
|
}
|
|
107
|
+
/*
|
|
108
|
+
* The moment SHOWS the verdict each evaluation reached; it does not compute
|
|
109
|
+
* a second one.
|
|
110
|
+
*
|
|
111
|
+
* It used to count failed rules: no failures meant pass, no passes meant
|
|
112
|
+
* fail, anything else meant partial. From 0.10.0 those two answers diverge,
|
|
113
|
+
* and the divergence is the whole point of the composer. An evaluation can
|
|
114
|
+
* pass with a rule visibly failed — a shipped default that only advises, or
|
|
115
|
+
* evidence too weak to carry the risk past the deployment's loss threshold
|
|
116
|
+
* — and the old arithmetic would have called that "partial", contradicting
|
|
117
|
+
* the verdict the tool returned for the same evaluation.
|
|
118
|
+
*
|
|
119
|
+
* "partial" now means what it says: several evaluations of one trace and
|
|
120
|
+
* they did not agree. An `unknown` verdict reads as unevaluated, because
|
|
121
|
+
* that is what it is — asked, and unable to answer.
|
|
122
|
+
*/
|
|
107
123
|
function computeVerdict(evals, snapshot) {
|
|
108
124
|
if (evals.length === 0)
|
|
109
125
|
return 'unevaluated';
|
|
110
126
|
if (snapshot.totalCount - snapshot.skipped.length === 0)
|
|
111
127
|
return 'unevaluated';
|
|
112
|
-
|
|
128
|
+
const decided = evals.filter((e) => e.verdict === undefined || e.verdict.state !== 'unknown');
|
|
129
|
+
if (decided.length === 0)
|
|
130
|
+
return 'unevaluated';
|
|
131
|
+
const passed = decided.filter((e) => e.passed).length;
|
|
132
|
+
if (passed === decided.length)
|
|
113
133
|
return 'pass';
|
|
114
|
-
if (
|
|
134
|
+
if (passed === 0)
|
|
115
135
|
return 'fail';
|
|
116
136
|
return 'partial';
|
|
117
137
|
}
|
|
@@ -122,8 +142,17 @@ function computeOverallScore(evals) {
|
|
|
122
142
|
return sum / evals.length;
|
|
123
143
|
}
|
|
124
144
|
function classifySignificance({ trace, evals, ruleSnapshot, verdict, }) {
|
|
125
|
-
|
|
126
|
-
|
|
145
|
+
/*
|
|
146
|
+
* 1. Safety violation — a rule that VETOES failed, or a safety-bundle rule
|
|
147
|
+
* did. Bundle membership alone was the old test, and it is the weaker
|
|
148
|
+
* one: from 0.10.0 which rules veto is the deployment's call
|
|
149
|
+
* (eval.criticalRules), so a rule promoted to critical outside the safety
|
|
150
|
+
* bundle is exactly as serious and used to rank as a plain failure. The
|
|
151
|
+
* bundle list stays as well, because a safety rule that a deployment
|
|
152
|
+
* DEMOTED still describes what it found.
|
|
153
|
+
*/
|
|
154
|
+
const vetoed = new Set(evals.flatMap((e) => e.rule_results.filter((r) => !r.skipped && r.passed === false && r.role === 'veto').map((r) => r.ruleName)));
|
|
155
|
+
const safetyFailed = ruleSnapshot.failed.filter((name) => SAFETY_RULE_NAMES.has(name) || vetoed.has(name));
|
|
127
156
|
if (safetyFailed.length > 0) {
|
|
128
157
|
return {
|
|
129
158
|
kind: 'safety-violation',
|
package/dist/eval/engine.d.ts
CHANGED
|
@@ -46,6 +46,8 @@ export declare class EvalEngine {
|
|
|
46
46
|
* promotion or demotion cannot apply on one code path and not another.
|
|
47
47
|
*/
|
|
48
48
|
private criticality;
|
|
49
|
+
/** The verdict's six defaults, resolved once from the config this engine was built with. */
|
|
50
|
+
private compose;
|
|
49
51
|
/**
|
|
50
52
|
* `criticalityOverrides` are `config.eval` — the criticalRules /
|
|
51
53
|
* nonCriticalRules lists. Validated here as well as in loadConfig, so an
|
|
@@ -53,6 +55,7 @@ export declare class EvalEngine {
|
|
|
53
55
|
* misspelled rule name.
|
|
54
56
|
*/
|
|
55
57
|
constructor(threshold?: number, ruleThresholds?: Record<string, unknown>, criticalityOverrides?: CriticalityOverrides);
|
|
58
|
+
private decide;
|
|
56
59
|
/** The effective criticality of one rule under this engine's config. Read by the rule roster surfaces. */
|
|
57
60
|
effectiveCriticality(rule: EvalRule): EffectiveCriticality;
|
|
58
61
|
/**
|
|
@@ -72,7 +75,7 @@ export declare class EvalEngine {
|
|
|
72
75
|
unregisterRule(ruleId: string): boolean;
|
|
73
76
|
/** Whether a deployed rule id is currently registered (and therefore firing). */
|
|
74
77
|
hasRule(ruleId: string): boolean;
|
|
75
|
-
evaluate(evalType: EvalType, context: EvalContext, customRules?: CustomRuleDefinition[]): EvalResult
|
|
78
|
+
evaluate(evalType: EvalType, context: EvalContext, customRules?: CustomRuleDefinition[]): Promise<EvalResult>;
|
|
76
79
|
/**
|
|
77
80
|
* eval_type="all" (#370): every built-in bundle, each with the deployed
|
|
78
81
|
* rules registered under it, plus the rules deployed under "custom" and
|
|
@@ -82,7 +85,7 @@ export declare class EvalEngine {
|
|
|
82
85
|
* that ran (weighted score against the threshold, critical veto across
|
|
83
86
|
* all bundles); `categories` carries the same arithmetic per bundle.
|
|
84
87
|
*/
|
|
85
|
-
evaluateAll(context: EvalContext, customRules?: CustomRuleDefinition[]): EvalResult
|
|
88
|
+
evaluateAll(context: EvalContext, customRules?: CustomRuleDefinition[]): Promise<EvalResult>;
|
|
86
89
|
private run;
|
|
87
90
|
/**
|
|
88
91
|
* Weighted average over the rules that ran, plus the critical veto.
|
package/dist/eval/engine.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getRulesForType, createCustomRule } from './rules/index.js';
|
|
2
2
|
import { criticalityResolver } from './criticality.js';
|
|
3
|
+
import { compose, interpretations, DEFAULT_COMPOSE } from './compose.js';
|
|
3
4
|
import { inputsPresent, stampRuleResult } from './stamp.js';
|
|
4
5
|
import { buildProvenance, configHash, deriveCoverage, deriveVerdict, rulesetHash } from './verdict.js';
|
|
5
6
|
import { PKG_VERSION } from '../config/defaults.js';
|
|
@@ -50,6 +51,8 @@ export class EvalEngine {
|
|
|
50
51
|
* promotion or demotion cannot apply on one code path and not another.
|
|
51
52
|
*/
|
|
52
53
|
criticality;
|
|
54
|
+
/** The verdict's six defaults, resolved once from the config this engine was built with. */
|
|
55
|
+
compose;
|
|
53
56
|
/**
|
|
54
57
|
* `criticalityOverrides` are `config.eval` — the criticalRules /
|
|
55
58
|
* nonCriticalRules lists. Validated here as well as in loadConfig, so an
|
|
@@ -61,6 +64,33 @@ export class EvalEngine {
|
|
|
61
64
|
this.ruleThresholds = ruleThresholds;
|
|
62
65
|
this.criticalityOverrides = criticalityOverrides;
|
|
63
66
|
this.criticality = criticalityResolver(criticalityOverrides);
|
|
67
|
+
this.compose = {
|
|
68
|
+
composer: criticalityOverrides?.composer ?? DEFAULT_COMPOSE.composer,
|
|
69
|
+
falsePassCost: criticalityOverrides?.falsePassCost ?? DEFAULT_COMPOSE.falsePassCost,
|
|
70
|
+
onCriticalSkipped: criticalityOverrides?.onCriticalSkipped ?? DEFAULT_COMPOSE.onCriticalSkipped,
|
|
71
|
+
requiredEvidence: criticalityOverrides?.requiredEvidence ?? DEFAULT_COMPOSE.requiredEvidence,
|
|
72
|
+
defaultsGate: criticalityOverrides?.defaultsGate ?? DEFAULT_COMPOSE.defaultsGate,
|
|
73
|
+
prior: criticalityOverrides?.prior ?? DEFAULT_COMPOSE.prior,
|
|
74
|
+
priorMode: criticalityOverrides?.priorMode ?? DEFAULT_COMPOSE.priorMode,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/*
|
|
78
|
+
* The verdict, and `passed` with it.
|
|
79
|
+
*
|
|
80
|
+
* From 0.10.0 `passed` IS `verdict.state === 'pass'` — one definition, on
|
|
81
|
+
* every surface. The weighted `score` survives untouched as a quality
|
|
82
|
+
* gradient over the rules that ran, and is never re-meant: a reader who
|
|
83
|
+
* was using it as a gradient keeps it, and a reader who was using it as a
|
|
84
|
+
* safety signal was reading a number that arc zero measured as inert.
|
|
85
|
+
*/
|
|
86
|
+
decide(result) {
|
|
87
|
+
const verdict = this.compose.composer === 'legacy' ? deriveVerdict(result, this.threshold) : compose(result, this.compose);
|
|
88
|
+
result.verdict = verdict;
|
|
89
|
+
result.passed = verdict.passed;
|
|
90
|
+
const notes = interpretations(result, verdict, this.compose);
|
|
91
|
+
if (notes.length > 0)
|
|
92
|
+
result.interpretations = notes;
|
|
93
|
+
return result;
|
|
64
94
|
}
|
|
65
95
|
/** The effective criticality of one rule under this engine's config. Read by the rule roster surfaces. */
|
|
66
96
|
effectiveCriticality(rule) {
|
|
@@ -109,7 +139,7 @@ export class EvalEngine {
|
|
|
109
139
|
hasRule(ruleId) {
|
|
110
140
|
return this.rulesById.has(ruleId);
|
|
111
141
|
}
|
|
112
|
-
evaluate(evalType, context, customRules) {
|
|
142
|
+
async evaluate(evalType, context, customRules) {
|
|
113
143
|
/*
|
|
114
144
|
* Inline custom_rules are ADDITIVE, which is what evaluate_output's
|
|
115
145
|
* description promises in two places: "fires REGARDLESS of eval_type"
|
|
@@ -146,7 +176,7 @@ export class EvalEngine {
|
|
|
146
176
|
* that ran (weighted score against the threshold, critical veto across
|
|
147
177
|
* all bundles); `categories` carries the same arithmetic per bundle.
|
|
148
178
|
*/
|
|
149
|
-
evaluateAll(context, customRules) {
|
|
179
|
+
async evaluateAll(context, customRules) {
|
|
150
180
|
const rules = [];
|
|
151
181
|
const categories = [];
|
|
152
182
|
for (const type of ALL_EVAL_TYPES) {
|
|
@@ -161,7 +191,16 @@ export class EvalEngine {
|
|
|
161
191
|
}
|
|
162
192
|
return this.run('all', rules, categories, context);
|
|
163
193
|
}
|
|
164
|
-
|
|
194
|
+
/*
|
|
195
|
+
* Async from 0.10.0. Nothing it awaits yet: every rule the package ships
|
|
196
|
+
* is synchronous, and `EvalRule.evaluate` stays synchronous so the type
|
|
197
|
+
* system keeps proving that a deterministic rule cannot reach the network
|
|
198
|
+
* — which is what makes "evaluate_output never spends" a compile-time
|
|
199
|
+
* fact rather than a test. The signature moves first, in one mechanical
|
|
200
|
+
* change, so the judgment rule that DOES call a provider can be added
|
|
201
|
+
* without re-touching every caller a second time.
|
|
202
|
+
*/
|
|
203
|
+
async run(evalType, rules, categories, context) {
|
|
165
204
|
// Merge system-level thresholds into customConfig (user-provided values take precedence)
|
|
166
205
|
if (this.ruleThresholds) {
|
|
167
206
|
context = {
|
|
@@ -194,10 +233,41 @@ export class EvalEngine {
|
|
|
194
233
|
* rule it carries.
|
|
195
234
|
*/
|
|
196
235
|
const evalContext = { ...context, regexBudget: { breaches: 0 } };
|
|
197
|
-
|
|
198
|
-
|
|
236
|
+
/*
|
|
237
|
+
* Sequential, and it must stay sequential when a rule becomes awaitable:
|
|
238
|
+
* every rule in one evaluation shares the regex circuit breaker above,
|
|
239
|
+
* and running them concurrently would race the breach count that bounds
|
|
240
|
+
* a hostile output.
|
|
241
|
+
*/
|
|
242
|
+
const ruleResults = [];
|
|
243
|
+
for (const [i, rule] of rules.entries()) {
|
|
244
|
+
/*
|
|
245
|
+
* A judgment rule calls a provider and costs money. It runs only when
|
|
246
|
+
* the caller has said this evaluation may spend — which the free
|
|
247
|
+
* evaluation path never does. Enforced here, on the one path every
|
|
248
|
+
* evaluation takes, so no tool can forget it and no future rule can
|
|
249
|
+
* quietly opt itself in.
|
|
250
|
+
*/
|
|
251
|
+
const raw = rule.kind === 'judgment' && evalContext.allowPaid !== true
|
|
252
|
+
? {
|
|
253
|
+
ruleName: rule.name,
|
|
254
|
+
passed: false,
|
|
255
|
+
score: 0,
|
|
256
|
+
message: 'Judgment rules are not run on this path: it may not call a paid provider.',
|
|
257
|
+
skipped: true,
|
|
258
|
+
skipReason: 'this evaluation may not spend (context.allowPaid is not set)',
|
|
259
|
+
}
|
|
260
|
+
: rule.evaluate(evalContext);
|
|
199
261
|
const ruleId = this.idByRule.get(rule);
|
|
200
|
-
|
|
262
|
+
/*
|
|
263
|
+
* The bundle this rule ran under. `categories` is only supplied for
|
|
264
|
+
* eval_type="all"; for a single bundle the rule's own evalType is the
|
|
265
|
+
* answer and is just as true. It used to be left off, which meant a
|
|
266
|
+
* single-bundle call could not tell a custom rule from a built-in one
|
|
267
|
+
* — and the composer needs that, because a custom rule's severity is
|
|
268
|
+
* the deployment's own statement of how much it matters.
|
|
269
|
+
*/
|
|
270
|
+
const category = categories?.[i] ?? (evalType === 'all' ? rule.evalType : evalType);
|
|
201
271
|
/*
|
|
202
272
|
* Every result says whether THIS rule vetoes and who decided that.
|
|
203
273
|
* Without it, a reader holding a failed evaluation cannot tell a
|
|
@@ -221,7 +291,7 @@ export class EvalEngine {
|
|
|
221
291
|
* so no surface can show a result without its receipt. It changes no
|
|
222
292
|
* verdict: summarize() below still decides passed exactly as before.
|
|
223
293
|
*/
|
|
224
|
-
|
|
294
|
+
ruleResults.push({
|
|
225
295
|
ruleName,
|
|
226
296
|
...(ruleId !== undefined ? { ruleId } : {}),
|
|
227
297
|
...(category !== undefined ? { category } : {}),
|
|
@@ -229,8 +299,8 @@ export class EvalEngine {
|
|
|
229
299
|
criticalSource: source,
|
|
230
300
|
...rest,
|
|
231
301
|
...stampRuleResult(rule, raw, context, effective),
|
|
232
|
-
};
|
|
233
|
-
}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
234
304
|
const overall = this.summarize(rules, ruleResults);
|
|
235
305
|
const perCategory = categories ? this.categorize(rules, ruleResults, categories) : undefined;
|
|
236
306
|
/*
|
|
@@ -283,8 +353,7 @@ export class EvalEngine {
|
|
|
283
353
|
coverage,
|
|
284
354
|
provenance,
|
|
285
355
|
};
|
|
286
|
-
|
|
287
|
-
return unknown;
|
|
356
|
+
return this.decide(unknown);
|
|
288
357
|
}
|
|
289
358
|
const suggestions = [];
|
|
290
359
|
for (const result of ruleResults) {
|
|
@@ -333,8 +402,7 @@ export class EvalEngine {
|
|
|
333
402
|
coverage,
|
|
334
403
|
provenance,
|
|
335
404
|
};
|
|
336
|
-
|
|
337
|
-
return result;
|
|
405
|
+
return this.decide(result);
|
|
338
406
|
}
|
|
339
407
|
/**
|
|
340
408
|
* Weighted average over the rules that ran, plus the critical veto.
|
|
@@ -26,8 +26,28 @@ export interface LLMJudgeEvaluateParams {
|
|
|
26
26
|
maxInputTokensEstimate?: number;
|
|
27
27
|
}
|
|
28
28
|
export interface LLMJudgeEvaluationResult {
|
|
29
|
+
/**
|
|
30
|
+
* The verdict, and it is the THRESHOLD's, not the model's.
|
|
31
|
+
*
|
|
32
|
+
* Until 0.10.0 the model's own `passed` boolean won whenever it supplied
|
|
33
|
+
* one, and the template's threshold was a fallback the product rarely
|
|
34
|
+
* reached. That let a judge return `score: 0.2` with `passed: true` and
|
|
35
|
+
* be believed — a scoring rubric whose score did not decide anything.
|
|
36
|
+
* Now the score is the measurement and the threshold is the rule.
|
|
37
|
+
*/
|
|
29
38
|
passed: boolean;
|
|
30
39
|
score: number;
|
|
40
|
+
/** The threshold the score was read against, so a reader can check the arithmetic. */
|
|
41
|
+
passThreshold: number;
|
|
42
|
+
/** What the model said about passing, when it said anything. Recorded, never obeyed. */
|
|
43
|
+
selfReportedPass?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* True when the model's own boolean disagrees with the threshold verdict.
|
|
46
|
+
* Worth surfacing: a judge that scores 0.95 and says "fail", or scores
|
|
47
|
+
* 0.2 and says "pass", is telling you its rubric and its judgement have
|
|
48
|
+
* come apart on this output.
|
|
49
|
+
*/
|
|
50
|
+
disagreement?: boolean;
|
|
31
51
|
rationale: string;
|
|
32
52
|
dimensions: Record<string, number>;
|
|
33
53
|
model: string;
|
|
@@ -151,10 +151,19 @@ export async function evaluateWithLLMJudge(params) {
|
|
|
151
151
|
latencyMs += raw.latencyMs;
|
|
152
152
|
parsed = parseJudgeResponse(raw.content);
|
|
153
153
|
}
|
|
154
|
-
|
|
154
|
+
/*
|
|
155
|
+
* The threshold decides. The model's own boolean is evidence about the
|
|
156
|
+
* model, not about the output, and it is recorded beside the verdict
|
|
157
|
+
* rather than substituted for it.
|
|
158
|
+
*/
|
|
159
|
+
const passed = parsed.score >= template.passThreshold;
|
|
160
|
+
const disagreement = parsed.passed !== undefined && parsed.passed !== passed;
|
|
155
161
|
const costUsd = estimateCostUsd(params.model, inputTokens, outputTokens);
|
|
156
162
|
return {
|
|
157
163
|
passed,
|
|
164
|
+
passThreshold: template.passThreshold,
|
|
165
|
+
...(parsed.passed !== undefined ? { selfReportedPass: parsed.passed } : {}),
|
|
166
|
+
...(disagreement ? { disagreement: true } : {}),
|
|
158
167
|
score: parsed.score,
|
|
159
168
|
rationale: parsed.rationale,
|
|
160
169
|
dimensions: parsed.dimensions,
|