@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
@@ -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-CeJbaq6m.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';
@@ -80,42 +82,21 @@ export function registerTraceRoutes(router, storage, options) {
80
82
  const evalTypeOmitted = body.eval_type === undefined;
81
83
  const evalType = body.eval_type ?? DEFAULT_EVAL_TYPE;
82
84
  const evaluation = evalType === 'all'
83
- ? options.evalEngine.evaluateAll(context)
84
- : options.evalEngine.evaluate(evalType, context);
85
+ ? await options.evalEngine.evaluateAll(context)
86
+ : await 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) {
@@ -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
@@ -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
+ }
@@ -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
+ }