@iris-eval/mcp-server 0.8.2 → 0.9.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 (114) hide show
  1. package/README.md +8 -1
  2. package/dist/capabilities.d.ts +64 -0
  3. package/dist/capabilities.js +65 -0
  4. package/dist/config/defaults.js +2 -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-Cz8_oOqG.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 +9 -28
  16. package/dist/dashboard/server.d.ts +2 -0
  17. package/dist/dashboard/server.js +6 -2
  18. package/dist/eval/accuracy.d.ts +41 -0
  19. package/dist/eval/accuracy.js +97 -0
  20. package/dist/eval/criticality.d.ts +8 -1
  21. package/dist/eval/criticality.js +6 -0
  22. package/dist/eval/dormant.d.ts +4 -0
  23. package/dist/eval/dormant.js +22 -0
  24. package/dist/eval/engine.d.ts +1 -0
  25. package/dist/eval/engine.js +49 -3
  26. package/dist/eval/failure-classes.d.ts +8 -0
  27. package/dist/eval/failure-classes.js +18 -0
  28. package/dist/eval/llm-judge/evaluator.d.ts +10 -0
  29. package/dist/eval/llm-judge/evaluator.js +16 -1
  30. package/dist/eval/published-accuracy.d.ts +230 -0
  31. package/dist/eval/published-accuracy.js +86 -0
  32. package/dist/eval/questions.d.ts +12 -0
  33. package/dist/eval/questions.js +14 -0
  34. package/dist/eval/response-schema.d.ts +652 -0
  35. package/dist/eval/response-schema.js +130 -0
  36. package/dist/eval/response.d.ts +12 -0
  37. package/dist/eval/response.js +30 -0
  38. package/dist/eval/rules/completeness.js +31 -0
  39. package/dist/eval/rules/cost.d.ts +1 -1
  40. package/dist/eval/rules/cost.js +44 -0
  41. package/dist/eval/rules/custom.d.ts +0 -12
  42. package/dist/eval/rules/custom.js +21 -0
  43. package/dist/eval/rules/relevance.js +16 -0
  44. package/dist/eval/rules/safety.js +178 -1
  45. package/dist/eval/stamp.d.ts +14 -0
  46. package/dist/eval/stamp.js +88 -0
  47. package/dist/eval/stats.d.ts +33 -0
  48. package/dist/eval/stats.js +109 -0
  49. package/dist/eval/verdict.d.ts +34 -0
  50. package/dist/eval/verdict.js +131 -0
  51. package/dist/index.js +5 -28
  52. package/dist/instructions.d.ts +17 -0
  53. package/dist/instructions.js +53 -0
  54. package/dist/judge-enablement.d.ts +34 -0
  55. package/dist/judge-enablement.js +78 -0
  56. package/dist/judge-enablement.json +10 -0
  57. package/dist/preferences.d.ts +1 -1
  58. package/dist/prompts.d.ts +3 -0
  59. package/dist/prompts.js +29 -0
  60. package/dist/resources/index.d.ts +5 -2
  61. package/dist/resources/index.js +65 -5
  62. package/dist/resources/uris.d.ts +12 -0
  63. package/dist/resources/uris.js +24 -0
  64. package/dist/retention.d.ts +20 -0
  65. package/dist/retention.js +44 -0
  66. package/dist/self-test.d.ts +1 -0
  67. package/dist/self-test.js +14 -0
  68. package/dist/server.d.ts +10 -1
  69. package/dist/server.js +34 -7
  70. package/dist/storage/index.js +1 -1
  71. package/dist/storage/migrations/007-eval-provenance.d.ts +3 -0
  72. package/dist/storage/migrations/007-eval-provenance.js +30 -0
  73. package/dist/storage/migrations/index.js +24 -4
  74. package/dist/storage/sqlite-adapter.d.ts +26 -1
  75. package/dist/storage/sqlite-adapter.js +135 -15
  76. package/dist/tools/delete-rule.d.ts +8 -0
  77. package/dist/tools/delete-rule.js +30 -38
  78. package/dist/tools/delete-trace.d.ts +5 -0
  79. package/dist/tools/delete-trace.js +24 -27
  80. package/dist/tools/deploy-rule.d.ts +13 -1
  81. package/dist/tools/deploy-rule.js +37 -34
  82. package/dist/tools/describe.d.ts +20 -0
  83. package/dist/tools/describe.js +36 -0
  84. package/dist/tools/errors.d.ts +36 -0
  85. package/dist/tools/errors.js +134 -0
  86. package/dist/tools/evaluate-output.d.ts +8 -1
  87. package/dist/tools/evaluate-output.js +37 -58
  88. package/dist/tools/evaluate-with-llm-judge.d.ts +31 -0
  89. package/dist/tools/evaluate-with-llm-judge.js +96 -69
  90. package/dist/tools/get-traces.d.ts +9 -0
  91. package/dist/tools/get-traces.js +29 -28
  92. package/dist/tools/index.d.ts +8 -0
  93. package/dist/tools/index.js +22 -1
  94. package/dist/tools/list-rules.d.ts +13 -0
  95. package/dist/tools/list-rules.js +43 -46
  96. package/dist/tools/log-trace.d.ts +4 -0
  97. package/dist/tools/log-trace.js +31 -29
  98. package/dist/tools/respond.d.ts +42 -0
  99. package/dist/tools/respond.js +90 -0
  100. package/dist/tools/strict-input.js +1 -1
  101. package/dist/tools/trace-link.d.ts +2 -0
  102. package/dist/tools/trace-link.js +13 -2
  103. package/dist/tools/verify-citations.d.ts +17 -2
  104. package/dist/tools/verify-citations.js +98 -93
  105. package/dist/types/config.d.ts +9 -0
  106. package/dist/types/eval.d.ts +258 -0
  107. package/dist/types/eval.js +2 -1
  108. package/dist/types/query.d.ts +2 -0
  109. package/package.json +1 -1
  110. package/server.json +2 -2
  111. package/dist/resources/dashboard-summary.d.ts +0 -3
  112. package/dist/resources/dashboard-summary.js +0 -16
  113. package/dist/resources/trace-detail.d.ts +0 -3
  114. package/dist/resources/trace-detail.js +0 -30
