@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
@@ -0,0 +1,265 @@
1
+ /*
2
+ * One normalisation pass, shared by every rule that matches text.
3
+ *
4
+ * The problem it solves is measured, not hypothetical. `npm run proof`
5
+ * publishes a transforms table: for every positive a critical rule catches,
6
+ * the text inside the evidence span is transformed the way an evader would
7
+ * transform it, and the rule is re-run. Before this module, `no_pii` kept
8
+ * 38% of its catches under a zero-width space, 22% under Cyrillic
9
+ * homoglyphs and **none at all** under full-width digits, and
10
+ * `no_blocklist_words` survived nothing but a change of case.
11
+ *
12
+ * What it does, in order, per grapheme cluster:
13
+ * 1. drops format characters that carry no meaning — zero-width spaces
14
+ * and joiners, the soft hyphen, the byte-order mark;
15
+ * 2. NFKC-folds the cluster, which turns full-width and mathematical
16
+ * alphanumerics into ASCII (4111 → 4111, 𝐩𝐚𝐬𝐬 → pass);
17
+ * 3. maps the confusables NFKC does NOT fold — Cyrillic and Greek letters
18
+ * that are drawn like Latin ones (раssword with a Cyrillic а and р);
19
+ * 4. collapses every run of whitespace to ONE character — a newline when
20
+ * the run contains one, a space otherwise. Line structure is meaning:
21
+ * a forged "System:" line and a fenced block are line-shaped, and
22
+ * flattening newlines to spaces measurably cost the injection rule
23
+ * recall on three transforms. Horizontal runs carry no such meaning.
24
+ *
25
+ * What it deliberately does NOT do is leetspeak (0 → o, 1 → i). That
26
+ * substitution is correct for injection phrasing and catastrophic for
27
+ * everything else: it would turn a credit card number into letters and
28
+ * blind every digit-based detector. The injection rule applies it on top of
29
+ * this pass, to this pass's output, and owns it alone.
30
+ *
31
+ * Every rule that matches on `text` reports evidence through `map`, so a
32
+ * span still indexes the RAW output the caller sent — the arc-1 contract
33
+ * ("spans are offsets into the raw text") is what makes redaction and the
34
+ * transforms measurement correct, and normalising without a map would
35
+ * quietly break it.
36
+ */
37
+ /** Format characters that carry no textual meaning and are pure evasion when they sit inside a token. */
38
+ const DROPPED = new Set([
39
+ '​', // zero-width space
40
+ '‌', // zero-width non-joiner
41
+ '‍', // zero-width joiner
42
+ '‎', // left-to-right mark
43
+ '‏', // right-to-left mark
44
+ '⁠', // word joiner
45
+ '', // byte-order mark / zero-width no-break space
46
+ '­', // soft hyphen
47
+ ]);
48
+ /**
49
+ * Letters that NFKC leaves alone but a reader cannot tell apart from Latin.
50
+ * Cyrillic first, then Greek; lowercase and uppercase where both are
51
+ * confusable. Deliberately conservative: only characters whose common
52
+ * rendering is indistinguishable in the fonts an agent's output is read in.
53
+ */
54
+ const CONFUSABLES = new Map([
55
+ // Cyrillic → Latin
56
+ ['а', 'a'], ['А', 'A'],
57
+ ['е', 'e'], ['Е', 'E'],
58
+ ['о', 'o'], ['О', 'O'],
59
+ ['р', 'p'], ['Р', 'P'],
60
+ ['с', 'c'], ['С', 'C'],
61
+ ['х', 'x'], ['Х', 'X'],
62
+ ['у', 'y'], ['У', 'Y'],
63
+ ['к', 'k'], ['К', 'K'],
64
+ ['м', 'm'], ['М', 'M'],
65
+ ['н', 'h'], ['Н', 'H'],
66
+ ['т', 't'], ['Т', 'T'],
67
+ ['в', 'v'], ['В', 'B'],
68
+ ['і', 'i'], ['І', 'I'],
69
+ ['ј', 'j'], ['Ј', 'J'],
70
+ ['ѕ', 's'], ['Ѕ', 'S'],
71
+ ['б', '6'],
72
+ ['г', 'r'],
73
+ ['з', '3'],
74
+ ['һ', 'h'],
75
+ ['ҙ', 'z'],
76
+ // Greek → Latin
77
+ ['ο', 'o'], ['Ο', 'O'],
78
+ ['α', 'a'], ['Α', 'A'],
79
+ ['ε', 'e'], ['Ε', 'E'],
80
+ ['ρ', 'p'], ['Ρ', 'P'],
81
+ ['τ', 't'], ['Τ', 'T'],
82
+ ['ν', 'v'], ['Ν', 'N'],
83
+ ['υ', 'u'], ['Υ', 'Y'],
84
+ ['ι', 'i'], ['Ι', 'I'],
85
+ ['κ', 'k'], ['Κ', 'K'],
86
+ ['β', 'B'], ['Β', 'B'],
87
+ ['η', 'n'], ['Η', 'H'],
88
+ ['χ', 'x'], ['Χ', 'X'],
89
+ ['μ', 'u'], ['Μ', 'M'],
90
+ ['γ', 'y'], ['Ζ', 'Z'],
91
+ ['Φ', 'O'],
92
+ // Other scripts whose letters are drawn as Latin
93
+ ['ԁ', 'd'],
94
+ ['ԛ', 'q'],
95
+ ['ɡ', 'g'],
96
+ ['ẞ', 'S'],
97
+ ['ո', 'n'],
98
+ ['ս', 'u'],
99
+ ['օ', 'o'],
100
+ ]);
101
+ /**
102
+ * Printable ASCII plus the newline. Nothing in that set folds, so the only
103
+ * thing that could change such a string is a whitespace RUN — which makes
104
+ * two linear scans a complete test for "this text is already normalised".
105
+ *
106
+ * This is the hot path and it is why the pass is affordable. Ordinary agent
107
+ * output is plain text; a one-megabyte payload of it used to cost a grapheme
108
+ * segmentation and a character-by-character rebuild, and the hostile-payload
109
+ * budget in the test battery caught exactly that.
110
+ */
111
+ const PLAIN_TEXT = /^[\x20-\x7E\n]*$/;
112
+ const WHITESPACE_RUN = /\s\s/;
113
+ /** The result for text that is already in normal form: no copy, no map until asked. */
114
+ function identity(raw) {
115
+ let cached;
116
+ return {
117
+ text: raw,
118
+ unchanged: true,
119
+ get map() {
120
+ if (cached === undefined) {
121
+ cached = new Int32Array(raw.length + 1);
122
+ for (let i = 0; i <= raw.length; i++)
123
+ cached[i] = i;
124
+ }
125
+ return cached;
126
+ },
127
+ };
128
+ }
129
+ let segmenter;
130
+ function graphemes(raw) {
131
+ if (typeof Intl?.Segmenter === 'function') {
132
+ segmenter ??= new Intl.Segmenter('en', { granularity: 'grapheme' });
133
+ return segmenter.segment(raw);
134
+ }
135
+ // Environments without Intl.Segmenter fall back to code points, which is
136
+ // correct for everything this pass folds and only differs on combining
137
+ // sequences it would leave alone anyway.
138
+ return [...raw];
139
+ }
140
+ const WHITESPACE = /\s/u;
141
+ const LINE_BREAK = /[\n\r\u2028\u2029]/u;
142
+ /** Folds `raw` for matching and returns the offset map that puts evidence back on the raw text. */
143
+ export function normalise(raw) {
144
+ // Already in normal form: two linear scans and no allocation at all.
145
+ if (PLAIN_TEXT.test(raw) && !WHITESPACE_RUN.test(raw))
146
+ return identity(raw);
147
+ const out = [];
148
+ const offsets = [];
149
+ /** The whitespace run being accumulated: where it started, and whether it broke a line. */
150
+ let run = null;
151
+ let changed = false;
152
+ /** False as soon as one output character does not sit at its own raw offset. */
153
+ let identityMap = true;
154
+ const push = (chars, at) => {
155
+ for (const ch of chars) {
156
+ if (at !== out.length)
157
+ identityMap = false;
158
+ out.push(ch);
159
+ offsets.push(at);
160
+ }
161
+ };
162
+ /** Emits the pending whitespace run as one character: a newline if it broke a line, else a space. */
163
+ const flushRun = () => {
164
+ if (run === null)
165
+ return;
166
+ const ch = run.hadBreak ? '\n' : ' ';
167
+ if (raw.slice(run.at, run.at + 1) !== ch)
168
+ changed = true;
169
+ push(ch, run.at);
170
+ run = null;
171
+ };
172
+ const segments = graphemes(raw);
173
+ const iterate = (rawCluster, index) => {
174
+ /*
175
+ * Strip the format characters from INSIDE the cluster, not just from
176
+ * clusters that are one. A zero-width non-joiner between two digits
177
+ * binds into the neighbouring grapheme, so a whole-cluster test misses
178
+ * exactly the evasion this exists to fold.
179
+ */
180
+ let cluster = rawCluster;
181
+ if (cluster.length > 1 || DROPPED.has(cluster)) {
182
+ let stripped = '';
183
+ for (const ch of cluster)
184
+ if (!DROPPED.has(ch))
185
+ stripped += ch;
186
+ if (stripped !== cluster) {
187
+ changed = true;
188
+ cluster = stripped;
189
+ }
190
+ }
191
+ if (cluster === '')
192
+ return;
193
+ if (WHITESPACE.test(cluster)) {
194
+ const hadBreak = LINE_BREAK.test(cluster);
195
+ if (run === null)
196
+ run = { at: index, hadBreak };
197
+ else {
198
+ run.hadBreak ||= hadBreak;
199
+ changed = true;
200
+ }
201
+ return;
202
+ }
203
+ flushRun();
204
+ let folded = cluster.normalize('NFKC');
205
+ if (folded !== cluster)
206
+ changed = true;
207
+ if (CONFUSABLES.size > 0) {
208
+ let mapped = '';
209
+ for (const ch of folded) {
210
+ const sub = CONFUSABLES.get(ch);
211
+ if (sub === undefined)
212
+ mapped += ch;
213
+ else {
214
+ mapped += sub;
215
+ changed = true;
216
+ }
217
+ }
218
+ folded = mapped;
219
+ }
220
+ // A cluster that folds away entirely (a lone combining mark NFKC drops)
221
+ // contributes nothing; its offset is covered by the next kept character.
222
+ push(folded, index);
223
+ };
224
+ if (Array.isArray(segments)) {
225
+ let at = 0;
226
+ for (const cluster of segments) {
227
+ iterate(cluster, at);
228
+ at += cluster.length;
229
+ }
230
+ }
231
+ else {
232
+ for (const { segment, index } of segments)
233
+ iterate(segment, index);
234
+ }
235
+ flushRun();
236
+ const text = out.join('');
237
+ let cached;
238
+ return {
239
+ text,
240
+ unchanged: !changed && identityMap && text.length === raw.length,
241
+ get map() {
242
+ if (cached === undefined) {
243
+ cached = new Int32Array(offsets.length + 1);
244
+ cached.set(offsets);
245
+ cached[offsets.length] = raw.length;
246
+ }
247
+ return cached;
248
+ },
249
+ };
250
+ }
251
+ /**
252
+ * A span in normalised coordinates as a span in raw coordinates. Always
253
+ * widens rather than narrows: when characters were dropped between the last
254
+ * matched character and the next kept one, the raw span covers them, which
255
+ * is what a reader wants — the evasion is part of the evidence.
256
+ */
257
+ export function toRawSpan(n, start, end) {
258
+ // The identity case never touches the map, so no array is built for the
259
+ // ordinary text that makes up almost every evaluation.
260
+ if (n.unchanged)
261
+ return [Math.max(0, start), Math.max(start, end)];
262
+ const s = Math.max(0, Math.min(start, n.map.length - 1));
263
+ const e = Math.max(s, Math.min(end, n.map.length - 1));
264
+ return [n.map[s], n.map[e]];
265
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Named sentencesOf, not sentencesOf: the hallucination rule in
3
+ * src/eval/rules/safety.ts has its own sentencesOf that also breaks on
4
+ * every newline, because it wants per-line units for grounding checks. The
5
+ * two are not the same job and unifying them would move that rule's
6
+ * numbers, so it is a separate measured change, not a rename.
7
+ *
8
+ * Splits `text` into sentences. Never returns an empty sentence; a text with
9
+ * no terminator is one sentence. Line breaks do not split on their own — a
10
+ * wrapped paragraph is one sentence — but a blank line does, because a new
11
+ * block is a new thought and a bullet list is not one long sentence.
12
+ */
13
+ export declare function sentencesOf(text: string): string[];
14
+ /** How many sentences the text contains. The one number `sentence_count` reports. */
15
+ export declare function countSentences(text: string): number;
@@ -0,0 +1,149 @@
1
+ /*
2
+ * One sentence splitter, for the two rules that count or walk sentences.
3
+ *
4
+ * Both had their own, and both were wrong in the same way. `sentence_count`
5
+ * split on `/[.!?]+/`, so "The latency is 3.5 seconds." counted as two
6
+ * sentences and "Dr. Chen approved it." as two more; the arc-zero review
7
+ * measured the damage at 43% of that rule's family. `topic_consistency`
8
+ * split on a full stop followed by whitespace, which fixes the decimal only
9
+ * when the decimal has no space after it and never fixes the abbreviation.
10
+ *
11
+ * A sentence ends at `.`, `!` or `?` when what follows looks like the start
12
+ * of a new sentence and what precedes is not one of the things that ends in
13
+ * a full stop without ending a sentence:
14
+ *
15
+ * - never between digits, so 3.5 and 1.2.3 stay whole;
16
+ * - never after a closed list of abbreviations (Dr, Mr, Mrs, Ms, e.g,
17
+ * i.e, vs, etc, No, Fig, St, Inc, Ltd, Jr, Sr, approx, cf, al);
18
+ * - never after a single capital letter, which is an initial (J. Smith);
19
+ * - only when the next non-space character starts a sentence: an
20
+ * uppercase letter, an opening quote or bracket, or a digit.
21
+ *
22
+ * The list is closed on purpose. An open-ended abbreviation heuristic
23
+ * (a short token ending in a full stop) swallows real sentence ends —
24
+ * "It was fun. Then we left." — and this splitter is used to COUNT, where
25
+ * missing a break is as wrong as inventing one.
26
+ */
27
+ /**
28
+ * Abbreviations that are essentially never the last word of a sentence, so
29
+ * the full stop after them is punctuation and not an end. Lowercase, no
30
+ * trailing stop.
31
+ */
32
+ const ALWAYS_ABBREVIATION = new Set([
33
+ 'dr', 'mr', 'mrs', 'ms', 'prof', 'sr', 'jr', 'st', 'mt',
34
+ 'e.g', 'i.e', 'vs', 'al', 'cf', 'approx', 'est',
35
+ 'dept', 'univ', 'a.m', 'p.m', 'u.s', 'u.k',
36
+ ]);
37
+ /**
38
+ * Abbreviations that are ALSO ordinary sentence endings — "shipped in Oct.
39
+ * The rollout held" ends a sentence; "shipped on Oct. 5" does not. What
40
+ * separates them is what follows: a number means the abbreviation is being
41
+ * used as a label, anything else means the sentence ended. Guessing either
42
+ * way unconditionally is wrong about half the time, and this splitter is
43
+ * used to COUNT, where a missed break costs exactly as much as an invented
44
+ * one.
45
+ */
46
+ const ABBREVIATION_BEFORE_NUMBER = new Set([
47
+ 'no', 'fig', 'eq', 'ch', 'vol', 'pp', 'etc',
48
+ 'inc', 'ltd', 'co', 'corp',
49
+ 'jan', 'feb', 'mar', 'apr', 'jun', 'jul', 'aug', 'sep', 'sept', 'oct', 'nov', 'dec',
50
+ ]);
51
+ const TERMINATORS = new Set(['.', '!', '?']);
52
+ /** True when the character can open a new sentence. */
53
+ function opensSentence(ch) {
54
+ if (ch === undefined)
55
+ return false;
56
+ if (ch >= 'A' && ch <= 'Z')
57
+ return true;
58
+ if (ch >= '0' && ch <= '9')
59
+ return true;
60
+ return '"‘’“”\'([{*_#-—'.includes(ch);
61
+ }
62
+ const isDigit = (ch) => ch !== undefined && ch >= '0' && ch <= '9';
63
+ /** The token immediately before `at`, lowercased, without its trailing stop. */
64
+ function precedingToken(text, at) {
65
+ let i = at - 1;
66
+ while (i >= 0 && !/[\s(["']/.test(text[i]))
67
+ i--;
68
+ return text.slice(i + 1, at).toLowerCase();
69
+ }
70
+ /**
71
+ * Named sentencesOf, not sentencesOf: the hallucination rule in
72
+ * src/eval/rules/safety.ts has its own sentencesOf that also breaks on
73
+ * every newline, because it wants per-line units for grounding checks. The
74
+ * two are not the same job and unifying them would move that rule's
75
+ * numbers, so it is a separate measured change, not a rename.
76
+ *
77
+ * Splits `text` into sentences. Never returns an empty sentence; a text with
78
+ * no terminator is one sentence. Line breaks do not split on their own — a
79
+ * wrapped paragraph is one sentence — but a blank line does, because a new
80
+ * block is a new thought and a bullet list is not one long sentence.
81
+ */
82
+ export function sentencesOf(text) {
83
+ const out = [];
84
+ let start = 0;
85
+ for (let i = 0; i < text.length; i++) {
86
+ const ch = text[i];
87
+ // A blank line ends a sentence whatever came before it.
88
+ if (ch === '\n' && /^\s*\n/.test(text.slice(i + 1))) {
89
+ const piece = text.slice(start, i).trim();
90
+ if (piece.length > 0)
91
+ out.push(piece);
92
+ start = i + 1;
93
+ continue;
94
+ }
95
+ if (!TERMINATORS.has(ch))
96
+ continue;
97
+ // 3.5 — a full stop between digits is a decimal point.
98
+ if (ch === '.' && isDigit(text[i - 1]) && isDigit(text[i + 1]))
99
+ continue;
100
+ // Run past a cluster of terminators ("What?!").
101
+ let end = i;
102
+ while (end + 1 < text.length && TERMINATORS.has(text[end + 1]))
103
+ end++;
104
+ // Closing quotes and brackets belong to the sentence that ends here.
105
+ let after = end + 1;
106
+ while (after < text.length && '"’”\')]}'.includes(text[after]))
107
+ after++;
108
+ // What comes next has to look like a new sentence.
109
+ let next = after;
110
+ while (next < text.length && /[ \t\r\n]/.test(text[next]))
111
+ next++;
112
+ /*
113
+ * No whitespace after the stop is usually a mid-token full stop — a
114
+ * version (v0.10.0), a filename (package.json), a hostname
115
+ * (iris-eval.com/proof) — and must not break. The exception is a
116
+ * following CAPITAL, which is a missing space between two sentences
117
+ * ("...ready.Ship it") and not a token: no filename or version has one.
118
+ */
119
+ if (next === after && next < text.length && !(text[next] >= 'A' && text[next] <= 'Z'))
120
+ continue;
121
+ if (next < text.length && !opensSentence(text[next]))
122
+ continue;
123
+ // Dr. Chen — an abbreviation, not an end.
124
+ if (ch === '.') {
125
+ const token = precedingToken(text, i);
126
+ if (ALWAYS_ABBREVIATION.has(token))
127
+ continue;
128
+ // Oct. 5 is a date; "in Oct. The rollout held" is two sentences.
129
+ if (ABBREVIATION_BEFORE_NUMBER.has(token) && isDigit(text[next]))
130
+ continue;
131
+ // A single capital letter is an initial: J. Smith.
132
+ if (token.length === 1 && /[a-z]/i.test(token))
133
+ continue;
134
+ }
135
+ const piece = text.slice(start, after).trim();
136
+ if (piece.length > 0)
137
+ out.push(piece);
138
+ start = after;
139
+ i = after - 1;
140
+ }
141
+ const tail = text.slice(start).trim();
142
+ if (tail.length > 0)
143
+ out.push(tail);
144
+ return out;
145
+ }
146
+ /** How many sentences the text contains. The one number `sentence_count` reports. */
147
+ export function countSentences(text) {
148
+ return sentencesOf(text).length;
149
+ }
@@ -0,0 +1,34 @@
1
+ import type { Coverage, EvalResult, EvalRule, EvalRuleResult, Need, Provenance, Verdict } from '../types/eval.js';
2
+ import type { EffectiveCriticality } from './criticality.js';
3
+ /**
4
+ * The basis of the verdict under the shipped composer (a weighted mean plus
5
+ * the critical veto). `passed` is `state === 'pass'` and equals the engine's
6
+ * own `passed` for every result; the basis says which layer decided.
7
+ */
8
+ export declare function deriveVerdict(result: Pick<EvalResult, 'passed' | 'score' | 'rule_results' | 'insufficient_data' | 'critical_failures' | 'rules_evaluated'>, threshold: number): Verdict;
9
+ /**
10
+ * Which evaluation questions were judged, which were not and why. At write
11
+ * time the engine passes the inputs the call carried; at read time they are
12
+ * reconstructed as the union of what the rules saw (a rule that saw an input
13
+ * proves the call carried it; one that did not cannot prove the reverse).
14
+ */
15
+ export declare function deriveCoverage(ruleResults: readonly EvalRuleResult[], present?: ReadonlySet<Need>): Coverage;
16
+ /** The critical rules that skipped — derived from the stamped flags on every read, never a column. */
17
+ export declare function deriveCriticalSkipped(ruleResults: readonly EvalRuleResult[]): string[] | undefined;
18
+ /** sha256 over the rules that ran — name, definition version, kind, effective criticality, weight — so two evaluations under the same ruleset hash the same. */
19
+ export declare function rulesetHash(rules: readonly EvalRule[], resolve: (rule: EvalRule) => EffectiveCriticality): string;
20
+ /** sha256 over the evaluation configuration that shapes a verdict. */
21
+ export declare function configHash(config: {
22
+ threshold: number;
23
+ ruleThresholds?: Record<string, unknown>;
24
+ criticalRules?: readonly string[];
25
+ nonCriticalRules?: readonly string[];
26
+ }): string;
27
+ export declare function buildProvenance(input: {
28
+ irisVersion: string;
29
+ rulesetHash: string;
30
+ configHash: string;
31
+ threshold: number;
32
+ ruleThresholds?: Record<string, unknown>;
33
+ judgedAt: string;
34
+ }): Provenance;
@@ -0,0 +1,131 @@
1
+ /*
2
+ * The verdict, the coverage and the provenance — computed once, derived on
3
+ * read, never fabricated.
4
+ *
5
+ * Arc zero (2026-09-05) found the same fact encoded three ways
6
+ * (`insufficient_data`, `critical_skipped`, `rule_results[].budgetExceeded`),
7
+ * the verdict's basis nowhere (a reader could not tell a veto from a low
8
+ * score without knowing the rule library), coverage counted in rules rather
9
+ * than evaluation questions, and no stored evaluation carrying the version,
10
+ * ruleset or thresholds that produced it — "why did this pass on that day"
11
+ * was unanswerable from Iris alone.
12
+ *
13
+ * This module answers those from what the engine already knows. It changes
14
+ * no verdict: `deriveVerdict` names the basis of today's arithmetic
15
+ * (`passed` here equals the engine's `passed`, and a test proves it); the
16
+ * compose-by-kind release replaces the arithmetic and keeps the shape.
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ import { RULE_QUESTION_IDS } from './questions.js';
20
+ import { NEEDS } from './failure-classes.js';
21
+ import { publishedProvenance } from './accuracy.js';
22
+ /**
23
+ * The basis of the verdict under the shipped composer (a weighted mean plus
24
+ * the critical veto). `passed` is `state === 'pass'` and equals the engine's
25
+ * own `passed` for every result; the basis says which layer decided.
26
+ */
27
+ export function deriveVerdict(result, threshold) {
28
+ const evaluated = result.rules_evaluated ?? result.rule_results.filter((r) => !r.skipped).length;
29
+ if (result.insufficient_data || evaluated === 0) {
30
+ return { state: 'unknown', passed: false, basis: 'no_rules', by: [], risk: null };
31
+ }
32
+ const vetoes = result.critical_failures ?? [];
33
+ if (vetoes.length > 0) {
34
+ const kinds = new Map(result.rule_results.map((r) => [r.ruleName, r.kind]));
35
+ const allPolicies = vetoes.every((name) => kinds.get(name) === 'policy');
36
+ return { state: 'fail', passed: false, basis: allPolicies ? 'policy_gate' : 'detector_veto', by: [...vetoes], risk: null };
37
+ }
38
+ if (result.score < threshold) {
39
+ const by = result.rule_results.filter((r) => !r.skipped && !r.passed).map((r) => r.ruleName);
40
+ return { state: 'fail', passed: false, basis: 'score_below_threshold', by, risk: null };
41
+ }
42
+ return { state: 'pass', passed: true, basis: 'clean', by: [], risk: null };
43
+ }
44
+ /**
45
+ * Which evaluation questions were judged, which were not and why. At write
46
+ * time the engine passes the inputs the call carried; at read time they are
47
+ * reconstructed as the union of what the rules saw (a rule that saw an input
48
+ * proves the call carried it; one that did not cannot prove the reverse).
49
+ */
50
+ export function deriveCoverage(ruleResults, present) {
51
+ const inputs = {};
52
+ const seen = new Set(present ?? []);
53
+ if (!present)
54
+ for (const r of ruleResults)
55
+ for (const n of r.saw ?? [])
56
+ seen.add(n);
57
+ for (const n of NEEDS)
58
+ inputs[n] = seen.has(n);
59
+ const questions = [];
60
+ for (const id of RULE_QUESTION_IDS) {
61
+ const rows = ruleResults.filter((r) => r.question === id);
62
+ if (rows.length === 0) {
63
+ questions.push({ id, status: 'not_applicable', why: 'no rule that answers this question ran in the selected bundles' });
64
+ continue;
65
+ }
66
+ if (rows.some((r) => !r.skipped)) {
67
+ questions.push({ id, status: 'judged' });
68
+ continue;
69
+ }
70
+ const defeated = rows.filter((r) => r.skipClass === 'defeated').map((r) => r.ruleName);
71
+ const broken = rows.filter((r) => r.skipClass === 'config_invalid').map((r) => r.ruleName);
72
+ if (defeated.length > 0) {
73
+ questions.push({ id, status: 'unjudged', why: `defeated: ${defeated.join(', ')} could not judge this output (sandbox budget)` });
74
+ continue;
75
+ }
76
+ if (broken.length > 0) {
77
+ questions.push({ id, status: 'unjudged', why: `config_invalid: ${broken.join(', ')} has a broken definition` });
78
+ continue;
79
+ }
80
+ const missing = new Set();
81
+ for (const r of rows) {
82
+ const saw = new Set(r.saw ?? []);
83
+ // The rule's needs are not on the result; what it lacked is what its
84
+ // skip reason names. Prefer the structured form when the stamp gave
85
+ // us `saw`: anything in NEEDS the call did not carry and this rule's
86
+ // family is known to read. Fall back to the skip reason text.
87
+ if (r.skipReason)
88
+ missing.add(r.skipReason);
89
+ else
90
+ for (const n of NEEDS)
91
+ if (!saw.has(n) && !seen.has(n))
92
+ missing.add(n);
93
+ }
94
+ questions.push({ id, status: 'unjudged', why: `not supplied: ${[...missing].join('; ')}` });
95
+ }
96
+ return { inputs, questions };
97
+ }
98
+ /** The critical rules that skipped — derived from the stamped flags on every read, never a column. */
99
+ export function deriveCriticalSkipped(ruleResults) {
100
+ if (!ruleResults.some((r) => r.critical !== undefined))
101
+ return undefined;
102
+ const names = ruleResults.filter((r) => r.skipped && r.critical).map((r) => r.ruleName);
103
+ return names.length > 0 ? names : undefined;
104
+ }
105
+ /** sha256 over the rules that ran — name, definition version, kind, effective criticality, weight — so two evaluations under the same ruleset hash the same. */
106
+ export function rulesetHash(rules, resolve) {
107
+ const rows = rules
108
+ .map((r) => [r.name, r.version ?? 0, r.kind ?? '', resolve(r).critical ? 1 : 0, r.weight])
109
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
110
+ return createHash('sha256').update(JSON.stringify(rows)).digest('hex').slice(0, 16);
111
+ }
112
+ /** sha256 over the evaluation configuration that shapes a verdict. */
113
+ export function configHash(config) {
114
+ const stable = JSON.stringify({
115
+ threshold: config.threshold,
116
+ ruleThresholds: Object.fromEntries(Object.entries(config.ruleThresholds ?? {}).sort(([a], [b]) => (a < b ? -1 : 1))),
117
+ criticalRules: [...(config.criticalRules ?? [])].sort(),
118
+ nonCriticalRules: [...(config.nonCriticalRules ?? [])].sort(),
119
+ });
120
+ return createHash('sha256').update(stable).digest('hex').slice(0, 16);
121
+ }
122
+ export function buildProvenance(input) {
123
+ return {
124
+ irisVersion: input.irisVersion,
125
+ rulesetHash: input.rulesetHash,
126
+ configHash: input.configHash,
127
+ thresholds: { default: input.threshold, ...(input.ruleThresholds ? { perRule: input.ruleThresholds } : {}) },
128
+ corpusVersion: publishedProvenance().corpusVersion,
129
+ judgedAt: input.judgedAt,
130
+ };
131
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { PKG_VERSION } from './config/defaults.js';
6
6
  import { createStorage } from './storage/index.js';
7
7
  import { withDemoIngestGuard } from './storage/demo-guard.js';
8
8
  import { createIrisServer } from './server.js';
9
+ import { runRetentionSweep, scheduleRetentionSweep } from './retention.js';
9
10
  import { createStdioTransport } from './transport/stdio.js';
10
11
  import { createHttpTransport } from './transport/http.js';
11
12
  import { createDashboardServer } from './dashboard/server.js';
@@ -290,34 +291,10 @@ async function main() {
290
291
  logger.info(`Loaded ${enabled.length} deployed custom rule(s) from ${customRuleStore.pathFor(LOCAL_TENANT)}`);
291
292
  }
292
293
  const httpServers = [];
293
- // Run data retention cleanup on startup.
294
- //
295
- // For OSS single-tenant installs we explicitly scope cleanup to
296
- // LOCAL_TENANT. Cloud will enumerate all tenants (TenantRegistry) and
297
- // call this per-tenant so retention applies uniformly; the adapter
298
- // method already scopes DELETEs by tenant, so the behavior scales
299
- // cleanly.
300
- if (config.retention.days > 0) {
301
- try {
302
- const deletedTraces = await storage.deleteTracesOlderThan(LOCAL_TENANT, config.retention.days);
303
- /*
304
- * Evaluations too (#372). Deleting a trace only NULLs trace_id on
305
- * its evaluations, so every eval row — output_text verbatim,
306
- * including whatever no_pii flagged — used to outlive the retention
307
- * window indefinitely while the traces around it were swept.
308
- */
309
- const deletedEvals = await storage.deleteEvalResultsOlderThan(LOCAL_TENANT, config.retention.days);
310
- if (deletedTraces + deletedEvals > 0) {
311
- // Fold the WAL into the main file and truncate it, so the swept
312
- // rows do not survive as readable text in iris.db-wal.
313
- await storage.checkpoint();
314
- logger.info(`Retention cleanup: deleted ${deletedTraces} trace(s) and ${deletedEvals} evaluation(s) older than ${config.retention.days} days`);
315
- }
316
- }
317
- catch (err) {
318
- logger.warn(`Retention cleanup skipped: ${err instanceof Error ? err.message : String(err)}`);
319
- }
320
- }
294
+ // Retention: one sweep at boot and the same sweep on a timer that never
295
+ // holds the process open (src/retention.ts).
296
+ await runRetentionSweep(storage, config, logger);
297
+ scheduleRetentionSweep(storage, config, logger);
321
298
  if (config.transport.type === 'http') {
322
299
  const { transport, httpServer } = await createHttpTransport(mcpServer, config, logger);
323
300
  httpServers.push(httpServer);
@@ -0,0 +1,17 @@
1
+ import type { JudgeState } from './judge-enablement.js';
2
+ export declare const INSTRUCTIONS_MAX_CHARS = 2600;
3
+ export interface InstructionsInput {
4
+ ruleCount: number;
5
+ categories: readonly string[];
6
+ threshold: number;
7
+ critical: readonly string[];
8
+ judge: JudgeState;
9
+ }
10
+ export declare function buildInstructions(i: InstructionsInput): string;
11
+ export declare const EVALUATE_MY_AGENT_PROMPT = "evaluate-my-agent";
12
+ /**
13
+ * The prompt a client shows as a slash command: a walk of log → evaluate →
14
+ * read → explain, in plain words, carrying the version so a cached copy
15
+ * cannot outlive a bump. Rendered from the same facts as the instructions.
16
+ */
17
+ export declare function evaluateMyAgentPrompt(what: 'output' | 'trace-file', version: string): string;