@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/LICENSE +21 -0
- package/README.md +179 -0
- package/index.mjs +674 -0
- package/package.json +82 -0
- package/packaging/README.md +78 -0
- package/packaging/nixos/agents.nix.snippet +46 -0
- package/packaging/nixos/default.nix.snippet +6 -0
- package/packaging/nixos/dsh-thinking-auditor.nix +54 -0
- package/src/async.mjs +90 -0
- package/src/auditor.mjs +252 -0
- package/src/claims.mjs +520 -0
- package/src/config.mjs +243 -0
- package/src/file-sink.mjs +76 -0
- package/src/ledger.mjs +398 -0
- package/src/policy.mjs +189 -0
- package/src/stakes.mjs +104 -0
- package/src/state.mjs +131 -0
- package/src/store.mjs +185 -0
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
|
+
}
|