@iris-eval/mcp-server 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/dist/config/defaults.js +15 -0
  3. package/dist/dashboard/assets/{index-Cz8_oOqG.js → index-CeJbaq6m.js} +1 -1
  4. package/dist/dashboard/index.html +1 -1
  5. package/dist/dashboard/routes/traces.js +2 -2
  6. package/dist/dashboard/seed-demo-data.js +1 -1
  7. package/dist/eval/citation-verify/verifier.d.ts +16 -1
  8. package/dist/eval/citation-verify/verifier.js +14 -4
  9. package/dist/eval/compose.d.ts +57 -0
  10. package/dist/eval/compose.js +179 -0
  11. package/dist/eval/criticality.d.ts +7 -0
  12. package/dist/eval/decision-moment.js +33 -4
  13. package/dist/eval/engine.d.ts +5 -2
  14. package/dist/eval/engine.js +81 -13
  15. package/dist/eval/llm-judge/evaluator.d.ts +20 -0
  16. package/dist/eval/llm-judge/evaluator.js +10 -1
  17. package/dist/eval/published-accuracy.d.ts +22 -22
  18. package/dist/eval/published-accuracy.js +11 -11
  19. package/dist/eval/risk.d.ts +60 -0
  20. package/dist/eval/risk.js +187 -0
  21. package/dist/eval/rules/completeness.js +5 -1
  22. package/dist/eval/rules/cost.d.ts +1 -1
  23. package/dist/eval/rules/cost.js +6 -6
  24. package/dist/eval/rules/custom.js +1 -0
  25. package/dist/eval/rules/relevance.js +7 -2
  26. package/dist/eval/rules/safety.d.ts +6 -2
  27. package/dist/eval/rules/safety.js +55 -59
  28. package/dist/eval/seeded-random.d.ts +4 -0
  29. package/dist/eval/seeded-random.js +36 -0
  30. package/dist/eval/stamp.d.ts +1 -1
  31. package/dist/eval/stamp.js +1 -0
  32. package/dist/eval/text/checksums.d.ts +23 -0
  33. package/dist/eval/text/checksums.js +97 -0
  34. package/dist/eval/text/normalise.d.ts +30 -0
  35. package/dist/eval/text/normalise.js +265 -0
  36. package/dist/eval/text/sentences.d.ts +15 -0
  37. package/dist/eval/text/sentences.js +149 -0
  38. package/dist/self-test.js +3 -3
  39. package/dist/storage/sqlite-adapter.js +16 -2
  40. package/dist/tools/evaluate-output.js +2 -2
  41. package/dist/tools/evaluate-with-llm-judge.d.ts +3 -0
  42. package/dist/tools/evaluate-with-llm-judge.js +29 -1
  43. package/dist/tools/verify-citations.d.ts +2 -1
  44. package/dist/tools/verify-citations.js +25 -4
  45. package/dist/types/config.d.ts +35 -0
  46. package/dist/types/eval.d.ts +51 -0
  47. package/package.json +1 -1
  48. package/server.json +2 -2
@@ -1,5 +1,5 @@
1
- export declare const PUBLISHED_ACCURACY_CORPUS_VERSION = "1251d242916c";
2
- export declare const PUBLISHED_ACCURACY_RELEASE = "0.9.0";
1
+ export declare const PUBLISHED_ACCURACY_CORPUS_VERSION = "4951762e456d";
2
+ export declare const PUBLISHED_ACCURACY_RELEASE = "0.10.0";
3
3
  export declare const PUBLISHED_ACCURACY_LABELLING: "same-model";