@@ -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-CyzO6OC7.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-Cz8_oOqG.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="/assets/index-D0cFfBqn.css">
11
11
  </head>
12
12
  <body>
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ import type { Capabilities } from '../../capabilities.js';
3
+ export declare function registerCapabilitiesRoutes(router: Router, build: () => Capabilities): void;
@@ -0,0 +1,11 @@
1
+ /*
2
+ * GET /api/v1/capabilities — the same object iris://capabilities serves,
3
+ * for a caller on the HTTP path (an agent driving Iris through the ingest
4
+ * endpoint, a dashboard, a health probe that wants more than "ok").
5
+ * Provider name only, never a key.
6
+ */
7
+ export function registerCapabilitiesRoutes(router, build) {
8
+ router.get('/capabilities', (_req, res) => {
9
+ res.json(build());
10
+ });
11
+ }
@@ -1,3 +1,7 @@
1
1
  import { Router } from 'express';
2
2
  import type { IStorageAdapter } from '../../types/query.js';
3
- export declare function registerHealthRoutes(router: Router, storage?: IStorageAdapter, version?: string): void;
3
+ export interface HealthOptions {
4
+ /** `demo` when serving the disposable demo database. */
5
+ mode?: 'real' | 'demo';
6
+ }
7
+ export declare function registerHealthRoutes(router: Router, storage?: IStorageAdapter, version?: string, options?: HealthOptions): void;
@@ -1,9 +1,19 @@
1
1
  import { LOCAL_TENANT } from '../../types/tenant.js';
2
+ import { judgeState } from '../../judge-enablement.js';
2
3
  const startTime = Date.now();
