@iris-eval/mcp-server 0.8.2 → 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 (131) hide show
  1. package/README.md +9 -2
  2. package/dist/capabilities.d.ts +64 -0
  3. package/dist/capabilities.js +65 -0
  4. package/dist/config/defaults.js +17 -0
  5. package/dist/custom-rule-store.d.ts +4 -0
  6. package/dist/custom-rule-store.js +8 -3
  7. package/dist/dashboard/assets/{index-CyzO6OC7.js → index-CeJbaq6m.js} +1 -1
  8. package/dist/dashboard/index.html +1 -1
  9. package/dist/dashboard/routes/capabilities.d.ts +3 -0
  10. package/dist/dashboard/routes/capabilities.js +11 -0
  11. package/dist/dashboard/routes/health.d.ts +5 -1
  12. package/dist/dashboard/routes/health.js +15 -3
  13. package/dist/dashboard/routes/rules.js +4 -1
  14. package/dist/dashboard/routes/traces.d.ts +3 -0
  15. package/dist/dashboard/routes/traces.js +11 -30
  16. package/dist/dashboard/seed-demo-data.js +1 -1
  17. package/dist/dashboard/server.d.ts +2 -0
  18. package/dist/dashboard/server.js +6 -2
  19. package/dist/eval/accuracy.d.ts +41 -0
  20. package/dist/eval/accuracy.js +97 -0
  21. package/dist/eval/citation-verify/verifier.d.ts +16 -1
  22. package/dist/eval/citation-verify/verifier.js +14 -4
  23. package/dist/eval/compose.d.ts +57 -0
  24. package/dist/eval/compose.js +179 -0
  25. package/dist/eval/criticality.d.ts +15 -1
  26. package/dist/eval/criticality.js +6 -0
  27. package/dist/eval/decision-moment.js +33 -4
  28. package/dist/eval/dormant.d.ts +4 -0
  29. package/dist/eval/dormant.js +22 -0
  30. package/dist/eval/engine.d.ts +6 -2
  31. package/dist/eval/engine.js +126 -12
  32. package/dist/eval/failure-classes.d.ts +8 -0
  33. package/dist/eval/failure-classes.js +18 -0
  34. package/dist/eval/llm-judge/evaluator.d.ts +30 -0
  35. package/dist/eval/llm-judge/evaluator.js +26 -2
  36. package/dist/eval/published-accuracy.d.ts +230 -0
  37. package/dist/eval/published-accuracy.js +86 -0
  38. package/dist/eval/questions.d.ts +12 -0
  39. package/dist/eval/questions.js +14 -0
  40. package/dist/eval/response-schema.d.ts +652 -0
  41. package/dist/eval/response-schema.js +130 -0
  42. package/dist/eval/response.d.ts +12 -0
  43. package/dist/eval/response.js +30 -0
  44. package/dist/eval/risk.d.ts +60 -0
  45. package/dist/eval/risk.js +187 -0
  46. package/dist/eval/rules/completeness.js +36 -1
  47. package/dist/eval/rules/cost.d.ts +2 -2
  48. package/dist/eval/rules/cost.js +50 -6
  49. package/dist/eval/rules/custom.d.ts +0 -12
  50. package/dist/eval/rules/custom.js +22 -0
  51. package/dist/eval/rules/relevance.js +23 -2
  52. package/dist/eval/rules/safety.d.ts +6 -2
  53. package/dist/eval/rules/safety.js +224 -51
  54. package/dist/eval/seeded-random.d.ts +4 -0
  55. package/dist/eval/seeded-random.js +36 -0
  56. package/dist/eval/stamp.d.ts +14 -0
  57. package/dist/eval/stamp.js +89 -0
  58. package/dist/eval/stats.d.ts +33 -0
  59. package/dist/eval/stats.js +109 -0
  60. package/dist/eval/text/checksums.d.ts +23 -0
  61. package/dist/eval/text/checksums.js +97 -0
  62. package/dist/eval/text/normalise.d.ts +30 -0
  63. package/dist/eval/text/normalise.js +265 -0
  64. package/dist/eval/text/sentences.d.ts +15 -0
  65. package/dist/eval/text/sentences.js +149 -0
  66. package/dist/eval/verdict.d.ts +34 -0
  67. package/dist/eval/verdict.js +131 -0
  68. package/dist/index.js +5 -28
  69. package/dist/instructions.d.ts +17 -0
  70. package/dist/instructions.js +53 -0
  71. package/dist/judge-enablement.d.ts +34 -0
  72. package/dist/judge-enablement.js +78 -0
  73. package/dist/judge-enablement.json +10 -0
  74. package/dist/preferences.d.ts +1 -1
  75. package/dist/prompts.d.ts +3 -0
  76. package/dist/prompts.js +29 -0
  77. package/dist/resources/index.d.ts +5 -2
  78. package/dist/resources/index.js +65 -5
  79. package/dist/resources/uris.d.ts +12 -0
  80. package/dist/resources/uris.js +24 -0
  81. package/dist/retention.d.ts +20 -0
  82. package/dist/retention.js +44 -0
  83. package/dist/self-test.d.ts +1 -0
  84. package/dist/self-test.js +17 -3
  85. package/dist/server.d.ts +10 -1
  86. package/dist/server.js +34 -7
  87. package/dist/storage/index.js +1 -1
  88. package/dist/storage/migrations/007-eval-provenance.d.ts +3 -0
  89. package/dist/storage/migrations/007-eval-provenance.js +30 -0
  90. package/dist/storage/migrations/index.js +24 -4
  91. package/dist/storage/sqlite-adapter.d.ts +26 -1
  92. package/dist/storage/sqlite-adapter.js +149 -15
  93. package/dist/tools/delete-rule.d.ts +8 -0
  94. package/dist/tools/delete-rule.js +30 -38
  95. package/dist/tools/delete-trace.d.ts +5 -0
  96. package/dist/tools/delete-trace.js +24 -27
  97. package/dist/tools/deploy-rule.d.ts +13 -1
  98. package/dist/tools/deploy-rule.js +37 -34
  99. package/dist/tools/describe.d.ts +20 -0
  100. package/dist/tools/describe.js +36 -0
  101. package/dist/tools/errors.d.ts +36 -0
  102. package/dist/tools/errors.js +134 -0
  103. package/dist/tools/evaluate-output.d.ts +8 -1
  104. package/dist/tools/evaluate-output.js +39 -60
  105. package/dist/tools/evaluate-with-llm-judge.d.ts +34 -0
  106. package/dist/tools/evaluate-with-llm-judge.js +124 -69
  107. package/dist/tools/get-traces.d.ts +9 -0
  108. package/dist/tools/get-traces.js +29 -28
  109. package/dist/tools/index.d.ts +8 -0
  110. package/dist/tools/index.js +22 -1
  111. package/dist/tools/list-rules.d.ts +13 -0
  112. package/dist/tools/list-rules.js +43 -46
  113. package/dist/tools/log-trace.d.ts +4 -0
  114. package/dist/tools/log-trace.js +31 -29
  115. package/dist/tools/respond.d.ts +42 -0
  116. package/dist/tools/respond.js +90 -0
  117. package/dist/tools/strict-input.js +1 -1
  118. package/dist/tools/trace-link.d.ts +2 -0
  119. package/dist/tools/trace-link.js +13 -2
  120. package/dist/tools/verify-citations.d.ts +18 -2
  121. package/dist/tools/verify-citations.js +122 -96
  122. package/dist/types/config.d.ts +44 -0
  123. package/dist/types/eval.d.ts +309 -0
  124. package/dist/types/eval.js +2 -1
  125. package/dist/types/query.d.ts +2 -0
  126. package/package.json +1 -1
  127. package/server.json +2 -2
  128. package/dist/resources/dashboard-summary.d.ts +0 -3
  129. package/dist/resources/dashboard-summary.js +0 -16
  130. package/dist/resources/trace-detail.d.ts +0 -3
  131. package/dist/resources/trace-detail.js +0 -30