4
4
  export declare const PUBLISHED_ACCURACY: {
5
5
  readonly min_output_length: {
@@ -34,17 +34,17 @@ export declare const PUBLISHED_ACCURACY: {
34
34
  };
35
35
  readonly sentence_count: {
36
36
  readonly n: 30;
37
- readonly tp: 8;
37
+ readonly tp: 16;
38
38
  readonly fp: 0;
39
- readonly fn: 6;
40
- readonly tn: 16;
39
+ readonly fn: 0;
40
+ readonly tn: 14;
41
41
  readonly precision: 1;
42
- readonly recall: 0.5714;
43
- readonly f1: 0.7273;
42
+ readonly recall: 1;
43
+ readonly f1: 1;
44
44
  readonly ci95: {
45
- readonly precision: readonly [0.6756, 1];
46
- readonly recall: readonly [0.3259, 0.7862];
47
- readonly f1: readonly [0.4706, 0.9091];
45
+ readonly precision: readonly [0.8064, 1];
46
+ readonly recall: readonly [0.8064, 1];
47
+ readonly f1: readonly [1, 1];
48
48
  };
49
49
  };
50
50
  readonly expected_coverage: {
@@ -93,33 +93,33 @@ export declare const PUBLISHED_ACCURACY: {
93
93
  };
94
94
  };
95
95
  readonly no_pii: {
96
- readonly n: 90;
96
+ readonly n: 93;
97
97
  readonly tp: 34;
98
98
  readonly fp: 5;
99
99
  readonly fn: 11;
100
- readonly tn: 40;
100
+ readonly tn: 43;
101
101
  readonly precision: 0.8718;
102
102
  readonly recall: 0.7556;
103
103
  readonly f1: 0.8095;
104
104
  readonly ci95: {
105
105
  readonly precision: readonly [0.7329, 0.944];
106
106
  readonly recall: readonly [0.6133, 0.8576];
107
- readonly f1: readonly [0.7105, 0.8936];
107
+ readonly f1: readonly [0.7077, 0.8932];
108
108
  };
109
109
  };
110
110
  readonly no_blocklist_words: {
111
111
  readonly n: 32;
112
- readonly tp: 11;
112
+ readonly tp: 14;
113
113
  readonly fp: 1;
114
- readonly fn: 4;
114
+ readonly fn: 1;
115
115
  readonly tn: 16;
116
- readonly precision: 0.9167;
117
- readonly recall: 0.7333;
118
- readonly f1: 0.8148;
116
+ readonly precision: 0.9333;
117
+ readonly recall: 0.9333;
118
+ readonly f1: 0.9333;
119
119
  readonly ci95: {
120
- readonly precision: readonly [0.6461, 0.9851];
121
- readonly recall: readonly [0.4805, 0.891];
122
- readonly f1: readonly [0.6087, 0.9524];
120
+ readonly precision: readonly [0.7018, 0.9881];
121
+ readonly recall: readonly [0.7018, 0.9881];
122
+ readonly f1: readonly [0.8148, 1];
123
123
  };
124
124
  };
125
125
  readonly no_injection_patterns: {
@@ -197,7 +197,7 @@ export declare const PUBLISHED_ACCURACY: {
197
197
  readonly f1: readonly [1, 1];
198
198
  };
199
199
  };
200
- readonly token_efficiency: {
200
+ readonly verbosity_ratio: {
201
201
  readonly n: 25;
202
202
  readonly tp: 9;
203
203
  readonly fp: 0;
@@ -4,8 +4,8 @@
4
4
  * runner produces. Read by src/eval/accuracy.ts; the numbers a verdict carries
5
5
  * are the numbers on https://iris-eval.com/proof, for the release named below.
6
6
  */
7
- export const PUBLISHED_ACCURACY_CORPUS_VERSION = '1251d242916c';
8
- export const PUBLISHED_ACCURACY_RELEASE = '0.9.0';
7
+ export const PUBLISHED_ACCURACY_CORPUS_VERSION = '4951762e456d';
8
+ export const PUBLISHED_ACCURACY_RELEASE = '0.10.0';
9
9
  export const PUBLISHED_ACCURACY_LABELLING = 'same-model';
10
10
  export const PUBLISHED_ACCURACY = {
11
11
  min_output_length: {
@@ -19,9 +19,9 @@ export const PUBLISHED_ACCURACY = {
19
19
  ci95: { precision: [0.7225, 1], recall: [0.552, 0.953], f1: [0.7368, 1] },
20
20
  },
21
21
  sentence_count: {
22
- n: 30, tp: 8, fp: 0, fn: 6, tn: 16,
23
- precision: 1, recall: 0.5714, f1: 0.7273,
24
- ci95: { precision: [0.6756, 1], recall: [0.3259, 0.7862], f1: [0.4706, 0.9091] },
22
+ n: 30, tp: 16, fp: 0, fn: 0, tn: 14,
23
+ precision: 1, recall: 1, f1: 1,
24
+ ci95: { precision: [0.8064, 1], recall: [0.8064, 1], f1: [1, 1] },
25
25
  },
26
26
  expected_coverage: {
27
27
  n: 29, tp: 14, fp: 0, fn: 0, tn: 15,
@@ -39,14 +39,14 @@ export const PUBLISHED_ACCURACY = {
39
39
  ci95: { precision: [0.7412, 1], recall: [0.6461, 0.9851], f1: [0.8333, 1] },
40
40
  },
41
41
  no_pii: {
42
- n: 90, tp: 34, fp: 5, fn: 11, tn: 40,
42
+ n: 93, tp: 34, fp: 5, fn: 11, tn: 43,
43
43
  precision: 0.8718, recall: 0.7556, f1: 0.8095,
44
- ci95: { precision: [0.7329, 0.944], recall: [0.6133, 0.8576], f1: [0.7105, 0.8936] },
44
+ ci95: { precision: [0.7329, 0.944], recall: [0.6133, 0.8576], f1: [0.7077, 0.8932] },
45
45
  },
46
46
  no_blocklist_words: {
47
- n: 32, tp: 11, fp: 1, fn: 4, tn: 16,
48
- precision: 0.9167, recall: 0.7333, f1: 0.8148,
49
- ci95: { precision: [0.6461, 0.9851], recall: [0.4805, 0.891], f1: [0.6087, 0.9524] },
47
+ n: 32, tp: 14, fp: 1, fn: 1, tn: 16,
48
+ precision: 0.9333, recall: 0.9333, f1: 0.9333,
49
+ ci95: { precision: [0.7018, 0.9881], recall: [0.7018, 0.9881], f1: [0.8148, 1] },
50
50
  },
51
51
  no_injection_patterns: {
52
52
  n: 90, tp: 41, fp: 0, fn: 1, tn: 48,
@@ -73,7 +73,7 @@ export const PUBLISHED_ACCURACY = {
73
73
  precision: 1, recall: 1, f1: 1,
74
74
  ci95: { precision: [0.7225, 1], recall: [0.7225, 1], f1: [1, 1] },
75
75
  },
76
- token_efficiency: {
76
+ verbosity_ratio: {
77
77
  n: 25, tp: 9, fp: 0, fn: 0, tn: 16,
78
78
  precision: 1, recall: 1, f1: 1,
79
79
  ci95: { precision: [0.7009, 1], recall: [0.7009, 1], f1: [1, 1] },
@@ -0,0 +1,60 @@
1
+ import type { EvalResult, FailureClass } from '../types/eval.js';
2
+ export declare const RISK_DRAWS = 2000;
3
+ export declare const DEFAULT_PRIOR = 0.5;
4
+ /**
5
+ * What the prior means (arc 2 finding, for arc 3's deliberation):
6
+ * 'per-class' — plan §4.3 as written: π is the prior that EACH examined
7
+ * class is present. With K classes examined the prior that
8
+ * nothing is wrong is (1 − π)^K — at π = 0.5 and K = 10 it
9
+ * is 0.001, so the noisy-OR blocks nearly everything.
10
+ * 'per-output' — π is the prior that the OUTPUT is bad (any class
11
+ * present); it is spread over the K examined classes as
12
+ * π_c = 1 − (1 − π)^(1/K), so that Π(1 − π_c) = 1 − π.
13
+ * Both are measured on the composite corpus so the choice is made on a
14
+ * number, not a preference.
15
+ */
16
+ export type PriorMode = 'per-class' | 'per-output';
17
+ export declare const DEFAULT_PRIOR_MODE: PriorMode;
18
+ /** τ = 1 / (1 + c) with c = 1: a false pass costs the same as a false block. */
19
+ export declare const DEFAULT_FALSE_PASS_COST = 1;
20
+ export declare const DEFAULT_TAU: number;
21
+ export interface RiskEstimate {
22
+ pBad: number;
23
+ lo: number;
24
+ hi: number;
25
+ perClass: Record<string, number | null>;
26
+ assumptions: string[];
27
+ }
28
+ export type RiskBasis = 'policy_gate' | 'detector_veto' | 'risk_over_loss' | 'clean' | 'no_rules';
29
+ export interface RiskVerdict {
30
+ state: 'pass' | 'fail' | 'unknown';
31
+ basis: RiskBasis;
32
+ by: string[];
33
+ risk: RiskEstimate | null;
34
+ confidence: 'decisive' | 'marginal' | null;
35
+ }
36
+ interface Detector {
37
+ name: string;
38
+ classes: FailureClass[];
39
+ fired: boolean;
40
+ counts: {
41
+ tp: number;
42
+ fp: number;
43
+ fn: number;
44
+ tn: number;
45
+ };
46
+ }
47
+ /** The evaluated detections and inferences with a published family, one entry per rule. */
48
+ export declare function detectorsOf(result: EvalResult): Detector[];
49
+ /** The per-class prior under a mode, given how many classes the detectors examine. */
50
+ export declare function classPrior(prior: number, mode: PriorMode, examinedClasses: number): number;
51
+ /** p_bad with a 95% credible interval from the Beta posteriors of every detector's sensitivity and specificity. */
52
+ export declare function riskEstimate(result: EvalResult, prior?: number, mode?: PriorMode): RiskEstimate | null;
53
+ /**
54
+ * Compose by kind, as arc 3 will: gates (a failing policy that is effectively
55
+ * critical here), then vetoes (a failing effectively-critical detection),
56
+ * then the risk against τ. `unknown` when a critical rule was asked and could
57
+ * not answer (defeated or config_invalid) — the fail-closed seam.
58
+ */
59
+ export declare function riskVerdict(result: EvalResult, tau?: number, prior?: number, mode?: PriorMode): RiskVerdict;
60
+ export {};
@@ -0,0 +1,187 @@
1
+ import { publishedAccuracyFor } from './accuracy.js';
2
+ import { PUBLISHED_ACCURACY_CORPUS_VERSION } from './published-accuracy.js';
3
+ import { FAILURE_CLASS_IDS } from './failure-classes.js';
4
+ import { fnv1a, mulberry32 } from './seeded-random.js';
5
+ /** Jeffreys prior: half a count on each cell, so a family that made no mistakes does not claim certainty. */
6
+ const HALF = 0.5;
7
+ const sensOf = (d) => (d.counts.tp + HALF) / (d.counts.tp + d.counts.fn + 2 * HALF);
8
+ const specOf = (d) => (d.counts.tn + HALF) / (d.counts.tn + d.counts.fp + 2 * HALF);
9
+ export const RISK_DRAWS = 2000;
10
+ export const DEFAULT_PRIOR = 0.5;
11
+ export const DEFAULT_PRIOR_MODE = 'per-output';
12
+ /** τ = 1 / (1 + c) with c = 1: a false pass costs the same as a false block. */
13
+ export const DEFAULT_FALSE_PASS_COST = 1;
14
+ export const DEFAULT_TAU = 1 / (1 + DEFAULT_FALSE_PASS_COST);
15
+ function beta(a, b, rng) {
16
+ // Marsaglia–Tsang gamma with the shape < 1 boost; beta = ga / (ga + gb).
17
+ const gamma = (shape) => {
18
+ if (shape < 1)
19
+ return gamma(shape + 1) * Math.pow(rng(), 1 / shape);
20
+ const d = shape - 1 / 3;
21
+ const c = 1 / Math.sqrt(9 * d);
22
+ for (;;) {
23
+ let x;
24
+ let v;
25
+ do {
26
+ const u1 = rng();
27
+ const u2 = rng();
28
+ x = Math.sqrt(-2 * Math.log(u1 || 1e-12)) * Math.cos(2 * Math.PI * u2);
29
+ v = 1 + c * x;
30
+ } while (v <= 0);
31
+ v = v * v * v;
32
+ const u = rng();
33
+ if (u < 1 - 0.0331 * x * x * x * x)
34
+ return d * v;
35
+ if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v)))
36
+ return d * v;
37
+ }
38
+ };
39
+ const ga = gamma(a);
40
+ const gb = gamma(b);
41
+ return ga / (ga + gb);
42
+ }
43
+ /** The evaluated detections and inferences with a published family, one entry per rule. */
44
+ export function detectorsOf(result) {
45
+ const out = [];
46
+ for (const r of result.rule_results) {
47
+ if (r.skipped)
48
+ continue;
49
+ if (r.kind !== 'detection' && r.kind !== 'inference')
50
+ continue;
51
+ const acc = publishedAccuracyFor(r.ruleName);
52
+ if (!acc)
53
+ continue;
54
+ out.push({
55
+ name: r.ruleName,
56
+ classes: (r.classes ?? []),
57
+ fired: r.passed === false,
58
+ counts: { tp: acc.tp, fp: acc.fp, fn: acc.fn, tn: acc.tn },
59
+ });
60
+ }
61
+ return out;
62
+ }
63
+ /** The per-class prior under a mode, given how many classes the detectors examine. */
64
+ export function classPrior(prior, mode, examinedClasses) {
65
+ if (mode === 'per-class' || examinedClasses <= 1)
66
+ return prior;
67
+ return 1 - Math.pow(1 - prior, 1 / examinedClasses);
68
+ }
69
+ function pBadFrom(detectors, prior, mode, sensOf, specOf) {
70
+ const perClass = {};
71
+ let survive = 1;
72
+ const examinedClasses = FAILURE_CLASS_IDS.filter((cls) => detectors.some((d) => d.classes.includes(cls))).length;
73
+ const priorC = classPrior(prior, mode, examinedClasses);
74
+ for (const cls of FAILURE_CLASS_IDS) {
75
+ const examined = detectors.filter((d) => d.classes.includes(cls));
76
+ if (examined.length === 0) {
77
+ perClass[cls] = null;
78
+ continue;
79
+ }
80
+ const fired = examined.filter((d) => d.fired);
81
+ let q;
82
+ if (fired.length > 0) {
83
+ q = Math.max(...fired.map((d) => {
84
+ const s = sensOf(d);
85
+ const p = specOf(d);
86
+ const den = s * priorC + (1 - p) * (1 - priorC);
87
+ return den === 0 ? 0 : (s * priorC) / den;
88
+ }));
89
+ }
90
+ else {
91
+ let missAll = 1;
92
+ let specAll = 1;
93
+ for (const d of examined) {
94
+ missAll *= 1 - sensOf(d);
95
+ specAll *= specOf(d);
96
+ }
97
+ const den = priorC * missAll + (1 - priorC) * specAll;
98
+ q = den === 0 ? 0 : (priorC * missAll) / den;
99
+ }
100
+ perClass[cls] = q;
101
+ survive *= 1 - q;
102
+ }
103
+ return { pBad: 1 - survive, perClass };
104
+ }
105
+ const round4 = (x) => Math.round(x * 10_000) / 10_000;
106
+ /** p_bad with a 95% credible interval from the Beta posteriors of every detector's sensitivity and specificity. */
107
+ export function riskEstimate(result, prior = DEFAULT_PRIOR, mode = DEFAULT_PRIOR_MODE) {
108
+ const detectors = detectorsOf(result);
109
+ if (detectors.length === 0)
110
+ return null;
111
+ /*
112
+ * Jeffreys half-counts in the POINT estimate, not only in the draws.
113
+ *
114
+ * Twelve of the fifteen published families recorded zero false positives.
115
+ * With specificity exactly 1 the positive predictive value of a fire is
116
+ * exactly 1 at every prior, so p_bad read exactly 1.000 on 31 of the 111
117
+ * composite cases — the same overconfidence the credible interval was
118
+ * added to cure, reintroduced one layer down. A half-count on each cell
119
+ * says what thirty cases can actually support: no_silent_tool_failure's
120
+ * single-fire contribution moves from 1.000 to about 0.69, still well
121
+ * over the shipped threshold, and now with an interval that means
122
+ * something. Found by arc 2 while writing up the composer, not by
123
+ * reading it.
124
+ */
125
+ const point = pBadFrom(detectors, prior, mode, sensOf, specOf);
126
+ const rng = mulberry32(fnv1a(`risk:${PUBLISHED_ACCURACY_CORPUS_VERSION}:${mode}:${prior.toFixed(3)}:${detectors.map((d) => `${d.name}${d.fired ? '!' : ''}`).join(',')}`));
127
+ const draws = [];
128
+ for (let i = 0; i < RISK_DRAWS; i++) {
129
+ const sens = new Map();
130
+ const spec = new Map();
131
+ for (const d of detectors) {
132
+ sens.set(d.name, beta(d.counts.tp + 0.5, d.counts.fn + 0.5, rng));
133
+ spec.set(d.name, beta(d.counts.tn + 0.5, d.counts.fp + 0.5, rng));
134
+ }
135
+ draws.push(pBadFrom(detectors, prior, mode, (d) => sens.get(d.name), (d) => spec.get(d.name)).pBad);
136
+ }
137
+ draws.sort((a, b) => a - b);
138
+ const at = (q) => draws[Math.min(draws.length - 1, Math.max(0, Math.ceil(q * draws.length) - 1))];
139
+ // The point uses the observed rates; a rate at exactly 1 (no false positives
140
+ // in the family) puts the point above every posterior draw, so the interval
141
+ // is widened to contain it — an interval that excludes its own point is a
142
+ // presentation error, not a finding.
143
+ return {
144
+ pBad: round4(point.pBad),
145
+ lo: round4(Math.min(at(0.025), point.pBad)),
146
+ hi: round4(Math.max(at(0.975), point.pBad)),
147
+ perClass: Object.fromEntries(Object.entries(point.perClass).map(([k, v]) => [k, v === null ? null : round4(v)])),
148
+ assumptions: [
149
+ 'detectors independent across classes',
150
+ 'published accuracy is in-sample, same-model labelled',
151
+ 'sensitivity and specificity carry a half-count prior, so a family with no observed errors does not read as certain',
152
+ `prior ${prior}, spread ${mode}`,
153
+ ],
154
+ };
155
+ }
156
+ const isEffectivelyCritical = (r) => r.critical === true;
157
+ /**
158
+ * Compose by kind, as arc 3 will: gates (a failing policy that is effectively
159
+ * critical here), then vetoes (a failing effectively-critical detection),
160
+ * then the risk against τ. `unknown` when a critical rule was asked and could
161
+ * not answer (defeated or config_invalid) — the fail-closed seam.
162
+ */
163
+ export function riskVerdict(result, tau = DEFAULT_TAU, prior = DEFAULT_PRIOR, mode = DEFAULT_PRIOR_MODE) {
164
+ const rows = result.rule_results;
165
+ const gates = rows.filter((r) => r.kind === 'policy' && !r.skipped && r.passed === false && isEffectivelyCritical(r));
166
+ if (gates.length > 0)
167
+ return { state: 'fail', basis: 'policy_gate', by: gates.map((r) => r.ruleName), risk: riskEstimate(result, prior, mode), confidence: null };
168
+ const vetoes = rows.filter((r) => (r.kind === 'detection' || r.kind === 'inference') && !r.skipped && r.passed === false && isEffectivelyCritical(r));
169
+ if (vetoes.length > 0)
170
+ return { state: 'fail', basis: 'detector_veto', by: vetoes.map((r) => r.ruleName), risk: riskEstimate(result, prior, mode), confidence: null };
171
+ const unknown = rows.filter((r) => isEffectivelyCritical(r) && r.skipped && r.skipClass && r.skipClass !== 'not_applicable');
172
+ if (unknown.length > 0)
173
+ return { state: 'unknown', basis: 'clean', by: unknown.map((r) => r.ruleName), risk: null, confidence: null };
174
+ const risk = riskEstimate(result, prior, mode);
175
+ if (!risk) {
176
+ const judged = rows.some((r) => !r.skipped);
177
+ return { state: judged ? 'pass' : 'unknown', basis: judged ? 'clean' : 'no_rules', by: [], risk: null, confidence: null };
178
+ }
179
+ const confidence = risk.lo <= tau && tau <= risk.hi ? 'marginal' : 'decisive';
180
+ if (risk.pBad > tau) {
181
+ const by = Object.entries(risk.perClass)
182
+ .filter(([, q]) => q !== null && q > 0.5)
183
+ .map(([cls]) => cls);
184
+ return { state: 'fail', basis: 'risk_over_loss', by, risk, confidence };
185
+ }
186
+ return { state: 'pass', basis: 'clean', by: [], risk, confidence };
187
+ }
@@ -1,3 +1,4 @@
1
+ import { countSentences } from '../text/sentences.js';
1
2
  export const minOutputLength = {
2
3
  name: 'min_output_length',
3
4
  description: 'Output must meet a minimum character length',
@@ -60,7 +61,10 @@ export const sentenceCount = {
60
61
  version: 1,
61
62
  evaluate(context) {
62
63
  const minSentences = context.customConfig?.min_sentences ?? 2;
63
- const sentences = context.output.split(/[.!?]+/).filter((s) => s.trim().length > 0).length;
64
+ // One splitter, shared with topic_consistency (src/eval/text/sentences.ts).
65
+ // Splitting on /[.!?]+/ counted "The latency is 3.5 seconds." as two
66
+ // sentences and "Dr. Chen approved it." as two more.
67
+ const sentences = countSentences(context.output);
64
68
  const passed = sentences >= minSentences;
65
69
  return {
66
70
  ruleName: 'sentence_count',
@@ -1,6 +1,6 @@
1
1
  import { type EvalRule } from '../../types/eval.js';
2
2
  export declare const costUnderThreshold: EvalRule;
3
- export declare const tokenEfficiency: EvalRule;
3
+ export declare const verbosityRatio: EvalRule;
4
4
  /** Default for config key `max_tool_repeats`: how many identical calls are tolerated. */
5
5
  export declare const DEFAULT_MAX_TOOL_REPEATS = 3;
6
6
  /**
@@ -30,9 +30,9 @@ export const costUnderThreshold = {
30
30
  };
31
31
  },
32
32
  };
33
- export const tokenEfficiency = {
34
- name: 'token_efficiency',
35
- description: 'Checks output-to-input token ratio for efficiency',
33
+ export const verbosityRatio = {
34
+ name: 'verbosity_ratio',
35
+ description: 'The completion-to-prompt token ratio against a ceiling: completion_tokens / prompt_tokens must not exceed max_token_ratio (default 5). This measures output VERBOSITY relative to prompt size, not efficiency — a long answer to a long prompt passes and a long answer to a short prompt fails, and neither says whether the tokens were well spent. Skipped when token usage is not supplied. Renamed from token_efficiency in 0.10.0, because the old name named something the rule does not measure',
36
36
  evalType: 'cost',
37
37
  weight: 0.5,
38
38
  kind: 'measurement',
@@ -45,13 +45,13 @@ export const tokenEfficiency = {
45
45
  const prompt = context.tokenUsage?.prompt_tokens;
46
46
  const completion = context.tokenUsage?.completion_tokens;
47
47
  if (prompt === undefined || completion === undefined || prompt === 0) {
48
- return { ruleName: 'token_efficiency', passed: false, score: 0, message: 'Token usage not provided', skipped: true, skipReason: 'context.tokenUsage not provided' };
48
+ return { ruleName: 'verbosity_ratio', passed: false, score: 0, message: 'Token usage not provided', skipped: true, skipReason: 'context.tokenUsage not provided' };
49
49
  }
50
50
  const ratio = completion / prompt;
51
51
  const maxRatio = context.customConfig?.max_token_ratio ?? 5;
52
52
  const passed = ratio <= maxRatio;
53
53
  return {
54
- ruleName: 'token_efficiency',
54
+ ruleName: 'verbosity_ratio',
55
55
  passed,
56
56
  score: passed ? 1 : Math.max(0, 1 - (ratio - maxRatio) / maxRatio),
57
57
  value: { stat: 'completion_to_prompt_ratio', unit: 'ratio', value: ratio },
@@ -184,4 +184,4 @@ export const noToolLoop = {
184
184
  };
185
185
  },
186
186
  };
187
- export const costRules = [costUnderThreshold, tokenEfficiency, noToolLoop];
187
+ export const costRules = [costUnderThreshold, verbosityRatio, noToolLoop];
@@ -244,6 +244,7 @@ export function createCustomRule(definition, severity) {
244
244
  weight: definition.weight ?? 1,
245
245
  critical: severity === 'high' || severity === 'critical',
246
246
  kind: 'policy',
247
+ origin: 'custom',
247
248
  mechanism: meta?.mechanism ?? 'formula',
248
249
  needs: meta?.needs ?? ['output'],
249
250
  classes: [],
@@ -1,3 +1,4 @@
1
+ import { sentencesOf } from '../text/sentences.js';
1
2
  /*
2
3
  * Relevance rules — one tokenizer, two DISTINCT signals.
3
4
  *
@@ -179,7 +180,11 @@ export const keywordOverlap = {
179
180
  */
180
181
  const DEFAULT_TOPIC_THRESHOLD = 1 / 3;
181
182
  const LIST_ITEM = /^\s*(?:[-*+•]|\d{1,3}[.)])\s+/;
182
- const SENTENCE_BREAK = /(?<=[.!?])\s+/;
183
+ /*
184
+ * Replaced by the shared splitter (src/eval/text/sentences.ts). The old
185
+ * pattern broke after any terminator followed by whitespace, so "Dr. Chen"
186
+ * and "3. 5" were two sentences each.
187
+ */
183
188
  export const topicConsistency = {
184
189
  name: 'topic_consistency',
185
190
  description: 'Continuity with the input: the share of the output\'s content-bearing sentences that connect to the input\'s topic — a sentence connects when it shares a content term with the input or with an earlier connected sentence (list items are read under the sentence that introduces them). Passes when at least a third connect (configurable: topic_consistency); a third, not half, so a short honest answer that elaborates in fresh words is not read as drift. Replaces the output-word-ratio measure that failed every grounded technical answer. Skipped when the output is too brief for meaningful comparison',
@@ -227,7 +232,7 @@ export const topicConsistency = {
227
232
  for (const line of context.output.replace(FENCED_CODE, '\n').split('\n')) {
228
233
  const isItem = LIST_ITEM.test(line);
229
234
  let lineConnected = false;
230
- for (const sentence of line.split(SENTENCE_BREAK)) {
235
+ for (const sentence of sentencesOf(line)) {
231
236
  const terms = contentTerms(sentence);
232
237
  if (terms.length === 0)
233
238
  continue;
@@ -1,9 +1,13 @@
1
1
  import type { EvalRule } from '../../types/eval.js';
2
- export declare const PII_PATTERNS: Array<{
2
+ export interface PiiPattern {
3
3
  name: string;
4
4
  pattern: RegExp;
5
+ /** Documentation values this pattern should recognise and ignore. */
5
6
  placeholders?: RegExp[];
6
- }>;
7
+ /** The structural check described above; a match that fails it is not a match. */
8
+ validate?: (match: string) => boolean;
9
+ }
10
+ export declare const PII_PATTERNS: PiiPattern[];
7
11
  /**
8
12
  * The pass message when placeholders were ignored. Says so explicitly,
9
13
  * with the count and the pattern names (#370): a builder smoke-testing with