@codebam/dsh-thinking-auditor 0.1.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.
package/src/claims.mjs ADDED
@@ -0,0 +1,520 @@
1
+ /**
2
+ * Deterministic claim extraction and claim-vs-evidence verification.
3
+ *
4
+ * This module is the high-precision half of the auditor. It never asks a model
5
+ * to judge what a ledger join can settle:
6
+ *
7
+ * - a claim about having run a command with no matching tool call in the turn
8
+ * is an unsupported action claim;
9
+ * - a claim of success contradicted by the paired result's failure markers is
10
+ * a contradiction;
11
+ * - a quoted file/output excerpt absent from every evidence source is an
12
+ * unsupported quotation.
13
+ *
14
+ * It is deliberately conservative. Hedged language, plans, hypotheses, and
15
+ * values that may legitimately come from the model's own knowledge are marked
16
+ * speculative or low severity, never "lying". Status is a claim-evidence
17
+ * relation, not intent.
18
+ *
19
+ * Pure module: no dsh imports.
20
+ *
21
+ * @module @codebam/dsh-thinking-auditor/claims
22
+ */
23
+
24
+ import { severityAtLeast } from './config.mjs'
25
+
26
+ const TOOL_VERBS = Object.freeze([
27
+ 'ran', 'run', 'executed', 'launched', 'checked', 'examined', 'inspected',
28
+ 'verified', 'confirmed', 'tested', 'read', 'opened', 'searched', 'grepped',
29
+ 'listed', 'reviewed', 'wrote', 'created', 'edited', 'modified', 'built',
30
+ 'installed', 'removed', 'deleted', 'fetched', 'downloaded', 'queried',
31
+ 'walked', 'scanned', 'parsed', 'measured', 'benchmarked', 'deployed',
32
+ ])
33
+
34
+ const ACTION_RE = new RegExp(
35
+ String.raw`\b(?:i|we)(?:'ve| have| had)?\s+(?:already\s+|just\s+|then\s+|also\s+)*(${TOOL_VERBS.join('|')})\b([^]{0,180})`,
36
+ 'gi',
37
+ )
38
+
39
+ const STATUS_RE = /\b(?:all\s+)?(?:the\s+)?(?:\d+\s+)?(tests?|test suite|checks?|build|compile|lint|typecheck|suite)\s+(?:all\s+)?(?:pass(?:ed|es|ing)?|succeed(?:ed|s)?|fail(?:ed|s)?|green|clean|works?|worked|broke(?:n)?)/gi
40
+
41
+ const ASSERTION_RE = /\b(the\s+)?(file|output|result|log|error|command|test|script|code|directory|docs?|config|source)\b([^]{0,100}?)\b(shows?|says?|reports?|returns?|contains?|reads?|equals?|is|has)\b/gi
42
+
43
+ const HEDGE_RE = /(?:\bi\s*(?:'ll| will|'m going to)\b|\b(?:would|should|could|may|might|probably|likely|perhaps|maybe|possibly|presumably|appears?|seems?|i think|i believe|expect|expected|hope|assume|assuming|if|unless|suppose|hypothetically|going to|gonna|plan to|intend to|need to|let me)\b)/i
44
+ const UNIVERSAL_RE = /\b(?:all|every|none|never|always|guaranteed|definitely|certainly|proven|100%)\b/i
45
+ const SUCCESS_RE = /\b(?:pass(?:ed|es|ing)?|succeed(?:ed|s)?|success(?:ful(?:ly)?)?|green|clean|works?|worked|verified|confirmed|ok|correct|complete(?:d)?)\b/i
46
+ const FAILURE_WORD_RE = /\b(?:fail(?:ed|s|ing)?|failure|error(?:s|ed)?|broken|broke(?:n)?|crash(?:ed)?|missing|not\s+work(?:ing)?|does\s+not\s+work)\b/i
47
+
48
+ const TEST_COMMAND_RE = /\b(?:pytest|py\.test|jest|vitest|mocha|ava|npm\s+(?:run\s+)?test|pnpm\s+(?:run\s+)?test|yarn\s+test|bun\s+test|go\s+test|cargo\s+test|ctest|make\s+test|dotnet\s+test|mvn\s+test|gradle\s+test|rspec|phpunit|busted)\b/i
49
+ const BUILD_COMMAND_RE = /\b(?:npm\s+run\s+build|pnpm\s+(?:run\s+)?build|yarn\s+build|bun\s+run\s+build|make\b|cargo\s+build|go\s+build|nix\s+build|tsc\b|vite\s+build|webpack|rollup|esbuild|dotnet\s+build|mvn\s+(?:package|install)|gradle\s+build)\b/i
50
+ const LINT_COMMAND_RE = /\b(?:eslint|biome|prettier|ruff|flake8|pylint|mypy|pyright|tsc\s+--noEmit|golangci-lint|clippy|cargo\s+clippy|shellcheck|hadolint)\b/i
51
+ const READ_TOOL_RE = /\b(?:read|read_file|fs_read|cat|view|open|show|head|tail|bat)\b/i
52
+ const WRITE_TOOL_RE = /\b(?:write|write_file|edit|edit_file|str_replace|replace|apply_patch|create|mkdir|touch|save)\b/i
53
+ const EXEC_TOOL_RE = /\b(?:bash|shell|exec|run|nu|pwsh|powershell|terminal|command|process|subprocess)\b/i
54
+
55
+ const FILE_EXTENSIONS = 'mjs|js|cjs|mts|cts|ts|tsx|jsx|json|ya?ml|toml|md|nix|py|go|rs|rb|java|c|h|cpp|hpp|cs|php|swift|kt|css|scss|html|sql|sh|bash|zsh|fish|lock|env|ini|cfg|conf'
56
+ const PATH_RE = new RegExp(
57
+ String.raw`(?:^|[\s` + '`' + String.raw`"'(])((?:[A-Za-z]:)?(?:\.\.?\/)?(?:[\w.@+-]+\/)+[\w.@+-]+\.(?:${FILE_EXTENSIONS}))(?=$|[\s` + '`' + String.raw`"'),:.])`,
58
+ 'gi',
59
+ )
60
+ const BARE_FILE_RE = new RegExp(String.raw`\b[\w@+-]+\.(?:${FILE_EXTENSIONS})\b`, 'gi')
61
+ const URL_RE = /\bhttps?:\/\/[^\s"'`<>)]+/gi
62
+ const VERSION_RE = /\bv?\d+\.\d+(?:\.\d+)?(?:-[A-Za-z0-9.]+)?\b/g
63
+ const QUANTITY_RE = /\b(\d+(?:\.\d+)?)\s*(tests?|files?|lines?|bytes?|tokens?|ms|milliseconds?|seconds?|minutes?|rows?|items?|errors?|warnings?|chars?|characters?|accounts?|results?|records?|files?|cases?)\b/gi
64
+ const QUOTE_RE = /`([^`\n]{3,240})`|"([^"\n]{3,240})"/g
65
+ const STOP_WORDS = new Set([
66
+ 'about', 'after', 'again', 'against', 'already', 'also', 'because', 'been', 'before',
67
+ 'being', 'between', 'both', 'cannot', 'could', 'does', 'doing', 'done', 'each', 'from',
68
+ 'further', 'have', 'having', 'here', 'into', 'itself', 'just', 'more', 'most', 'only',
69
+ 'other', 'over', 'same', 'should', 'some', 'such', 'than', 'that', 'their', 'them',
70
+ 'then', 'there', 'these', 'they', 'this', 'those', 'through', 'under', 'until', 'very',
71
+ 'were', 'what', 'when', 'where', 'which', 'while', 'with', 'would', 'your', 'yours',
72
+ 'will', 'probably', 'likely', 'maybe', 'perhaps', 'possibly', 'definitely', 'certainly',
73
+ 'always', 'never', 'every', 'guaranteed', 'proven',
74
+ ])
75
+
76
+ /** True when a quoted token is a file path rather than a content excerpt. */
77
+ export function looksLikePath(value) {
78
+ const text = String(value ?? '').trim()
79
+ if (text.length === 0) return false
80
+ const pathRe = new RegExp(String.raw`^(?:[A-Za-z]:)?(?:\.\.?\/)?(?:[\w.@+-]+\/)+[\w.@+-]+\.(?:${FILE_EXTENSIONS})$`, 'i')
81
+ const bareRe = new RegExp(String.raw`^[\w@+-]+\.(?:${FILE_EXTENSIONS})$`, 'i')
82
+ return pathRe.test(text) || bareRe.test(text)
83
+ }
84
+
85
+ /** Split a reasoning window into sentence-ish units without a lookbehind regex. */
86
+ export function splitSentences(text) {
87
+ const value = String(text ?? '')
88
+ const parts = []
89
+ let start = 0
90
+ for (let index = 0; index < value.length; index += 1) {
91
+ const char = value[index]
92
+ const next = value[index + 1]
93
+ if (char === '\n' || (['.', '!', '?'].includes(char) && (next === undefined || /\s/.test(next)))) {
94
+ const part = value.slice(start, index + 1).trim()
95
+ if (part.length > 0) parts.push(part)
96
+ start = index + 1
97
+ }
98
+ }
99
+ const tail = value.slice(start).trim()
100
+ if (tail.length > 0) parts.push(tail)
101
+ return parts
102
+ }
103
+
104
+ /** Cap one string deterministically. */
105
+ function truncate(value, max) {
106
+ const text = String(value ?? '')
107
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`
108
+ }
109
+
110
+ /** Deduplicate anchors by kind and normalized value. */
111
+ function dedupeAnchors(anchors) {
112
+ const seen = new Set()
113
+ const out = []
114
+ for (const anchor of anchors) {
115
+ const key = `${anchor.kind}:${anchor.normalized}`
116
+ if (seen.has(key)) continue
117
+ seen.add(key)
118
+ out.push(anchor)
119
+ }
120
+ return out
121
+ }
122
+
123
+ /** Extract searchable anchors from one sentence. */
124
+ export function extractAnchors(sentence) {
125
+ const anchors = []
126
+ const push = (kind, value) => {
127
+ const text = String(value ?? '').trim()
128
+ if (text.length < 2) return
129
+ anchors.push({ kind, value: text, normalized: text.toLowerCase().replace(/\s+/g, ' ') })
130
+ }
131
+
132
+ for (const match of sentence.matchAll(QUOTE_RE)) {
133
+ const quoted = String(match[1] ?? match[2] ?? '').trim()
134
+ // A quoted path/URL is a reference to a source, not a quoted claim.
135
+ if (looksLikePath(quoted)) push('path', quoted)
136
+ else if (/^https?:\/\//i.test(quoted)) push('url', quoted)
137
+ else push('quote', quoted)
138
+ }
139
+ for (const match of sentence.matchAll(PATH_RE)) push('path', match[1])
140
+ for (const match of sentence.matchAll(BARE_FILE_RE)) push('path', match[0])
141
+ if (/\burl|link|endpoint|api\b/i.test(sentence)) {
142
+ for (const match of sentence.matchAll(URL_RE)) push('url', match[0].replace(/[.),:]+$/, ''))
143
+ }
144
+ if (/\bversion|v\d/i.test(sentence)) {
145
+ for (const match of sentence.matchAll(VERSION_RE)) push('version', match[0])
146
+ }
147
+ for (const match of sentence.matchAll(QUANTITY_RE)) push('quantity', match[0])
148
+
149
+ // Distinctive content words after action/status verbs. These anchor a claim
150
+ // to a plausible tool call even when the trace paraphrases the output.
151
+ const words = String(sentence ?? '')
152
+ .toLowerCase()
153
+ .split(/[^a-z0-9_.@/-]+/i)
154
+ .filter((word) => word.length >= 4 && !STOP_WORDS.has(word) && !/^\d+$/.test(word))
155
+ for (const word of words.slice(0, 8)) push('term', word)
156
+
157
+ return dedupeAnchors(anchors)
158
+ }
159
+
160
+ /** Irregular past forms of tool verbs that assert an observed action. */
161
+ const PAST_IRREGULAR_RE = /^(?:ran|read|wrote|built|made|found|saw|got|took|sent|left|put|kept|held|met|paid|said|told|thought|knew|grew|drew|threw|chose|brought|caught|taught|understood|won|began|was|were)$/i
162
+
163
+ /** True when a recognized action verb reads as a completed observation. */
164
+ function verbReadsAsPast(verb) {
165
+ const value = String(verb ?? '').toLowerCase()
166
+ return value.endsWith('ed') || PAST_IRREGULAR_RE.test(value)
167
+ }
168
+
169
+ /** Map a claim sentence to the tool class whose output would support it. */
170
+ export function classifyToolClass(sentence, detail, anchors, kind) {
171
+ const value = String(sentence ?? '').replace(URL_RE, ' ')
172
+ const rest = String(detail ?? '').replace(URL_RE, ' ')
173
+ const combined = `${value} ${rest}`
174
+ if (kind === 'hedged' || kind === 'universal') return null
175
+ if (/\b(tests?|test suite|suite|pytest|jest|vitest|rspec|phpunit|mocha|ava)\b/i.test(combined)) return 'test'
176
+ if (/\b(build|compile|bundle|package)\b/i.test(combined)) return 'build'
177
+ if (/\b(lint|typecheck|type-check|static analysis|format(?:ting)? check)\b/i.test(combined)) return 'lint'
178
+ if (/\b(wrote|created|edited|modified|patched|saved|deleted|removed)\b/i.test(rest)) return 'write'
179
+ if (/\b(read|opened|cat|viewed|inspected|examined|looked at)\b/i.test(rest) || anchors.some((anchor) => anchor.kind === 'path')) return 'read'
180
+ if (/\b(ran|executed|launched|tested|checked|verified|confirmed|searched|grepped|queried|fetched|downloaded)\b/i.test(rest)) return 'exec'
181
+ return null
182
+ }
183
+
184
+ /** True when a tool call's command/arguments match one tool class. */
185
+ export function callMatchesClass(call, toolClass) {
186
+ const haystack = `${call.name ?? ''}\n${call.argsRaw ?? ''}\n${call.resultText ?? ''}`
187
+ switch (toolClass) {
188
+ case 'test':
189
+ return TEST_COMMAND_RE.test(haystack)
190
+ case 'build':
191
+ return BUILD_COMMAND_RE.test(haystack)
192
+ case 'lint':
193
+ return LINT_COMMAND_RE.test(haystack)
194
+ case 'read':
195
+ return READ_TOOL_RE.test(call.name ?? '') || /(?:read|cat|view|open|show|head|tail)/i.test(call.argsRaw ?? '')
196
+ case 'write':
197
+ return WRITE_TOOL_RE.test(call.name ?? '')
198
+ case 'exec':
199
+ return EXEC_TOOL_RE.test(call.name ?? '') || /(?:bash|sh|nu|pwsh|node|python|nix|npm|pnpm|yarn|git|make|cargo|go|pytest|jest)/i.test(call.argsRaw ?? '')
200
+ default:
201
+ return false
202
+ }
203
+ }
204
+
205
+ /** True when the claim's anchors select a specific call. */
206
+ function callMatchesAnchors(call, anchors) {
207
+ const haystack = `${call.name ?? ''} ${call.argsRaw ?? ''} ${call.resultText ?? ''}`.toLowerCase()
208
+ return anchors.some((anchor) => anchor.kind !== 'term' && haystack.includes(anchor.normalized))
209
+ }
210
+
211
+ /** Find the most plausible call for a claim: exact anchor match first, then class. */
212
+ function selectCall(claim, calls) {
213
+ const withAnchors = calls.filter((call) => callMatchesAnchors(call, claim.anchors))
214
+ if (withAnchors.length > 0) return withAnchors[withAnchors.length - 1]
215
+ if (claim.toolClass !== null) {
216
+ const byClass = calls.filter((call) => callMatchesClass(call, claim.toolClass))
217
+ if (byClass.length > 0) return byClass[byClass.length - 1]
218
+ }
219
+ return undefined
220
+ }
221
+
222
+ /** True when a call's rendered output asserts success for one tool class. */
223
+ function callShowsSuccess(call, toolClass) {
224
+ if (call.failed) return false
225
+ const text = String(call.resultText ?? '')
226
+ if (/\b(?:FAILED|ERROR|Traceback|failed|failing)\b/.test(text)) return false
227
+ if (toolClass === 'test') return /\b(?:passed|passing|ok\b|\d+\s+passed|all tests passed)\b/i.test(text)
228
+ return true
229
+ }
230
+
231
+ /** True when a call's rendered output asserts failure for one tool class. */
232
+ function callShowsFailure(call) {
233
+ if (call.failed) return true
234
+ const text = String(call.resultText ?? '')
235
+ return /\b(?:failed|failing|FAILED|ERROR|Traceback|\d+\s+failed)\b/.test(text)
236
+ }
237
+
238
+ /**
239
+ * Extract candidate claims from a reasoning window.
240
+ * @param {string} text - unaudited reasoning text.
241
+ * @param {object} [options] - `maxClaims` cap.
242
+ * @returns {object[]} claims.
243
+ */
244
+ export function extractClaims(text, options = {}) {
245
+ const maxClaims = options.maxClaims ?? 64
246
+ const claims = []
247
+ const seen = new Set()
248
+
249
+ const add = (claim) => {
250
+ if (claims.length >= maxClaims) return
251
+ const key = claim.quote.toLowerCase().replace(/\s+/g, ' ')
252
+ if (seen.has(key)) return
253
+ seen.add(key)
254
+ claims.push(claim)
255
+ }
256
+
257
+ for (const sentence of splitSentences(text)) {
258
+ if (sentence.length < 8) continue
259
+ const anchors = extractAnchors(sentence)
260
+ const hedged = HEDGE_RE.test(sentence)
261
+ const universal = UNIVERSAL_RE.test(sentence)
262
+ const assertsSuccess = SUCCESS_RE.test(sentence) && !FAILURE_WORD_RE.test(sentence)
263
+ const assertsFailure = FAILURE_WORD_RE.test(sentence) && !SUCCESS_RE.test(sentence)
264
+ const matched = []
265
+ let actionDetail = ''
266
+
267
+ ACTION_RE.lastIndex = 0
268
+ let actionVerb = ''
269
+ let match
270
+ while ((match = ACTION_RE.exec(sentence)) !== null) {
271
+ matched.push('action')
272
+ if (actionVerb.length === 0) actionVerb = match[1] ?? ''
273
+ actionDetail = `${actionDetail} ${match[2] ?? ''}`.trim()
274
+ }
275
+ STATUS_RE.lastIndex = 0
276
+ while (STATUS_RE.exec(sentence) !== null) matched.push('status')
277
+ ASSERTION_RE.lastIndex = 0
278
+ while (ASSERTION_RE.exec(sentence) !== null) matched.push('assertion')
279
+
280
+ if (matched.length === 0) {
281
+ if (hedged) matched.push('hedged')
282
+ else if (universal) matched.push('universal')
283
+ else if (anchors.length > 0) matched.push('reference')
284
+ else continue
285
+ }
286
+
287
+ let kind = matched[0]
288
+ // Planning, conditional, or hypothetical language is not an observed
289
+ // action: "If I ran the tests" / "I'll run the tests" must not become a
290
+ // high-severity missing-tool-call claim.
291
+ const conditional = /\b(?:if|unless|suppose|hypothetically|would|could|might|may)\b/i.test(sentence)
292
+ const future = /(?:\bi\s*(?:'ll| will|'m going to)\b|\b(?:going to|gonna|plan to|intend to|need to|should|let me)\b)/i.test(sentence)
293
+ if (kind === 'action' && (conditional || (future && !verbReadsAsPast(actionVerb)))) kind = 'hedged'
294
+ const toolClass = classifyToolClass(sentence, actionDetail, anchors, kind)
295
+ add({
296
+ quote: truncate(sentence, 400),
297
+ kind,
298
+ toolClass,
299
+ anchors,
300
+ hedged,
301
+ universal,
302
+ assertsSuccess: assertsSuccess || /verified|confirmed/i.test(actionDetail) || kind === 'status' && !assertsFailure && SUCCESS_RE.test(sentence),
303
+ assertsFailure: assertsFailure || FAILURE_WORD_RE.test(actionDetail),
304
+ detail: truncate(actionDetail || sentence, 300),
305
+ })
306
+ }
307
+ return claims
308
+ }
309
+
310
+ /** Build one finding object. */
311
+ function finding(claim, status, severity, basis, extra = {}) {
312
+ return {
313
+ quote: claim.quote,
314
+ status,
315
+ severity,
316
+ basis,
317
+ kind: claim.kind,
318
+ toolClass: claim.toolClass,
319
+ ...extra,
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Verify extracted claims against one evidence view.
325
+ *
326
+ * @param {object[]} claims - output of {@link extractClaims}.
327
+ * @param {object} evidence - a view from `EvidenceLedger.view()`.
328
+ * @returns {object[]} findings, one per claim.
329
+ */
330
+ export function verifyClaims(claims, evidence) {
331
+ const findings = []
332
+ for (const claim of claims ?? []) {
333
+ findings.push(verifyClaim(claim, evidence ?? emptyEvidence()))
334
+ }
335
+ return findings
336
+ }
337
+
338
+ /** Evidence object used when no session ledger exists. */
339
+ function emptyEvidence() {
340
+ return { calls: [], users: [], currentTurn: 0, has: () => false, hasUser: () => false }
341
+ }
342
+
343
+ /** Verify one claim. */
344
+ function verifyClaim(claim, evidence) {
345
+ const anchors = claim.anchors ?? []
346
+ const relevantCalls = claimRelevantCalls(claim, evidence)
347
+ const quoted = anchors.filter((anchor) => anchor.kind === 'quote')
348
+ const paths = anchors.filter((anchor) => anchor.kind === 'path')
349
+ const quantities = anchors.filter((anchor) => anchor.kind === 'quantity')
350
+ const references = anchors.filter((anchor) => anchor.kind === 'url' || anchor.kind === 'version')
351
+ const terms = anchors.filter((anchor) => anchor.kind === 'term')
352
+ const evidenceRefs = []
353
+
354
+ // Planning, conditional, and hedged language is a hypothesis, not a
355
+ // fabrication. Do this before any anchor join so a conditional sentence
356
+ // cannot become a high-severity unsupported or contradicted claim.
357
+ if (claim.hedged || claim.kind === 'hedged') {
358
+ const supported = terms.some((anchor) => evidence.has(anchor.normalized))
359
+ return supported
360
+ ? finding(claim, 'supported', 'low', 'hedged claim matches a ledger term', { evidenceRefs })
361
+ : finding(claim, 'speculative', 'low', 'hedged language without an asserted observation', { evidenceRefs })
362
+ }
363
+
364
+ // 1. Quoted excerpts are the strongest fabrication signal: a quoted file or
365
+ // output fragment that appears nowhere in the ledger is unsupported.
366
+ for (const anchor of quoted) {
367
+ if (evidence.has(anchor.normalized) || evidence.hasUser(anchor.normalized)) continue
368
+ const supportingCall = selectCall(claim, relevantCalls)
369
+ if (supportingCall !== undefined && claim.kind === 'assertion') {
370
+ return finding(claim, 'contradicted', 'high',
371
+ `quoted text ${JSON.stringify(truncate(anchor.value, 80))} does not appear in the result of ${supportingCall.name} (${supportingCall.callId})`,
372
+ { evidenceRefs: [`result:${supportingCall.callId}`], noMatchingToolCall: false })
373
+ }
374
+ return finding(claim, 'unsupported', 'high',
375
+ `quoted text ${JSON.stringify(truncate(anchor.value, 80))} has no antecedent in the evidence ledger`,
376
+ { evidenceRefs, noMatchingToolCall: false })
377
+ }
378
+
379
+ // 2. Paths must appear in some tool interaction or user message.
380
+ for (const anchor of paths) {
381
+ if (evidence.has(anchor.normalized) || evidence.hasUser(anchor.normalized)) continue
382
+ const severity = claim.toolClass === 'read' || claim.toolClass === 'write' || claim.kind === 'action' ? 'high' : 'medium'
383
+ return finding(claim, 'unsupported', severity,
384
+ `path ${JSON.stringify(anchor.value)} never appears in a tool call, tool result, or user message`,
385
+ { evidenceRefs, noMatchingToolCall: relevantCalls.length === 0 })
386
+ }
387
+
388
+ // 3. Action/status claims need a matching tool call in this turn. A missing
389
+ // call for a first-person tool claim is the highest-precision signal.
390
+ if (['action', 'status', 'assertion'].includes(claim.kind) && claim.toolClass !== null) {
391
+ const call = selectCall(claim, relevantCalls)
392
+ if (call === undefined) {
393
+ return finding(claim, 'unsupported', 'high',
394
+ `no tool call in the current turn matches a ${claim.toolClass} claim; the result is asserted without an antecedent`,
395
+ { evidenceRefs, noMatchingToolCall: true })
396
+ }
397
+ evidenceRefs.push(`call:${call.callId}`, `result:${call.callId}`)
398
+ if (claim.assertsSuccess && callShowsFailure(call)) {
399
+ return finding(claim, 'contradicted', 'high',
400
+ `claim asserts success but tool result ${call.callId} (${call.name}) reports failure: ${JSON.stringify(truncate(call.resultText, 160))}`,
401
+ { evidenceRefs, noMatchingToolCall: false })
402
+ }
403
+ if (claim.assertsFailure && callShowsSuccess(call, claim.toolClass)) {
404
+ return finding(claim, 'contradicted', 'medium',
405
+ `claim asserts failure but tool result ${call.callId} (${call.name}) reports success`,
406
+ { evidenceRefs, noMatchingToolCall: false })
407
+ }
408
+ return finding(claim, 'supported', 'low',
409
+ `tool result ${call.callId} (${call.name}) is the antecedent for this claim`,
410
+ { evidenceRefs, noMatchingToolCall: false })
411
+ }
412
+
413
+ // 4. Quantities without a ledger antecedent are unsupported.
414
+ for (const anchor of quantities) {
415
+ if (evidence.has(anchor.normalized) || evidence.hasUser(anchor.normalized)) continue
416
+ return finding(claim, 'unsupported', 'medium',
417
+ `quantity ${JSON.stringify(anchor.value)} has no antecedent in the evidence ledger`,
418
+ { evidenceRefs, noMatchingToolCall: relevantCalls.length === 0 })
419
+ }
420
+
421
+ // 5. URLs and versions without an antecedent are weaker references.
422
+ for (const anchor of references) {
423
+ if (evidence.has(anchor.normalized) || evidence.hasUser(anchor.normalized)) continue
424
+ return finding(claim, 'unsupported', 'low',
425
+ `${anchor.kind} ${JSON.stringify(anchor.value)} has no antecedent in the evidence ledger`,
426
+ { evidenceRefs, noMatchingToolCall: false })
427
+ }
428
+
429
+
430
+ // 7. Universal/absolute language without a ledger antecedent is speculative.
431
+ if (claim.universal || claim.kind === 'universal') {
432
+ return finding(claim, 'speculative', 'medium',
433
+ 'absolute language without a ledger antecedent; scope may exceed the evidence',
434
+ { evidenceRefs })
435
+ }
436
+
437
+ // 8. Plain references are supported when their anchors appear.
438
+ const missing = terms.find((anchor) => !evidence.has(anchor.normalized))
439
+ if (missing !== undefined) {
440
+ return finding(claim, 'unsupported', 'low',
441
+ `term ${JSON.stringify(missing.value)} has no antecedent in the evidence ledger`,
442
+ { evidenceRefs, noMatchingToolCall: relevantCalls.length === 0 })
443
+ }
444
+ return finding(claim, 'supported', 'low', 'all claim anchors have ledger antecedents', { evidenceRefs })
445
+ }
446
+
447
+ /** Calls worth considering for one claim: current turn first, then recent. */
448
+ function claimRelevantCalls(claim, evidence) {
449
+ const calls = evidence.calls ?? []
450
+ const currentTurn = evidence.currentTurn ?? 0
451
+ const turnCalls = currentTurn > 0 ? calls.filter((call) => call.turn === currentTurn) : []
452
+ return turnCalls.length > 0 ? turnCalls : calls.slice(-12)
453
+ }
454
+
455
+ /**
456
+ * Summarize findings into the deterministic mechanical verdict consumed by the
457
+ * policy module.
458
+ * @param {object[]} findings - verified findings.
459
+ * @param {object} config - resolved configuration.
460
+ * @returns {object} mechanical summary.
461
+ */
462
+ export function summarizeFindings(findings, config = {}) {
463
+ const gates = config.gates ?? {}
464
+ const minimum = gates.minimumSeverity ?? 'low'
465
+ const counts = { supported: 0, unsupported: 0, contradicted: 0, speculative: 0 }
466
+ const significant = []
467
+ for (const item of findings ?? []) {
468
+ if (counts[item.status] !== undefined) counts[item.status] += 1
469
+ if (severityAtLeast(item.severity, minimum)) significant.push(item)
470
+ }
471
+ const contradictedHigh = significant.some((item) => item.status === 'contradicted' && item.severity === 'high')
472
+ const contradicted = significant.some((item) => item.status === 'contradicted')
473
+ const unsupportedHigh = significant.some((item) => item.status === 'unsupported' && item.severity === 'high')
474
+ const unsupported = significant.some((item) => item.status === 'unsupported')
475
+ const noToolHigh = significant.some((item) => item.status === 'unsupported' && item.severity === 'high' && item.noMatchingToolCall === true)
476
+ const confirmedFabrication = noToolHigh || contradictedHigh
477
+
478
+ const blockOn = gates.blockOn ?? 'confirmed'
479
+ const verifyOn = gates.verifyOn ?? 'unsupported'
480
+ const statusRank = { supported: 0, speculative: 1, unsupported: 2, contradicted: 3 }
481
+ const verifyFloor = verifyOn === 'speculative'
482
+ ? 1
483
+ : verifyOn === 'unsupported'
484
+ ? 2
485
+ : verifyOn === 'contradicted'
486
+ ? 3
487
+ : Number.POSITIVE_INFINITY
488
+ const blockWanted = blockOn === 'confirmed' ? confirmedFabrication : blockOn === 'contradicted' ? contradicted : false
489
+ const drivesVerify = significant.some((item) => (statusRank[item.status] ?? 0) >= verifyFloor)
490
+
491
+ let level = 'none'
492
+ if (blockWanted) level = 'block'
493
+ else if (drivesVerify) level = 'verify'
494
+ else if (significant.length > 0) level = 'watch'
495
+
496
+ const reasons = []
497
+ if (noToolHigh) reasons.push('an asserted tool result has no matching tool call')
498
+ if (contradictedHigh) reasons.push('a success claim is contradicted by a failed tool result')
499
+ if (unsupportedHigh && !noToolHigh) reasons.push('a high-severity claim has no ledger antecedent')
500
+ if (unsupported && !unsupportedHigh && !noToolHigh) reasons.push('unsupported claims need verification')
501
+ if (counts.speculative > 0) reasons.push(`${counts.speculative} speculative claim(s)`)
502
+
503
+ return {
504
+ level,
505
+ confirmedFabrication,
506
+ counts,
507
+ reasons,
508
+ findings: findings ?? [],
509
+ highestSeverity: highestSeverity(significant),
510
+ }
511
+ }
512
+
513
+ /** Highest severity from a list of findings. */
514
+ function highestSeverity(findings) {
515
+ let highest = 'low'
516
+ for (const item of findings) {
517
+ if ((item.severity === 'high') || (item.severity === 'medium' && highest === 'low')) highest = item.severity
518
+ }
519
+ return highest
520
+ }