@@ -1,4 +1,4 @@
1
- import type { EvalRule, EvalType } from '../types/eval.js';
1
+ import type { EvalRule, EvalType, ClaimKind, Mechanism, Need, QuestionId, FailureClass } from '../types/eval.js';
2
2
  /** Where a rule's EFFECTIVE criticality came from. */
3
3
  export type CriticalitySource = 'default' | 'config';
4
4
  export interface CriticalityOverrides {
@@ -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;
@@ -50,6 +57,13 @@ export interface BuiltInRuleMeta {
50
57
  critical: boolean;
51
58
  /** Who decided it: the rule's own declaration, or one of the config lists. */
52
59
  criticalSource: CriticalitySource;
60
+ /** The rule's declared metadata (see EvalRule): the kind of claim, its mechanism, what it reads, the question it answers, the failure classes, the definition version. */
61
+ kind?: ClaimKind;
62
+ mechanism?: Mechanism;
63
+ needs?: readonly Need[];
64
+ question?: QuestionId;
65
+ classes?: readonly FailureClass[];
66
+ version?: number;
53
67
  }
54
68
  /**
55
69
  * The whole built-in roster, one entry per rule.
@@ -147,6 +147,12 @@ export function builtInRuleRoster(resolve) {
147
147
  weight: rule.weight,
148
148
  critical: effective.critical,
149
149
  criticalSource: effective.source,
150
+ kind: rule.kind,
151
+ mechanism: rule.mechanism,
152
+ needs: rule.needs,
153
+ question: rule.question,
154
+ classes: rule.classes,
155
+ version: rule.version,
150
156
  });
151
157
  }
152
158
  }
@@ -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',
@@ -0,0 +1,4 @@
1
+ import type { Coverage } from '../types/eval.js';
2
+ export type DormantRule = NonNullable<Coverage['dormant']>[number];
3
+ /** The quarantined entries that would have gated, as `coverage.dormant` rows. */
4
+ export declare function dormantRulesFrom(quarantined: readonly unknown[]): DormantRule[];
@@ -0,0 +1,22 @@
1
+ const GATING = new Set(['high', 'critical']);
2
+ function field(entry, key) {
3
+ if (!entry || typeof entry !== 'object')
4
+ return undefined;
5
+ const value = entry[key];
6
+ return typeof value === 'string' ? value : undefined;
7
+ }
8
+ /** The quarantined entries that would have gated, as `coverage.dormant` rows. */
9
+ export function dormantRulesFrom(quarantined) {
10
+ const out = [];
11
+ for (const entry of quarantined) {
12
+ const severity = field(entry, 'severity');
13
+ if (!severity || !GATING.has(severity))
14
+ continue;
15
+ out.push({
16
+ ruleId: field(entry, 'id') ?? 'unknown',
17
+ name: field(entry, 'name') ?? 'unnamed',
18
+ reason: `quarantined: the stored definition failed validation in this version, so this ${severity} rule is not running`,
19
+ });
20
+ }
21
+ return out;
22
+ }
@@ -39,12 +39,15 @@ export declare class EvalEngine {
39
39
  private idByRule;
40
40
  private threshold;
41
41
  private ruleThresholds?;
42
+ private criticalityOverrides?;
42
43
  /**
43
44
  * Effective criticality per rule, bound to this engine's config overrides.
44
45
  * Every veto decision reads THIS, never `rule.critical` directly, so a
45
46
  * promotion or demotion cannot apply on one code path and not another.
46
47
  */
47
48
  private criticality;
49
+ /** The verdict's six defaults, resolved once from the config this engine was built with. */
50
+ private compose;
48
51
  /**
49
52
  * `criticalityOverrides` are `config.eval` — the criticalRules /
50
53
  * nonCriticalRules lists. Validated here as well as in loadConfig, so an
@@ -52,6 +55,7 @@ export declare class EvalEngine {
52
55
  * misspelled rule name.
53
56
  */
54
57
  constructor(threshold?: number, ruleThresholds?: Record<string, unknown>, criticalityOverrides?: CriticalityOverrides);
58
+ private decide;
55
59
  /** The effective criticality of one rule under this engine's config. Read by the rule roster surfaces. */
56
60
  effectiveCriticality(rule: EvalRule): EffectiveCriticality;
57
61
  /**
@@ -71,7 +75,7 @@ export declare class EvalEngine {
71
75
  unregisterRule(ruleId: string): boolean;
72
76
  /** Whether a deployed rule id is currently registered (and therefore firing). */
73
77
  hasRule(ruleId: string): boolean;
74
- evaluate(evalType: EvalType, context: EvalContext, customRules?: CustomRuleDefinition[]): EvalResult;
78
+ evaluate(evalType: EvalType, context: EvalContext, customRules?: CustomRuleDefinition[]): Promise<EvalResult>;
75
79
  /**
76
80
  * eval_type="all" (#370): every built-in bundle, each with the deployed
77
81
  * rules registered under it, plus the rules deployed under "custom" and
@@ -81,7 +85,7 @@ export declare class EvalEngine {
81
85
  * that ran (weighted score against the threshold, critical veto across
82
86
  * all bundles); `categories` carries the same arithmetic per bundle.
83
87
  */
84
- evaluateAll(context: EvalContext, customRules?: CustomRuleDefinition[]): EvalResult;
88
+ evaluateAll(context: EvalContext, customRules?: CustomRuleDefinition[]): Promise<EvalResult>;
85
89
  private run;
86
90
  /**
87
91
  * Weighted average over the rules that ran, plus the critical veto.
@@ -1,5 +1,9 @@
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';
4
+ import { inputsPresent, stampRuleResult } from './stamp.js';
5
+ import { buildProvenance, configHash, deriveCoverage, deriveVerdict, rulesetHash } from './verdict.js';
6
+ import { PKG_VERSION } from '../config/defaults.js';
3
7
  import { generateEvalId } from '../utils/ids.js';
4
8
  /**
5
9
  * Every bundle eval_type="all" walks, in the order their categories are
@@ -40,12 +44,15 @@ export class EvalEngine {
40
44
  idByRule = new Map();
41
45
  threshold;
42
46
  ruleThresholds;
47
+ criticalityOverrides;
43
48
  /**
44
49
  * Effective criticality per rule, bound to this engine's config overrides.
45
50
  * Every veto decision reads THIS, never `rule.critical` directly, so a
46
51
  * promotion or demotion cannot apply on one code path and not another.
47
52
  */
48
53
  criticality;
54
+ /** The verdict's six defaults, resolved once from the config this engine was built with. */
55
+ compose;
49
56
  /**
50
57
  * `criticalityOverrides` are `config.eval` — the criticalRules /
51
58
  * nonCriticalRules lists. Validated here as well as in loadConfig, so an
@@ -55,7 +62,35 @@ export class EvalEngine {
55
62
  constructor(threshold = 0.7, ruleThresholds, criticalityOverrides) {
56
63
  this.threshold = threshold;
57
64
  this.ruleThresholds = ruleThresholds;
65
+ this.criticalityOverrides = criticalityOverrides;
58
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;
59
94
  }
60
95
  /** The effective criticality of one rule under this engine's config. Read by the rule roster surfaces. */
61
96
  effectiveCriticality(rule) {
@@ -104,7 +139,7 @@ export class EvalEngine {
104
139
  hasRule(ruleId) {
105
140
  return this.rulesById.has(ruleId);
106
141
  }
107
- evaluate(evalType, context, customRules) {
142
+ async evaluate(evalType, context, customRules) {
108
143
  /*
109
144
  * Inline custom_rules are ADDITIVE, which is what evaluate_output's
110
145
  * description promises in two places: "fires REGARDLESS of eval_type"
@@ -141,7 +176,7 @@ export class EvalEngine {
141
176
  * that ran (weighted score against the threshold, critical veto across
142
177
  * all bundles); `categories` carries the same arithmetic per bundle.
143
178
  */
144
- evaluateAll(context, customRules) {
179
+ async evaluateAll(context, customRules) {
145
180
  const rules = [];
146
181
  const categories = [];
147
182
  for (const type of ALL_EVAL_TYPES) {
@@ -156,7 +191,16 @@ export class EvalEngine {
156
191
  }
157
192
  return this.run('all', rules, categories, context);
158
193
  }
159
- 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) {
160
204
  // Merge system-level thresholds into customConfig (user-provided values take precedence)
161
205
  if (this.ruleThresholds) {
162
206
  context = {
@@ -177,6 +221,7 @@ export class EvalEngine {
177
221
  rules_evaluated: 0,
178
222
  rules_skipped: 0,
179
223
  insufficient_data: true,
224
+ verdict: { state: 'unknown', passed: false, basis: 'no_rules', by: [], risk: null },
180
225
  };
181
226
  }
182
227
  /*
@@ -188,10 +233,41 @@ export class EvalEngine {
188
233
  * rule it carries.
189
234
  */
190
235
  const evalContext = { ...context, regexBudget: { breaches: 0 } };
191
- const ruleResults = rules.map((rule, i) => {
192
- 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);
193
261
  const ruleId = this.idByRule.get(rule);
194
- 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);
195
271
  /*
196
272
  * Every result says whether THIS rule vetoes and who decided that.
197
273
  * Without it, a reader holding a failed evaluation cannot tell a
@@ -201,21 +277,53 @@ export class EvalEngine {
201
277
  * the one path every evaluation takes, so a surface cannot render the
202
278
  * declared criticality where the engine applied a configured one.
203
279
  */
204
- const { critical, source } = this.criticality(rule);
280
+ const effective = this.criticality(rule);
281
+ const { critical, source } = effective;
205
282
  // ruleId / category sit right after the name so a reader scanning
206
283
  // rule_results sees WHICH deployed rule (and which bundle) spoke.
207
284
  const { ruleName, ...rest } = raw;
208
- return {
285
+ /*
286
+ * The stamp (0.9.0): what kind of claim this is, what the composer
287
+ * did with it, which question it answers, what it saw, why it skipped,
288
+ * and how wrong it tends to be — from the rule's declaration, the
289
+ * inputs this call carried, and the published accuracy that ships in
290
+ * the package. Computed here, on the one path every evaluation takes,
291
+ * so no surface can show a result without its receipt. It changes no
292
+ * verdict: summarize() below still decides passed exactly as before.
293
+ */
294
+ ruleResults.push({
209
295
  ruleName,
210
296
  ...(ruleId !== undefined ? { ruleId } : {}),
211
297
  ...(category !== undefined ? { category } : {}),
212
298
  critical,
213
299
  criticalSource: source,
214
300
  ...rest,
215
- };
216
- });
301
+ ...stampRuleResult(rule, raw, context, effective),
302
+ });
303
+ }
217
304
  const overall = this.summarize(rules, ruleResults);
218
305
  const perCategory = categories ? this.categorize(rules, ruleResults, categories) : undefined;
306
+ /*
307
+ * The receipt for the whole evaluation (0.9.0): what produced it, which
308
+ * questions it judged, and the basis of its verdict. Computed here from
309
+ * what the engine already holds; persisted as provenance and derived
310
+ * again on every read, so a stored row answers "why did this pass on
311
+ * that day" without a backfill. Changes no verdict.
312
+ */
313
+ const provenance = buildProvenance({
314
+ irisVersion: PKG_VERSION,
315
+ rulesetHash: rulesetHash(rules, (r) => this.criticality(r)),
316
+ configHash: configHash({
317
+ threshold: this.threshold,
318
+ ruleThresholds: this.ruleThresholds,
319
+ criticalRules: this.criticalityOverrides?.criticalRules,
320
+ nonCriticalRules: this.criticalityOverrides?.nonCriticalRules,
321
+ }),
322
+ threshold: this.threshold,
323
+ ruleThresholds: this.ruleThresholds,
324
+ judgedAt: new Date().toISOString(),
325
+ });
326
+ const coverage = deriveCoverage(ruleResults, inputsPresent(context));
219
327
  // Handle "all rules skipped" — insufficient data
220
328
  if (overall.rulesEvaluated === 0) {
221
329
  const skipMessages = ruleResults
@@ -225,7 +333,7 @@ export class EvalEngine {
225
333
  // that EVERY critical rule that skipped is named here, and a caller
226
334
  // whose only rules were critical ones should not have to infer that
227
335
  // from insufficient_data alone.
228
- return {
336
+ const unknown = {
229
337
  id: generateEvalId(),
230
338
  eval_type: evalType,
231
339
  output_text: context.output,
@@ -242,7 +350,10 @@ export class EvalEngine {
242
350
  insufficient_data: true,
243
351
  ...(overall.criticalSkipped.length > 0 ? { critical_skipped: overall.criticalSkipped } : {}),
244
352
  ...(perCategory ? { categories: perCategory } : {}),
353
+ coverage,
354
+ provenance,
245
355
  };
356
+ return this.decide(unknown);
246
357
  }
247
358
  const suggestions = [];
248
359
  for (const result of ruleResults) {
@@ -273,7 +384,7 @@ export class EvalEngine {
273
384
  'checks, not "clean"; a gate that must fail closed should treat critical_skipped ' +
274
385
  'as a failure.');
275
386
  }
276
- return {
387
+ const result = {
277
388
  id: generateEvalId(),
278
389
  eval_type: evalType,
279
390
  output_text: context.output,
@@ -288,7 +399,10 @@ export class EvalEngine {
288
399
  ...(overall.criticalFailures.length > 0 ? { critical_failures: overall.criticalFailures } : {}),
289
400
  ...(overall.criticalSkipped.length > 0 ? { critical_skipped: overall.criticalSkipped } : {}),
290
401
  ...(perCategory ? { categories: perCategory } : {}),
402
+ coverage,
403
+ provenance,
291
404
  };
405
+ return this.decide(result);
292
406
  }
293
407
  /**
294
408
  * Weighted average over the rules that ran, plus the critical veto.
@@ -0,0 +1,8 @@
1
+ import type { FailureClass, Need } from '../types/eval.js';
2
+ export interface FailureClassEntry {
3
+ id: FailureClass;
4
+ text: string;
5
+ }
6
+ export declare const FAILURE_CLASSES: readonly FailureClassEntry[];
7
+ export declare const FAILURE_CLASS_IDS: readonly FailureClass[];
8
+ export declare const NEEDS: readonly Need[];
@@ -0,0 +1,18 @@
1
+ export const FAILURE_CLASSES = [
2
+ { id: 'pii_leak', text: 'Personal data in the output (SSN, card, phone, email, date of birth, address …)' },
3
+ { id: 'credential_leak', text: 'A secret in the output (API key, token, private key, seed phrase)' },
4
+ { id: 'injection', text: 'Injection-shaped content in the output: attack phrasing or a directive aimed at an evaluator or a downstream system' },
5
+ { id: 'injection_compliance', text: 'The agent read an injected instruction in a tool result and obeyed it' },
6
+ { id: 'silent_tool_failure', text: 'A tool call failed and the output never acknowledges it' },
7
+ { id: 'tool_loop', text: 'The same call repeated past the configured limit with the same result' },
8
+ { id: 'stub', text: 'A placeholder, a deferral or a promise instead of the work' },
9
+ { id: 'fabrication', text: 'A claim that contradicts the material the agent was given' },
10
+ { id: 'ungrounded', text: 'An identifier, number or citation in the output that nothing the agent read supports' },
11
+ { id: 'incomplete_ask', text: 'Part of a multi-part ask was not answered' },
12
+ { id: 'off_task', text: 'The output does not address what was asked' },
13
+ { id: 'over_budget', text: 'The run cost more than the deployment allows, in money or tokens' },
14
+ { id: 'format', text: 'The output fails a shape or size requirement (empty, too short, too few sentences, invalid JSON)' },
15
+ { id: 'invalid_tool_call', text: 'A tool was called that is not in the catalogue, or with arguments its schema rejects' },
16
+ ];
17
+ export const FAILURE_CLASS_IDS = FAILURE_CLASSES.map((c) => c.id);
18
+ export const NEEDS = ['output', 'input', 'expected', 'tool_calls', 'tool_outputs', 'tools_catalogue', 'cost', 'tokens', 'citations'];
@@ -1,5 +1,15 @@
1
1
  import { type LLMProvider } from './client.js';
2
2
  import { type TemplateName } from './templates/index.js';
3
+ /**
4
+ * The pre-check refused the call: the worst-case spend (two attempts) would
5
+ * exceed the cap. Typed so the tool can answer IRIS_BUDGET_EXCEEDED with both
6
+ * numbers; nothing was spent.
7
+ */
8
+ export declare class CostCapError extends Error {
9
+ readonly estimatedUsd: number;
10
+ readonly capUsd: number;
11
+ constructor(estimatedUsd: number, capUsd: number);
12
+ }
3
13
  export interface LLMJudgeEvaluateParams {
4
14
  output: string;
5
15
  template: TemplateName;
@@ -16,8 +26,28 @@ export interface LLMJudgeEvaluateParams {
16
26
  maxInputTokensEstimate?: number;
17
27
  }
18
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
+ */
19
38
  passed: boolean;
20
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;
21
51
  rationale: string;
22
52
  dimensions: Record<string, number>;
23
53
  model: string;
@@ -1,6 +1,21 @@
1
1
  import { callLLMJudge, estimateInputTokens, LLMJudgeError } from './client.js';
2
2
  import { estimateCostUsd, findPricing } from './pricing.js';
3
3
  import { getTemplate } from './templates/index.js';
4
+ /**
5
+ * The pre-check refused the call: the worst-case spend (two attempts) would
6
+ * exceed the cap. Typed so the tool can answer IRIS_BUDGET_EXCEEDED with both
7
+ * numbers; nothing was spent.
8
+ */
9
+ export class CostCapError extends Error {
10
+ estimatedUsd;
11
+ capUsd;
12
+ constructor(estimatedUsd, capUsd) {
13
+ super(`Estimated max cost ${estimatedUsd.toFixed(4)} USD (including one retry on a malformed judge reply) exceeds cap ${capUsd.toFixed(4)} USD — refusing to call. Raise IRIS_LLM_JUDGE_MAX_COST_USD_PER_EVAL or max_cost_usd, or trim prompts/maxOutputTokens.`);
14
+ this.estimatedUsd = estimatedUsd;
15
+ this.capUsd = capUsd;
16
+ this.name = 'CostCapError';
17
+ }
18
+ }
4
19
  // Malformed judge response — retried once by `evaluate`, surfaced as
5
20
  // LLMJudgeError('malformed_response') if the retry also fails.
6
21
  function parseJudgeResponse(raw) {
@@ -88,7 +103,7 @@ export async function evaluateWithLLMJudge(params) {
88
103
  const retryCost = estimateCostUsd(params.model, estimateInputTokens(strictSystem, userPrompt), retryMaxOutputTokens);
89
104
  const estimatedCost = firstAttemptCost === null || retryCost === null ? null : firstAttemptCost + retryCost;
90
105
  if (estimatedCost !== null && estimatedCost > maxCost) {
91
- throw new Error(`Estimated max cost ${estimatedCost.toFixed(4)} USD (including one retry on a malformed judge reply) exceeds cap ${maxCost.toFixed(4)} USD — refusing to call. Raise IRIS_LLM_JUDGE_MAX_COST_USD_PER_EVAL or trim prompts/maxOutputTokens.`);
106
+ throw new CostCapError(estimatedCost, maxCost);
92
107
  }
93
108
  // First attempt
94
109
  let raw = await callLLMJudge({
@@ -136,10 +151,19 @@ export async function evaluateWithLLMJudge(params) {
136
151
  latencyMs += raw.latencyMs;
137
152
  parsed = parseJudgeResponse(raw.content);
138
153
  }
139
- 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;
140
161
  const costUsd = estimateCostUsd(params.model, inputTokens, outputTokens);
141
162
  return {
142
163
  passed,
164
+ passThreshold: template.passThreshold,
165
+ ...(parsed.passed !== undefined ? { selfReportedPass: parsed.passed } : {}),
166
+ ...(disagreement ? { disagreement: true } : {}),
143
167
  score: parsed.score,
144
168
  rationale: parsed.rationale,
145
169
  dimensions: parsed.dimensions,