3
- export function registerHealthRoutes(router, storage, version) {
4
+ export function registerHealthRoutes(router, storage, version, options) {
4
5
  const serverVersion = version ?? 'unknown';
6
+ const mode = options?.mode ?? 'real';
5
7
  router.get('/health', async (_req, res) => {
6
8
  const uptime_seconds = Math.floor((Date.now() - startTime) / 1000);
9
+ /*
10
+ * The judge state, provider name only — never the key. Read per
11
+ * request rather than at boot so a test (or an operator) that sets
12
+ * the variable in this process sees it here; the process a client
13
+ * spawns has a fixed environment anyway, so both reads agree there.
14
+ */
15
+ const judge = judgeState();
16
+ const judgeField = { enabled: judge.enabled, provider: judge.provider };
7
17
  if (storage) {
8
18
  try {
9
19
  /* Health probes use LOCAL_TENANT directly — the health endpoint
@@ -26,14 +36,16 @@ export function registerHealthRoutes(router, storage, version) {
26
36
  uptime_seconds,
27
37
  trace_count: total,
28
38
  storage: 'connected',
39
+ judge: judgeField,
40
+ mode,
29
41
  });
30
42
  }
31
43
  catch {
32
- res.status(503).json({ status: 'degraded', version: serverVersion, uptime_seconds, storage: 'disconnected' });
44
+ res.status(503).json({ status: 'degraded', version: serverVersion, uptime_seconds, storage: 'disconnected', judge: judgeField, mode });
33
45
  }
34
46
  }
35
47
  else {
36
- res.json({ status: 'ok', version: serverVersion, uptime_seconds });
48
+ res.json({ status: 'ok', version: serverVersion, uptime_seconds, judge: judgeField, mode });
37
49
  }
38
50
  });
39
51
  }
@@ -110,7 +110,9 @@ export function registerRuleRoutes(router, storage, opts) {
110
110
  router.get('/rules/custom', (req, res) => {
111
111
  const tenantId = requireTenant(req);
112
112
  const rules = opts.customRuleStore.list(tenantId);
113
- res.json({ rules });
113
+ // Quarantined entries ride along: they are on disk, never registered,
114
+ // and a gating one is named on every verdict as coverage.dormant.
115
+ res.json({ rules, quarantined: opts.customRuleStore.quarantined(tenantId) });
114
116
  });
115
117
  router.post('/rules/custom', async (req, res) => {
116
118
  try {
@@ -133,6 +135,7 @@ export function registerRuleRoutes(router, storage, opts) {
133
135
  */
134
136
  const replaced = retireSameNamedRules(opts.customRuleStore, opts.evalEngine, tenantId, input.name, input.replace, 'local');
135
137
  const rule = opts.customRuleStore.deploy(tenantId, {
138
+ replaces: replaced.map((r) => r.id),
136
139
  name: input.name,
137
140
  description: input.description,
138
141
  evalType: input.evalType,
@@ -1,4 +1,5 @@
1
1
  import { Router } from 'express';
2
+ import type { CustomRuleStore } from '../../custom-rule-store.js';
2
3
  import type { IStorageAdapter } from '../../types/query.js';
3
4
  import type { EvalEngine } from '../../eval/engine.js';
4
5
  export interface TraceRouteOptions {
@@ -10,5 +11,7 @@ export interface TraceRouteOptions {
10
11
  * as a success.
11
12
  */
12
13
  evalEngine?: EvalEngine;
14
+ /** The custom-rule store, so an evaluation over HTTP carries coverage.dormant like the tool does. */
15
+ customRuleStore?: CustomRuleStore;
13
16
  }
14
17
  export declare function registerTraceRoutes(router: Router, storage: IStorageAdapter, options?: TraceRouteOptions): void;
@@ -1,3 +1,5 @@
1
+ import { toEvaluationResponse } from '../../eval/response.js';
2
+ import { dormantRulesFrom } from '../../eval/dormant.js';
1
3
  import { requireTenant } from '../../middleware/tenant.js';
2
4
  import { generateTraceId, generateSpanId } from '../../utils/ids.js';
3
5
  import { bestEffortExport } from '../../otel/lazy.js';
@@ -84,38 +86,17 @@ export function registerTraceRoutes(router, storage, options) {
84
86
  : options.evalEngine.evaluate(evalType, context);
85
87
  evaluation.trace_id = traceId;
86
88
  await storage.insertEvalResult(tenantId, evaluation);
89
+ // The same serializer as evaluate_output (src/eval/response.ts): the
90
+ // veto reason, the skipped criticals, the verdict basis, coverage and
91
+ // provenance travel the ingest path exactly as they travel the tool.
87
92
  res.status(201).json({
88
93
  trace_id: traceId,
89
94
  status: 'stored',
90
- evaluation: {
91
- id: evaluation.id,
92
- eval_type: evaluation.eval_type,
93
- score: evaluation.score,
94
- passed: evaluation.passed,
95
- rule_results: evaluation.rule_results,
96
- suggestions: evaluation.suggestions,
97
- rules_evaluated: evaluation.rules_evaluated,
98
- rules_skipped: evaluation.rules_skipped,
99
- insufficient_data: evaluation.insufficient_data,
100
- /*
101
- * The veto reason travels the ingest path too. Omitting it left an
102
- * HTTP caller seeing passed:false beside a high score with no way
103
- * to learn that a critical rule — not the weighted average —
104
- * produced the verdict. Absent when nothing vetoed.
105
- */
106
- ...(evaluation.critical_failures?.length
107
- ? { critical_failures: evaluation.critical_failures }
108
- : {}),
109
- // The other half of the veto contract, same as evaluate_output: a
110
- // critical rule that SKIPPED did not judge the output and cannot
111
- // veto, so a fail-closed gate needs to see it named.
112
- ...(evaluation.critical_skipped?.length
113
- ? { critical_skipped: evaluation.critical_skipped }
114
- : {}),
115
- // Per-bundle breakdown — eval_type="all" only.
116
- ...(evaluation.categories ? { categories: evaluation.categories } : {}),
95
+ evaluation: toEvaluationResponse(evaluation, {
96
+ traceId,
97
+ dormant: options?.customRuleStore ? dormantRulesFrom(options.customRuleStore.quarantined(tenantId)) : undefined,
117
98
  ...(evalTypeOmitted ? { note: DEFAULT_EVAL_TYPE_NOTE } : {}),
118
- },
99
+ }),
119
100
  });
120
101
  }
121
102
  catch (err) {
@@ -14,5 +14,7 @@ export interface DashboardServerOptions {
14
14
  customRuleStore?: CustomRuleStore;
15
15
  evalEngine?: EvalEngine;
16
16
  preferenceStore?: PreferenceStore;
17
+ /** when serving the disposable demo database; reported on /api/v1/capabilities. */
18
+ mode?: 'real' | 'demo';
17
19
  }
18
20
  export declare function createDashboardServer(storage: IStorageAdapter, config: IrisConfig, logger: Logger, options?: DashboardServerOptions): DashboardServer;
@@ -4,6 +4,8 @@ import { fileURLToPath } from 'node:url';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
6
6
  import { irisHome } from '../utils/iris-home.js';
7
+ import { buildCapabilities } from '../capabilities.js';
8
+ import { registerCapabilitiesRoutes } from './routes/capabilities.js';
7
9
  import { createAuthMiddleware } from '../middleware/auth.js';
8
10
  import { createCorsMiddleware } from '../middleware/cors.js';
9
11
  import { createErrorHandler } from '../middleware/error-handler.js';
@@ -75,12 +77,14 @@ export function createDashboardServer(storage, config, logger, options) {
75
77
  // API routes with rate limiting
76
78
  const router = express.Router();
77
79
  router.use(createApiRateLimiter(config));
78
- registerTraceRoutes(router, storage, { evalEngine: options?.evalEngine });
80
+ registerTraceRoutes(router, storage, { evalEngine: options?.evalEngine, customRuleStore: options?.customRuleStore });
79
81
  registerSummaryRoutes(router, storage);
80
82
  registerEvaluationRoutes(router, storage);
81
83
  registerEvalStatsRoutes(router, storage);
82
84
  registerFilterRoutes(router, storage);
83
- registerHealthRoutes(router, storage, config.server.version);
85
+ registerHealthRoutes(router, storage, config.server.version, { mode: options?.mode });
86
+ // The same object iris://capabilities serves, for the HTTP path.
87
+ registerCapabilitiesRoutes(router, () => buildCapabilities({ config, evalEngine: options?.evalEngine, customRuleStore: options?.customRuleStore, mode: options?.mode }));
84
88
  registerMomentRoutes(router, storage);
85
89
  registerFailureRoutes(router, storage);
86
90
  if (options?.customRuleStore && options?.evalEngine) {
@@ -0,0 +1,41 @@
1
+ import { type Confusion } from './stats.js';
2
+ export interface Interval {
3
+ point: number;
4
+ lo: number;
5
+ hi: number;
6
+ }
7
+ export interface PublishedRuleAccuracy extends Confusion {
8
+ n: number;
9
+ precision: number | null;
10
+ recall: number | null;
11
+ f1: number | null;
12
+ ci95: {
13
+ precision: readonly [number, number] | null;
14
+ recall: readonly [number, number] | null;
15
+ f1: readonly [number, number] | null;
16
+ };
17
+ }
18
+ export interface PublishedProvenance {
19
+ corpusVersion: string;
20
+ release: string;
21
+ labelling: 'same-model' | 'human-verified';
22
+ }
23
+ export declare const DEFAULT_PREVALENCE = 0.5;
24
+ export declare const INTERVAL_DRAWS = 2000;
25
+ export declare function publishedProvenance(): PublishedProvenance;
26
+ /** The published numbers for a built-in rule, or null for a rule with no family (a custom rule, a rule added before its proof). */
27
+ export declare function publishedAccuracyFor(ruleName: string): PublishedRuleAccuracy | null;
28
+ /** Every rule name with published numbers, in the order the proof emitted them. */
29
+ export declare function publishedRuleNames(): string[];
30
+ /**
31
+ * PPV at a prevalence with a 95% credible interval, for a rule that FIRED.
32
+ * Null when the rule has no published family or its family has no positives
33
+ * or no negatives (nothing to estimate from).
34
+ */
35
+ export declare function ppvInterval(ruleName: string, prevalence?: number): Interval | null;
36
+ /** P(violation | the rule did not fire) at a prevalence with a 95% credible interval, for a rule that did NOT fire. */
37
+ export declare function missRateInterval(ruleName: string, prevalence?: number): Interval | null;
38
+ /** PPV at several prevalences — the field-prevalence table a reader needs beside a published precision. */
39
+ export declare function ppvAt(ruleName: string, prevalences?: readonly number[]): Record<string, number | null>;
40
+ /** Test hook: clear the memo so a seeded interval can be recomputed. */
41
+ export declare function resetAccuracyMemo(): void;
@@ -0,0 +1,97 @@
1
+ /*
2
+ * The published accuracy, read at runtime.
3
+ *
4
+ * `npm run proof` measures every built-in rule on its labelled family and
5
+ * writes the numbers to proof/results.json — and, since 0.9.0, to
6
+ * src/eval/published-accuracy.ts, a generated module that ships inside the
7
+ * package (the npm `files` list carries dist/ only, so a runtime read of
8
+ * proof/ would find nothing). `npm run proof -- --check` diffs the generated
9
+ * module too, so the numbers a verdict carries are the numbers on /proof.
10
+ *
11
+ * What this module adds: the arithmetic that turns a confusion matrix into
12
+ * "how often is this fire right for YOU". The published precision is the
13
+ * positive predictive value at the corpus prevalence, roughly one half; a
14
+ * deployment whose traffic carries one violation in a hundred sees a very
15
+ * different number from the same rule. Both the point and its interval are
16
+ * computed here — the interval by seeded Monte Carlo over the Beta posteriors
17
+ * of sensitivity and specificity (Jeffreys prior, ½ pseudo-count per cell),
18
+ * two thousand draws, memoised per (rule, prevalence to three decimals) so
19
+ * the draws run once per process.
20
+ *
21
+ * Every number carries its provenance: the corpus version, the release it
22
+ * was generated for, and the labelling ('same-model' until the founder's
23
+ * blind label lands). A surface that drops the labelling tag is a truth
24
+ * defect, not a formatting choice.
25
+ */
26
+ import { PUBLISHED_ACCURACY, PUBLISHED_ACCURACY_CORPUS_VERSION, PUBLISHED_ACCURACY_LABELLING, PUBLISHED_ACCURACY_RELEASE } from './published-accuracy.js';
27
+ import { beta, fnv1a, missRate, mulberry32, percentile95, ppv, round4, sensitivity, specificity } from './stats.js';
28
+ export const DEFAULT_PREVALENCE = 0.5;
29
+ export const INTERVAL_DRAWS = 2000;
30
+ export function publishedProvenance() {
31
+ return { corpusVersion: PUBLISHED_ACCURACY_CORPUS_VERSION, release: PUBLISHED_ACCURACY_RELEASE, labelling: PUBLISHED_ACCURACY_LABELLING };
32
+ }
33
+ /** The published numbers for a built-in rule, or null for a rule with no family (a custom rule, a rule added before its proof). */
34
+ export function publishedAccuracyFor(ruleName) {
35
+ const entry = PUBLISHED_ACCURACY[ruleName];
36
+ return entry ?? null;
37
+ }
38
+ /** Every rule name with published numbers, in the order the proof emitted them. */
39
+ export function publishedRuleNames() {
40
+ return Object.keys(PUBLISHED_ACCURACY);
41
+ }
42
+ const memo = new Map();
43
+ const key = (ruleName, prevalence, which) => `${which}:${ruleName}:${prevalence.toFixed(3)}`;
44
+ function sampleInterval(counts, prevalence, seed, fn) {
45
+ const sens = sensitivity(counts);
46
+ const spec = specificity(counts);
47
+ if (sens === null || spec === null)
48
+ return null;
49
+ const rng = mulberry32(fnv1a(seed));
50
+ const draws = [];
51
+ for (let i = 0; i < INTERVAL_DRAWS; i++) {
52
+ const s = beta(counts.tp + 0.5, counts.fn + 0.5, rng);
53
+ const p = beta(counts.tn + 0.5, counts.fp + 0.5, rng);
54
+ draws.push(fn(s, p, prevalence));
55
+ }
56
+ const [lo, hi] = percentile95(draws);
57
+ return { point: round4(fn(sens, spec, prevalence)), lo: round4(lo), hi: round4(hi) };
58
+ }
59
+ /**
60
+ * PPV at a prevalence with a 95% credible interval, for a rule that FIRED.
61
+ * Null when the rule has no published family or its family has no positives
62
+ * or no negatives (nothing to estimate from).
63
+ */
64
+ export function ppvInterval(ruleName, prevalence = DEFAULT_PREVALENCE) {
65
+ const k = key(ruleName, prevalence, 'ppv');
66
+ if (memo.has(k))
67
+ return memo.get(k) ?? null;
68
+ const counts = publishedAccuracyFor(ruleName);
69
+ const result = counts ? sampleInterval(counts, prevalence, `ppv:${ruleName}:${PUBLISHED_ACCURACY_CORPUS_VERSION}`, ppv) : null;
70
+ memo.set(k, result);
71
+ return result;
72
+ }
73
+ /** P(violation | the rule did not fire) at a prevalence with a 95% credible interval, for a rule that did NOT fire. */
74
+ export function missRateInterval(ruleName, prevalence = DEFAULT_PREVALENCE) {
75
+ const k = key(ruleName, prevalence, 'miss');
76
+ if (memo.has(k))
77
+ return memo.get(k) ?? null;
78
+ const counts = publishedAccuracyFor(ruleName);
79
+ const result = counts ? sampleInterval(counts, prevalence, `miss:${ruleName}:${PUBLISHED_ACCURACY_CORPUS_VERSION}`, missRate) : null;
80
+ memo.set(k, result);
81
+ return result;
82
+ }
83
+ /** PPV at several prevalences — the field-prevalence table a reader needs beside a published precision. */
84
+ export function ppvAt(ruleName, prevalences = [0.01, 0.05, 0.2, 0.5]) {
85
+ const counts = publishedAccuracyFor(ruleName);
86
+ const out = {};
87
+ for (const p of prevalences) {
88
+ const sens = counts ? sensitivity(counts) : null;
89
+ const spec = counts ? specificity(counts) : null;
90
+ out[p.toFixed(2)] = sens === null || spec === null ? null : round4(ppv(sens, spec, p));
91
+ }
92
+ return out;
93
+ }
94
+ /** Test hook: clear the memo so a seeded interval can be recomputed. */
95
+ export function resetAccuracyMemo() {
96
+ memo.clear();
97
+ }
@@ -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 {
@@ -50,6 +50,13 @@ export interface BuiltInRuleMeta {
50
50
  critical: boolean;
51
51
  /** Who decided it: the rule's own declaration, or one of the config lists. */
52
52
  criticalSource: CriticalitySource;
53
+ /** 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. */
54
+ kind?: ClaimKind;
55
+ mechanism?: Mechanism;
56
+ needs?: readonly Need[];
57
+ question?: QuestionId;
58
+ classes?: readonly FailureClass[];
59
+ version?: number;
53
60
  }
54
61
  /**
55
62
  * 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
  }
@@ -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,6 +39,7 @@ 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
@@ -1,5 +1,8 @@
1
1
  import { getRulesForType, createCustomRule } from './rules/index.js';
2
2
  import { criticalityResolver } from './criticality.js';
3
+ import { inputsPresent, stampRuleResult } from './stamp.js';
4
+ import { buildProvenance, configHash, deriveCoverage, deriveVerdict, rulesetHash } from './verdict.js';
5
+ import { PKG_VERSION } from '../config/defaults.js';
3
6
  import { generateEvalId } from '../utils/ids.js';
4
7
  /**
5
8
  * Every bundle eval_type="all" walks, in the order their categories are
@@ -40,6 +43,7 @@ export class EvalEngine {
40
43
  idByRule = new Map();
41
44
  threshold;
42
45
  ruleThresholds;
46
+ criticalityOverrides;
43
47
  /**
44
48
  * Effective criticality per rule, bound to this engine's config overrides.
45
49
  * Every veto decision reads THIS, never `rule.critical` directly, so a
@@ -55,6 +59,7 @@ export class EvalEngine {
55
59
  constructor(threshold = 0.7, ruleThresholds, criticalityOverrides) {
56
60
  this.threshold = threshold;
57
61
  this.ruleThresholds = ruleThresholds;
62
+ this.criticalityOverrides = criticalityOverrides;
58
63
  this.criticality = criticalityResolver(criticalityOverrides);
59
64
  }
60
65
  /** The effective criticality of one rule under this engine's config. Read by the rule roster surfaces. */
@@ -177,6 +182,7 @@ export class EvalEngine {
177
182
  rules_evaluated: 0,
178
183
  rules_skipped: 0,
179
184
  insufficient_data: true,
185
+ verdict: { state: 'unknown', passed: false, basis: 'no_rules', by: [], risk: null },
180
186
  };
181
187
  }
182
188
  /*
@@ -201,10 +207,20 @@ export class EvalEngine {
201
207
  * the one path every evaluation takes, so a surface cannot render the
202
208
  * declared criticality where the engine applied a configured one.
203
209
  */
204
- const { critical, source } = this.criticality(rule);
210
+ const effective = this.criticality(rule);
211
+ const { critical, source } = effective;
205
212
  // ruleId / category sit right after the name so a reader scanning
206
213
  // rule_results sees WHICH deployed rule (and which bundle) spoke.
207
214
  const { ruleName, ...rest } = raw;
215
+ /*
216
+ * The stamp (0.9.0): what kind of claim this is, what the composer
217
+ * did with it, which question it answers, what it saw, why it skipped,
218
+ * and how wrong it tends to be — from the rule's declaration, the
219
+ * inputs this call carried, and the published accuracy that ships in
220
+ * the package. Computed here, on the one path every evaluation takes,
221
+ * so no surface can show a result without its receipt. It changes no
222
+ * verdict: summarize() below still decides passed exactly as before.
223
+ */
208
224
  return {
209
225
  ruleName,
210
226
  ...(ruleId !== undefined ? { ruleId } : {}),
@@ -212,10 +228,32 @@ export class EvalEngine {
212
228
  critical,
213
229
  criticalSource: source,
214
230
  ...rest,
231
+ ...stampRuleResult(rule, raw, context, effective),
215
232
  };
216
233
  });
217
234
  const overall = this.summarize(rules, ruleResults);
218
235
  const perCategory = categories ? this.categorize(rules, ruleResults, categories) : undefined;
236
+ /*
237
+ * The receipt for the whole evaluation (0.9.0): what produced it, which
238
+ * questions it judged, and the basis of its verdict. Computed here from
239
+ * what the engine already holds; persisted as provenance and derived
240
+ * again on every read, so a stored row answers "why did this pass on
241
+ * that day" without a backfill. Changes no verdict.
242
+ */
243
+ const provenance = buildProvenance({
244
+ irisVersion: PKG_VERSION,
245
+ rulesetHash: rulesetHash(rules, (r) => this.criticality(r)),
246
+ configHash: configHash({
247
+ threshold: this.threshold,
248
+ ruleThresholds: this.ruleThresholds,
249
+ criticalRules: this.criticalityOverrides?.criticalRules,
250
+ nonCriticalRules: this.criticalityOverrides?.nonCriticalRules,
251
+ }),
252
+ threshold: this.threshold,
253
+ ruleThresholds: this.ruleThresholds,
254
+ judgedAt: new Date().toISOString(),
255
+ });
256
+ const coverage = deriveCoverage(ruleResults, inputsPresent(context));
219
257
  // Handle "all rules skipped" — insufficient data
220
258
  if (overall.rulesEvaluated === 0) {
221
259
  const skipMessages = ruleResults
@@ -225,7 +263,7 @@ export class EvalEngine {
225
263
  // that EVERY critical rule that skipped is named here, and a caller
226
264
  // whose only rules were critical ones should not have to infer that
227
265
  // from insufficient_data alone.
228
- return {
266
+ const unknown = {
229
267
  id: generateEvalId(),
230
268
  eval_type: evalType,
231
269
  output_text: context.output,
@@ -242,7 +280,11 @@ export class EvalEngine {
242
280
  insufficient_data: true,
243
281
  ...(overall.criticalSkipped.length > 0 ? { critical_skipped: overall.criticalSkipped } : {}),
244
282
  ...(perCategory ? { categories: perCategory } : {}),
283
+ coverage,
284
+ provenance,
245
285
  };
286
+ unknown.verdict = deriveVerdict(unknown, this.threshold);
287
+ return unknown;
246
288
  }
247
289
  const suggestions = [];
248
290
  for (const result of ruleResults) {
@@ -273,7 +315,7 @@ export class EvalEngine {
273
315
  'checks, not "clean"; a gate that must fail closed should treat critical_skipped ' +
274
316
  'as a failure.');
275
317
  }
276
- return {
318
+ const result = {
277
319
  id: generateEvalId(),
278
320
  eval_type: evalType,
279
321
  output_text: context.output,
@@ -288,7 +330,11 @@ export class EvalEngine {
288
330
  ...(overall.criticalFailures.length > 0 ? { critical_failures: overall.criticalFailures } : {}),
289
331
  ...(overall.criticalSkipped.length > 0 ? { critical_skipped: overall.criticalSkipped } : {}),
290
332
  ...(perCategory ? { categories: perCategory } : {}),
333
+ coverage,
334
+ provenance,
291
335
  };
336
+ result.verdict = deriveVerdict(result, this.threshold);
337
+ return result;
292
338
  }
293
339
  /**
294
340
  * 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;
@@ -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({