@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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/dist/config/defaults.js +15 -0
  3. package/dist/dashboard/assets/{index-Cz8_oOqG.js → index-CeJbaq6m.js} +1 -1
  4. package/dist/dashboard/index.html +1 -1
  5. package/dist/dashboard/routes/traces.js +2 -2
  6. package/dist/dashboard/seed-demo-data.js +1 -1
  7. package/dist/eval/citation-verify/verifier.d.ts +16 -1
  8. package/dist/eval/citation-verify/verifier.js +14 -4
  9. package/dist/eval/compose.d.ts +57 -0
  10. package/dist/eval/compose.js +179 -0
  11. package/dist/eval/criticality.d.ts +7 -0
  12. package/dist/eval/decision-moment.js +33 -4
  13. package/dist/eval/engine.d.ts +5 -2
  14. package/dist/eval/engine.js +81 -13
  15. package/dist/eval/llm-judge/evaluator.d.ts +20 -0
  16. package/dist/eval/llm-judge/evaluator.js +10 -1
  17. package/dist/eval/published-accuracy.d.ts +22 -22
  18. package/dist/eval/published-accuracy.js +11 -11
  19. package/dist/eval/risk.d.ts +60 -0
  20. package/dist/eval/risk.js +187 -0
  21. package/dist/eval/rules/completeness.js +5 -1
  22. package/dist/eval/rules/cost.d.ts +1 -1
  23. package/dist/eval/rules/cost.js +6 -6
  24. package/dist/eval/rules/custom.js +1 -0
  25. package/dist/eval/rules/relevance.js +7 -2
  26. package/dist/eval/rules/safety.d.ts +6 -2
  27. package/dist/eval/rules/safety.js +55 -59
  28. package/dist/eval/seeded-random.d.ts +4 -0
  29. package/dist/eval/seeded-random.js +36 -0
  30. package/dist/eval/stamp.d.ts +1 -1
  31. package/dist/eval/stamp.js +1 -0
  32. package/dist/eval/text/checksums.d.ts +23 -0
  33. package/dist/eval/text/checksums.js +97 -0
  34. package/dist/eval/text/normalise.d.ts +30 -0
  35. package/dist/eval/text/normalise.js +265 -0
  36. package/dist/eval/text/sentences.d.ts +15 -0
  37. package/dist/eval/text/sentences.js +149 -0
  38. package/dist/self-test.js +3 -3
  39. package/dist/storage/sqlite-adapter.js +16 -2
  40. package/dist/tools/evaluate-output.js +2 -2
  41. package/dist/tools/evaluate-with-llm-judge.d.ts +3 -0
  42. package/dist/tools/evaluate-with-llm-judge.js +29 -1
  43. package/dist/tools/verify-citations.d.ts +2 -1
  44. package/dist/tools/verify-citations.js +25 -4
  45. package/dist/types/config.d.ts +35 -0
  46. package/dist/types/eval.d.ts +51 -0
  47. package/package.json +1 -1
  48. 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-Cz8_oOqG.js"></script>
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: 'token_efficiency',
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
- passed: boolean;
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
- // Fail if >= 50% of judged sources don't support the claim. When no
251
- // citations, none resolved, or none judged, we don't fail — there's
252
- // nothing to score, we just report that.
253
- const passed = overallScore === null ? true : overallScore >= 0.5;
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
- if (snapshot.failed.length === 0)
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 (snapshot.passedCount === 0)
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
- // 1. Safety violation — any safety rule failed → top priority.
126
- const safetyFailed = ruleSnapshot.failed.filter((name) => SAFETY_RULE_NAMES.has(name));
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',
@@ -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.
@@ -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
- run(evalType, rules, categories, context) {
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
- const ruleResults = rules.map((rule, i) => {
198
- const raw = rule.evaluate(evalContext);
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
- const category = categories?.[i];
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
- return {
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
- unknown.verdict = deriveVerdict(unknown, this.threshold);
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
- result.verdict = deriveVerdict(result, this.threshold);
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
- const passed = parsed.passed ?? parsed.score >= template.passThreshold;
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,