@gmickel/gno 1.19.0 → 1.20.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/README.md +12 -7
- package/assets/skill/SKILL.md +27 -12
- package/assets/skill/mcp-reference.md +7 -2
- package/assets/skill/recipes/citation-and-provenance.md +32 -9
- package/package.json +1 -1
- package/spec/cli.md +42 -17
- package/spec/evals-agentic.md +87 -5
- package/spec/mcp.md +53 -3
- package/spec/output-schemas/ask.schema.json +198 -0
- package/spec/output-schemas/claim-verification.schema.json +291 -0
- package/spec/output-schemas/context-capsule-v1.schema.json +36 -1
- package/src/app/context-runtime-contract.ts +10 -5
- package/src/app/context-runtime-input.ts +29 -1
- package/src/app/context-runtime-types.ts +4 -0
- package/src/app/context-runtime.ts +5 -1
- package/src/app/context-surface.ts +4 -0
- package/src/app/verified-ask.ts +291 -0
- package/src/cli/commands/ask-format.ts +255 -0
- package/src/cli/commands/ask.ts +40 -149
- package/src/cli/program.ts +32 -1
- package/src/core/context-budget.ts +6 -0
- package/src/core/context-capsule-retrieval-schema.ts +4 -0
- package/src/core/context-capsule-schema.ts +17 -0
- package/src/core/context-capsule-validation.ts +3 -2
- package/src/core/context-capsule.ts +18 -0
- package/src/core/context-compiler.ts +33 -21
- package/src/core/context-evidence.ts +6 -0
- package/src/core/retrieval-trace-evidence-origin.ts +3 -0
- package/src/core/retrieval-trace-session.ts +15 -2
- package/src/llm/errors.ts +10 -1
- package/src/llm/httpGeneration.ts +11 -1
- package/src/llm/nodeLlamaCpp/generation.ts +54 -10
- package/src/llm/types.ts +6 -0
- package/src/mcp/tools/ask.ts +228 -0
- package/src/mcp/tools/context.ts +28 -7
- package/src/mcp/tools/index.ts +9 -0
- package/src/pipeline/claim-verification-schema.ts +235 -0
- package/src/pipeline/claim-verification.ts +487 -0
- package/src/pipeline/claim-verifier.ts +474 -0
- package/src/pipeline/types.ts +25 -0
- package/src/sdk/client.ts +35 -2
- package/src/serve/public/components/AskVerificationPanel.tsx +189 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/pages/Ask.tsx +42 -4
- package/src/serve/routes/api.ts +149 -3
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import type { ContextCapsuleV1 } from "../core/context-capsule";
|
|
2
|
+
import type { ContextCapsuleVerification } from "../core/context-capsule-verification";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
contextCapsuleV1Schema,
|
|
6
|
+
parseContextCapsuleV1,
|
|
7
|
+
} from "../core/context-capsule";
|
|
8
|
+
import { sha256Text } from "../core/context-capsule-validation";
|
|
9
|
+
import { contextCapsuleVerificationSchema } from "../core/context-capsule-verification";
|
|
10
|
+
import {
|
|
11
|
+
CLAIM_COORDINATE_SPACE,
|
|
12
|
+
CLAIM_VERIFICATION_SCHEMA_VERSION,
|
|
13
|
+
claimVerificationResultSchema,
|
|
14
|
+
type ClaimVerificationResult,
|
|
15
|
+
type SemanticClaimJudgment,
|
|
16
|
+
semanticClaimJudgmentSchema,
|
|
17
|
+
} from "./claim-verification-schema";
|
|
18
|
+
|
|
19
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
|
20
|
+
const CITATION_PATTERN = /\[evidence(?::([^\]\r\n]*))?(\]?)/g;
|
|
21
|
+
const SUBSTANTIVE_PATTERN = /[\p{L}\p{N}]/u;
|
|
22
|
+
const CLAIM_BOUNDARY_PATTERN = /[.!?;\n]/;
|
|
23
|
+
const TRAILING_CLOSER_PATTERN = /["')\]}’”]/;
|
|
24
|
+
const COVERAGE_THRESHOLD = 1;
|
|
25
|
+
const MAX_CLAIMS = 256;
|
|
26
|
+
const MAX_CITATIONS = 256;
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
CLAIM_COORDINATE_SPACE,
|
|
30
|
+
CLAIM_VERIFICATION_SCHEMA_VERSION,
|
|
31
|
+
claimVerificationResultSchema,
|
|
32
|
+
semanticClaimJudgmentSchema,
|
|
33
|
+
};
|
|
34
|
+
export const CLAIM_ABSTENTION_TEXT =
|
|
35
|
+
"I cannot provide this answer as verified from the supplied Context Capsule.";
|
|
36
|
+
|
|
37
|
+
export type { SemanticClaimJudgment };
|
|
38
|
+
|
|
39
|
+
export interface ClaimSpan {
|
|
40
|
+
claimId: string;
|
|
41
|
+
text: string;
|
|
42
|
+
start: number;
|
|
43
|
+
end: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ClaimVerificationStatus =
|
|
47
|
+
| "supported"
|
|
48
|
+
| "contradicted"
|
|
49
|
+
| "insufficient"
|
|
50
|
+
| "uncertain";
|
|
51
|
+
|
|
52
|
+
export type RejectedCitationReason =
|
|
53
|
+
| "malformed_citation"
|
|
54
|
+
| "out_of_capsule"
|
|
55
|
+
| "freshness_unavailable"
|
|
56
|
+
| "freshness_receipt_invalid"
|
|
57
|
+
| "freshness_receipt_mismatch"
|
|
58
|
+
| "evidence_stale"
|
|
59
|
+
| "evidence_missing"
|
|
60
|
+
| "orphan_citation";
|
|
61
|
+
|
|
62
|
+
export interface RejectedCitation {
|
|
63
|
+
marker: string;
|
|
64
|
+
start: number;
|
|
65
|
+
end: number;
|
|
66
|
+
evidenceId: string | null;
|
|
67
|
+
reason: RejectedCitationReason;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ClaimEvidenceReference {
|
|
71
|
+
evidenceId: string;
|
|
72
|
+
uri: string;
|
|
73
|
+
startLine: number;
|
|
74
|
+
endLine: number;
|
|
75
|
+
text: string;
|
|
76
|
+
sourceHash: string;
|
|
77
|
+
mirrorHash: string;
|
|
78
|
+
passageHash: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface VerifiedClaim extends ClaimSpan {
|
|
82
|
+
status: ClaimVerificationStatus;
|
|
83
|
+
confidence: number | null;
|
|
84
|
+
rationaleCode:
|
|
85
|
+
| "semantic_entailment"
|
|
86
|
+
| "semantic_contradiction"
|
|
87
|
+
| "no_valid_evidence"
|
|
88
|
+
| "semantic_judgment_unavailable";
|
|
89
|
+
verifierFingerprint: string | null;
|
|
90
|
+
evidence: ClaimEvidenceReference[];
|
|
91
|
+
rejectedCitations: RejectedCitation[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type { ClaimVerificationResult };
|
|
95
|
+
|
|
96
|
+
export interface VerifyClaimsInput {
|
|
97
|
+
answer: string;
|
|
98
|
+
capsule: unknown;
|
|
99
|
+
freshness?: unknown;
|
|
100
|
+
semanticJudgments?: readonly unknown[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const trimSpan = (
|
|
104
|
+
answer: string,
|
|
105
|
+
startInput: number,
|
|
106
|
+
endInput: number
|
|
107
|
+
): { start: number; end: number } => {
|
|
108
|
+
let start = startInput;
|
|
109
|
+
let end = endInput;
|
|
110
|
+
while (start < end && /\s/.test(answer[start] ?? "")) start += 1;
|
|
111
|
+
while (end > start && /\s/.test(answer[end - 1] ?? "")) end -= 1;
|
|
112
|
+
return { start, end };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const citationFreeText = (value: string): string =>
|
|
116
|
+
value.replace(CITATION_PATTERN, "").replace(/^\s*(?:[#>*+-]|\d+[.)])\s+/, "");
|
|
117
|
+
|
|
118
|
+
const isAbbreviationPeriod = (answer: string, index: number): boolean => {
|
|
119
|
+
const previous = answer[index - 1] ?? "";
|
|
120
|
+
const next = answer[index + 1] ?? "";
|
|
121
|
+
if (/\d/.test(previous) && /\d/.test(next)) return true;
|
|
122
|
+
const prefix = answer.slice(Math.max(0, index - 8), index + 1).toLowerCase();
|
|
123
|
+
return /(?:\b(?:dr|mr|mrs|ms|prof|sr|jr|vs|etc)|e\.g|i\.e|u\.s)\.$/.test(
|
|
124
|
+
prefix
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const fencedCodeEnd = (answer: string, start: number): number => {
|
|
129
|
+
const closing = answer.indexOf("```", start + 3);
|
|
130
|
+
return closing === -1 ? answer.length : closing + 3;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const assertCitationLimit = (answer: string): void => {
|
|
134
|
+
CITATION_PATTERN.lastIndex = 0;
|
|
135
|
+
let count = 0;
|
|
136
|
+
for (const _match of answer.matchAll(CITATION_PATTERN)) {
|
|
137
|
+
count += 1;
|
|
138
|
+
if (count > MAX_CITATIONS) {
|
|
139
|
+
throw new Error(`Citation limit exceeded (${MAX_CITATIONS})`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const claimIdentity = (
|
|
145
|
+
capsuleId: string,
|
|
146
|
+
start: number,
|
|
147
|
+
end: number,
|
|
148
|
+
text: string
|
|
149
|
+
): string => sha256Text(JSON.stringify({ capsuleId, end, start, text }));
|
|
150
|
+
|
|
151
|
+
/** Split substantive claims without rewriting. Offsets are half-open UTF-16. */
|
|
152
|
+
export const segmentSubstantiveClaims = (
|
|
153
|
+
answer: string,
|
|
154
|
+
capsuleId: string
|
|
155
|
+
): ClaimSpan[] => {
|
|
156
|
+
const claims: ClaimSpan[] = [];
|
|
157
|
+
const appendClaim = (start: number, end: number, text: string): void => {
|
|
158
|
+
claims.push({
|
|
159
|
+
claimId: claimIdentity(capsuleId, start, end, text),
|
|
160
|
+
text,
|
|
161
|
+
start,
|
|
162
|
+
end,
|
|
163
|
+
});
|
|
164
|
+
if (claims.length > MAX_CLAIMS) {
|
|
165
|
+
throw new Error(`Claim limit exceeded (${MAX_CLAIMS})`);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
let segmentStart = 0;
|
|
169
|
+
for (let index = 0; index < answer.length; index += 1) {
|
|
170
|
+
if (answer.startsWith("```", index)) {
|
|
171
|
+
const before = trimSpan(answer, segmentStart, index);
|
|
172
|
+
const beforeText = answer.slice(before.start, before.end);
|
|
173
|
+
if (SUBSTANTIVE_PATTERN.test(citationFreeText(beforeText))) {
|
|
174
|
+
appendClaim(before.start, before.end, beforeText);
|
|
175
|
+
}
|
|
176
|
+
const codeEnd = fencedCodeEnd(answer, index);
|
|
177
|
+
segmentStart = codeEnd;
|
|
178
|
+
index = codeEnd - 1;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const character = answer[index] ?? "";
|
|
182
|
+
if (!CLAIM_BOUNDARY_PATTERN.test(character)) continue;
|
|
183
|
+
if (character === "." && isAbbreviationPeriod(answer, index)) continue;
|
|
184
|
+
let boundary = index + 1;
|
|
185
|
+
while (
|
|
186
|
+
character !== "\n" &&
|
|
187
|
+
boundary < answer.length &&
|
|
188
|
+
TRAILING_CLOSER_PATTERN.test(answer[boundary] ?? "")
|
|
189
|
+
) {
|
|
190
|
+
boundary += 1;
|
|
191
|
+
}
|
|
192
|
+
const next = answer[boundary];
|
|
193
|
+
if (character !== "\n" && next !== undefined && !/\s/.test(next)) continue;
|
|
194
|
+
const span = trimSpan(answer, segmentStart, boundary);
|
|
195
|
+
const text = answer.slice(span.start, span.end);
|
|
196
|
+
if (SUBSTANTIVE_PATTERN.test(citationFreeText(text))) {
|
|
197
|
+
appendClaim(span.start, span.end, text);
|
|
198
|
+
}
|
|
199
|
+
segmentStart = boundary;
|
|
200
|
+
}
|
|
201
|
+
const span = trimSpan(answer, segmentStart, answer.length);
|
|
202
|
+
const text = answer.slice(span.start, span.end);
|
|
203
|
+
if (SUBSTANTIVE_PATTERN.test(citationFreeText(text))) {
|
|
204
|
+
appendClaim(span.start, span.end, text);
|
|
205
|
+
}
|
|
206
|
+
return claims;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
type Evidence = ContextCapsuleV1["evidence"][number];
|
|
210
|
+
type EvidenceReceipt = ContextCapsuleVerification["evidence"][number];
|
|
211
|
+
|
|
212
|
+
interface FreshnessState {
|
|
213
|
+
status: "verified" | "unavailable" | "invalid" | "mismatch";
|
|
214
|
+
receipts: Map<string, EvidenceReceipt>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const freshnessState = (
|
|
218
|
+
capsule: ContextCapsuleV1,
|
|
219
|
+
input: unknown
|
|
220
|
+
): FreshnessState => {
|
|
221
|
+
if (input === undefined)
|
|
222
|
+
return { status: "unavailable", receipts: new Map() };
|
|
223
|
+
const parsed = contextCapsuleVerificationSchema.safeParse(input);
|
|
224
|
+
if (!parsed.success) return { status: "invalid", receipts: new Map() };
|
|
225
|
+
const expected = new Map(
|
|
226
|
+
capsule.evidence.map((evidence) => [evidence.evidenceId, evidence])
|
|
227
|
+
);
|
|
228
|
+
const complete =
|
|
229
|
+
parsed.data.capsuleId === capsule.capsuleId &&
|
|
230
|
+
parsed.data.evidence.length === expected.size &&
|
|
231
|
+
parsed.data.evidence.every(
|
|
232
|
+
(receipt) =>
|
|
233
|
+
expected.get(receipt.evidenceId)?.uri === receipt.uri &&
|
|
234
|
+
(receipt.contentStatus !== "unchanged" ||
|
|
235
|
+
(receipt.currentSourceHash ===
|
|
236
|
+
expected.get(receipt.evidenceId)?.sourceHash &&
|
|
237
|
+
receipt.currentMirrorHash ===
|
|
238
|
+
expected.get(receipt.evidenceId)?.mirrorHash &&
|
|
239
|
+
receipt.currentPassageHash ===
|
|
240
|
+
expected.get(receipt.evidenceId)?.passageHash))
|
|
241
|
+
);
|
|
242
|
+
if (!complete) return { status: "mismatch", receipts: new Map() };
|
|
243
|
+
return {
|
|
244
|
+
status: "verified",
|
|
245
|
+
receipts: new Map(
|
|
246
|
+
parsed.data.evidence.map((receipt) => [receipt.evidenceId, receipt])
|
|
247
|
+
),
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const rejectedReason = (
|
|
252
|
+
evidence: Evidence | undefined,
|
|
253
|
+
freshness: FreshnessState
|
|
254
|
+
): RejectedCitationReason | null => {
|
|
255
|
+
if (!evidence) return "out_of_capsule";
|
|
256
|
+
if (freshness.status === "unavailable") return "freshness_unavailable";
|
|
257
|
+
if (freshness.status === "invalid") return "freshness_receipt_invalid";
|
|
258
|
+
if (freshness.status === "mismatch") return "freshness_receipt_mismatch";
|
|
259
|
+
const receipt = freshness.receipts.get(evidence.evidenceId);
|
|
260
|
+
if (!receipt || receipt.contentStatus === "missing")
|
|
261
|
+
return "evidence_missing";
|
|
262
|
+
if (receipt.contentStatus === "stale") return "evidence_stale";
|
|
263
|
+
return null;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const evidenceReference = (evidence: Evidence): ClaimEvidenceReference => ({
|
|
267
|
+
evidenceId: evidence.evidenceId,
|
|
268
|
+
uri: evidence.uri,
|
|
269
|
+
startLine: evidence.startLine,
|
|
270
|
+
endLine: evidence.endLine,
|
|
271
|
+
text: evidence.text,
|
|
272
|
+
sourceHash: evidence.sourceHash,
|
|
273
|
+
mirrorHash: evidence.mirrorHash,
|
|
274
|
+
passageHash: evidence.passageHash,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const citationsForClaim = (
|
|
278
|
+
answer: string,
|
|
279
|
+
claim: ClaimSpan,
|
|
280
|
+
evidenceById: ReadonlyMap<string, Evidence>,
|
|
281
|
+
freshness: FreshnessState
|
|
282
|
+
): { accepted: Evidence[]; rejected: RejectedCitation[] } => {
|
|
283
|
+
const accepted = new Map<string, Evidence>();
|
|
284
|
+
const rejected: RejectedCitation[] = [];
|
|
285
|
+
CITATION_PATTERN.lastIndex = claim.start;
|
|
286
|
+
for (const match of answer.matchAll(CITATION_PATTERN)) {
|
|
287
|
+
const start = match.index;
|
|
288
|
+
const end = start + match[0].length;
|
|
289
|
+
if (start >= claim.end) break;
|
|
290
|
+
if (start < claim.start) continue;
|
|
291
|
+
const rawId = match[1] ?? "";
|
|
292
|
+
const closed = match[2] === "]";
|
|
293
|
+
const evidenceId = closed && SHA256_PATTERN.test(rawId) ? rawId : null;
|
|
294
|
+
const evidence =
|
|
295
|
+
evidenceId === null ? undefined : evidenceById.get(evidenceId);
|
|
296
|
+
const reason =
|
|
297
|
+
evidenceId === null
|
|
298
|
+
? "malformed_citation"
|
|
299
|
+
: rejectedReason(evidence, freshness);
|
|
300
|
+
if (reason) {
|
|
301
|
+
rejected.push({
|
|
302
|
+
marker: match[0],
|
|
303
|
+
start,
|
|
304
|
+
end,
|
|
305
|
+
evidenceId,
|
|
306
|
+
reason,
|
|
307
|
+
});
|
|
308
|
+
} else if (evidence) {
|
|
309
|
+
accepted.set(evidence.evidenceId, evidence);
|
|
310
|
+
}
|
|
311
|
+
if (accepted.size + rejected.length > MAX_CITATIONS) {
|
|
312
|
+
throw new Error(`Citation limit exceeded (${MAX_CITATIONS})`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { accepted: [...accepted.values()], rejected };
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const judgmentForClaim = (
|
|
319
|
+
claim: ClaimSpan,
|
|
320
|
+
acceptedIds: ReadonlySet<string>,
|
|
321
|
+
inputs: readonly unknown[]
|
|
322
|
+
): SemanticClaimJudgment | null => {
|
|
323
|
+
const matches: SemanticClaimJudgment[] = [];
|
|
324
|
+
for (const input of inputs) {
|
|
325
|
+
const parsed = semanticClaimJudgmentSchema.safeParse(input);
|
|
326
|
+
if (
|
|
327
|
+
parsed.success &&
|
|
328
|
+
parsed.data.claimId === claim.claimId &&
|
|
329
|
+
parsed.data.evidenceIds.every((id) => acceptedIds.has(id))
|
|
330
|
+
) {
|
|
331
|
+
matches.push(parsed.data);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return matches.length === 1 ? (matches[0] ?? null) : null;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const verifyClaim = (
|
|
338
|
+
answer: string,
|
|
339
|
+
claim: ClaimSpan,
|
|
340
|
+
evidenceById: ReadonlyMap<string, Evidence>,
|
|
341
|
+
freshness: FreshnessState,
|
|
342
|
+
judgments: readonly unknown[]
|
|
343
|
+
): VerifiedClaim => {
|
|
344
|
+
const citations = citationsForClaim(answer, claim, evidenceById, freshness);
|
|
345
|
+
const references = citations.accepted.map(evidenceReference);
|
|
346
|
+
const acceptedIds = new Set(references.map((item) => item.evidenceId));
|
|
347
|
+
const judgment = judgmentForClaim(claim, acceptedIds, judgments);
|
|
348
|
+
if (references.length === 0) {
|
|
349
|
+
return {
|
|
350
|
+
...claim,
|
|
351
|
+
status: "insufficient",
|
|
352
|
+
confidence: null,
|
|
353
|
+
rationaleCode: "no_valid_evidence",
|
|
354
|
+
verifierFingerprint: null,
|
|
355
|
+
evidence: [],
|
|
356
|
+
rejectedCitations: citations.rejected,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if (!judgment) {
|
|
360
|
+
return {
|
|
361
|
+
...claim,
|
|
362
|
+
status: "uncertain",
|
|
363
|
+
confidence: null,
|
|
364
|
+
rationaleCode: "semantic_judgment_unavailable",
|
|
365
|
+
verifierFingerprint: null,
|
|
366
|
+
evidence: references,
|
|
367
|
+
rejectedCitations: citations.rejected,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
const selected = new Set(judgment.evidenceIds);
|
|
371
|
+
return {
|
|
372
|
+
...claim,
|
|
373
|
+
status: judgment.verdict,
|
|
374
|
+
confidence: judgment.confidence,
|
|
375
|
+
rationaleCode: judgment.rationaleCode,
|
|
376
|
+
verifierFingerprint: judgment.verifierFingerprint,
|
|
377
|
+
evidence: references.filter((item) => selected.has(item.evidenceId)),
|
|
378
|
+
rejectedCitations: citations.rejected,
|
|
379
|
+
};
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const orphanCitations = (
|
|
383
|
+
answer: string,
|
|
384
|
+
claims: readonly ClaimSpan[],
|
|
385
|
+
evidenceById: ReadonlyMap<string, Evidence>,
|
|
386
|
+
freshness: FreshnessState
|
|
387
|
+
): RejectedCitation[] => {
|
|
388
|
+
const rejected: RejectedCitation[] = [];
|
|
389
|
+
CITATION_PATTERN.lastIndex = 0;
|
|
390
|
+
for (const match of answer.matchAll(CITATION_PATTERN)) {
|
|
391
|
+
const start = match.index;
|
|
392
|
+
const end = start + match[0].length;
|
|
393
|
+
if (claims.some((claim) => start >= claim.start && start < claim.end)) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const rawId = match[1] ?? "";
|
|
397
|
+
const evidenceId =
|
|
398
|
+
match[2] === "]" && SHA256_PATTERN.test(rawId) ? rawId : null;
|
|
399
|
+
const evidence =
|
|
400
|
+
evidenceId === null ? undefined : evidenceById.get(evidenceId);
|
|
401
|
+
rejected.push({
|
|
402
|
+
marker: match[0],
|
|
403
|
+
start,
|
|
404
|
+
end,
|
|
405
|
+
evidenceId,
|
|
406
|
+
reason:
|
|
407
|
+
evidenceId === null
|
|
408
|
+
? "malformed_citation"
|
|
409
|
+
: (rejectedReason(evidence, freshness) ?? "orphan_citation"),
|
|
410
|
+
});
|
|
411
|
+
if (rejected.length > MAX_CITATIONS) {
|
|
412
|
+
throw new Error(`Citation limit exceeded (${MAX_CITATIONS})`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return rejected;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
export const verifyClaimsDeterministically = (
|
|
419
|
+
input: VerifyClaimsInput
|
|
420
|
+
): ClaimVerificationResult => {
|
|
421
|
+
if ((input.semanticJudgments?.length ?? 0) > MAX_CLAIMS) {
|
|
422
|
+
throw new Error(`Semantic judgment limit exceeded (${MAX_CLAIMS})`);
|
|
423
|
+
}
|
|
424
|
+
const capsule = parseContextCapsuleV1(input.capsule);
|
|
425
|
+
// A second strict parse makes the identity and nested contract requirement
|
|
426
|
+
// explicit at this pipeline boundary.
|
|
427
|
+
contextCapsuleV1Schema.parse(capsule);
|
|
428
|
+
assertCitationLimit(input.answer);
|
|
429
|
+
const claims = segmentSubstantiveClaims(input.answer, capsule.capsuleId);
|
|
430
|
+
const freshness = freshnessState(capsule, input.freshness);
|
|
431
|
+
const evidenceById = new Map(
|
|
432
|
+
capsule.evidence.map((evidence) => [evidence.evidenceId, evidence])
|
|
433
|
+
);
|
|
434
|
+
const verifiedClaims = claims.map((claim) =>
|
|
435
|
+
verifyClaim(
|
|
436
|
+
input.answer,
|
|
437
|
+
claim,
|
|
438
|
+
evidenceById,
|
|
439
|
+
freshness,
|
|
440
|
+
input.semanticJudgments ?? []
|
|
441
|
+
)
|
|
442
|
+
);
|
|
443
|
+
const rejectedCitations = orphanCitations(
|
|
444
|
+
input.answer,
|
|
445
|
+
claims,
|
|
446
|
+
evidenceById,
|
|
447
|
+
freshness
|
|
448
|
+
);
|
|
449
|
+
const count = (status: ClaimVerificationStatus): number =>
|
|
450
|
+
verifiedClaims.filter((claim) => claim.status === status).length;
|
|
451
|
+
const supportedClaims = count("supported");
|
|
452
|
+
const coverage = {
|
|
453
|
+
totalClaims: verifiedClaims.length,
|
|
454
|
+
supportedClaims,
|
|
455
|
+
contradictedClaims: count("contradicted"),
|
|
456
|
+
insufficientClaims: count("insufficient"),
|
|
457
|
+
uncertainClaims: count("uncertain"),
|
|
458
|
+
supportedRatio:
|
|
459
|
+
verifiedClaims.length === 0 ? 0 : supportedClaims / verifiedClaims.length,
|
|
460
|
+
};
|
|
461
|
+
const abstentionReason =
|
|
462
|
+
coverage.totalClaims === 0
|
|
463
|
+
? "no_substantive_claims"
|
|
464
|
+
: coverage.contradictedClaims > 0
|
|
465
|
+
? "contradiction_detected"
|
|
466
|
+
: rejectedCitations.length > 0 ||
|
|
467
|
+
verifiedClaims.some((claim) => claim.rejectedCitations.length > 0)
|
|
468
|
+
? "citation_hygiene_failed"
|
|
469
|
+
: coverage.supportedRatio < COVERAGE_THRESHOLD
|
|
470
|
+
? "coverage_below_threshold"
|
|
471
|
+
: null;
|
|
472
|
+
const abstained = abstentionReason !== null;
|
|
473
|
+
return claimVerificationResultSchema.parse({
|
|
474
|
+
schemaVersion: CLAIM_VERIFICATION_SCHEMA_VERSION,
|
|
475
|
+
coordinateSpace: CLAIM_COORDINATE_SPACE,
|
|
476
|
+
capsuleId: capsule.capsuleId,
|
|
477
|
+
answerHash: sha256Text(input.answer),
|
|
478
|
+
coverageThreshold: COVERAGE_THRESHOLD,
|
|
479
|
+
claims: verifiedClaims,
|
|
480
|
+
rejectedCitations,
|
|
481
|
+
coverage,
|
|
482
|
+
answerStatus: abstained ? "abstained" : "verified",
|
|
483
|
+
abstained,
|
|
484
|
+
abstentionReason,
|
|
485
|
+
abstentionText: abstained ? CLAIM_ABSTENTION_TEXT : null,
|
|
486
|
+
});
|
|
487
|
+
};
|