@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.
Files changed (45) hide show
  1. package/README.md +12 -7
  2. package/assets/skill/SKILL.md +27 -12
  3. package/assets/skill/mcp-reference.md +7 -2
  4. package/assets/skill/recipes/citation-and-provenance.md +32 -9
  5. package/package.json +1 -1
  6. package/spec/cli.md +42 -17
  7. package/spec/evals-agentic.md +87 -5
  8. package/spec/mcp.md +53 -3
  9. package/spec/output-schemas/ask.schema.json +198 -0
  10. package/spec/output-schemas/claim-verification.schema.json +291 -0
  11. package/spec/output-schemas/context-capsule-v1.schema.json +36 -1
  12. package/src/app/context-runtime-contract.ts +10 -5
  13. package/src/app/context-runtime-input.ts +29 -1
  14. package/src/app/context-runtime-types.ts +4 -0
  15. package/src/app/context-runtime.ts +5 -1
  16. package/src/app/context-surface.ts +4 -0
  17. package/src/app/verified-ask.ts +291 -0
  18. package/src/cli/commands/ask-format.ts +255 -0
  19. package/src/cli/commands/ask.ts +40 -149
  20. package/src/cli/program.ts +32 -1
  21. package/src/core/context-budget.ts +6 -0
  22. package/src/core/context-capsule-retrieval-schema.ts +4 -0
  23. package/src/core/context-capsule-schema.ts +17 -0
  24. package/src/core/context-capsule-validation.ts +3 -2
  25. package/src/core/context-capsule.ts +18 -0
  26. package/src/core/context-compiler.ts +33 -21
  27. package/src/core/context-evidence.ts +6 -0
  28. package/src/core/retrieval-trace-evidence-origin.ts +3 -0
  29. package/src/core/retrieval-trace-session.ts +15 -2
  30. package/src/llm/errors.ts +10 -1
  31. package/src/llm/httpGeneration.ts +11 -1
  32. package/src/llm/nodeLlamaCpp/generation.ts +54 -10
  33. package/src/llm/types.ts +6 -0
  34. package/src/mcp/tools/ask.ts +228 -0
  35. package/src/mcp/tools/context.ts +28 -7
  36. package/src/mcp/tools/index.ts +9 -0
  37. package/src/pipeline/claim-verification-schema.ts +235 -0
  38. package/src/pipeline/claim-verification.ts +487 -0
  39. package/src/pipeline/claim-verifier.ts +474 -0
  40. package/src/pipeline/types.ts +25 -0
  41. package/src/sdk/client.ts +35 -2
  42. package/src/serve/public/components/AskVerificationPanel.tsx +189 -0
  43. package/src/serve/public/globals.built.css +1 -1
  44. package/src/serve/public/pages/Ask.tsx +42 -4
  45. package/src/serve/routes/api.ts +149 -3
@@ -18,6 +18,39 @@ type LlamaModel = Awaited<
18
18
  Awaited<ReturnType<typeof import("node-llama-cpp").getLlama>>["loadModel"]
19
19
  >
20
20
  >;
21
+ type Llama = Awaited<ReturnType<typeof import("node-llama-cpp").getLlama>>;
22
+ type JsonGrammarSchema = Parameters<Llama["createGrammarForJsonSchema"]>[0];
23
+
24
+ export interface JsonSchemaGrammarLike {
25
+ parse(response: string): unknown;
26
+ }
27
+
28
+ export interface StructuredPromptSession {
29
+ prompt(
30
+ prompt: string,
31
+ options: {
32
+ temperature: number;
33
+ seed: number;
34
+ maxTokens: number;
35
+ grammar?: JsonSchemaGrammarLike;
36
+ }
37
+ ): Promise<string>;
38
+ }
39
+
40
+ export const promptWithJsonSchemaGrammar = async (
41
+ session: StructuredPromptSession,
42
+ prompt: string,
43
+ options: {
44
+ temperature: number;
45
+ seed: number;
46
+ maxTokens: number;
47
+ },
48
+ grammar?: JsonSchemaGrammarLike
49
+ ): Promise<string> => {
50
+ const response = await session.prompt(prompt, { ...options, grammar });
51
+ grammar?.parse(response);
52
+ return response;
53
+ };
21
54
 
22
55
  // ─────────────────────────────────────────────────────────────────────────────
23
56
  // Default Parameters (for determinism)
@@ -34,6 +67,7 @@ const DEFAULT_MAX_TOKENS = 256;
34
67
  export class NodeLlamaCppGeneration implements GenerationPort {
35
68
  private readonly manager: ModelManager;
36
69
  readonly modelUri: string;
70
+ readonly structuredOutput = "json_schema" as const;
37
71
  private readonly modelPath: string;
38
72
 
39
73
  constructor(manager: ModelManager, modelUri: string, modelPath: string) {
@@ -56,11 +90,16 @@ export class NodeLlamaCppGeneration implements GenerationPort {
56
90
  }
57
91
 
58
92
  const llamaModel = model.value.model as LlamaModel;
59
- const context = await llamaModel.createContext(
60
- params?.contextSize ? { contextSize: params.contextSize } : undefined
61
- );
62
-
93
+ let context: Awaited<ReturnType<LlamaModel["createContext"]>> | null = null;
63
94
  try {
95
+ const grammar = params?.jsonSchema
96
+ ? await (
97
+ await this.manager.getLlama()
98
+ ).createGrammarForJsonSchema(params.jsonSchema as JsonGrammarSchema)
99
+ : undefined;
100
+ context = await llamaModel.createContext(
101
+ params?.contextSize ? { contextSize: params.contextSize } : undefined
102
+ );
64
103
  // Import LlamaChatSession dynamically
65
104
  const { LlamaChatSession } = await import("node-llama-cpp");
66
105
  const session = new LlamaChatSession({
@@ -68,17 +107,22 @@ export class NodeLlamaCppGeneration implements GenerationPort {
68
107
  });
69
108
 
70
109
  // Note: stop sequences not yet supported - requires stopOnTrigger API
71
- const response = await session.prompt(prompt, {
72
- temperature: params?.temperature ?? DEFAULT_TEMPERATURE,
73
- seed: params?.seed ?? DEFAULT_SEED,
74
- maxTokens: params?.maxTokens ?? DEFAULT_MAX_TOKENS,
75
- });
110
+ const response = await promptWithJsonSchemaGrammar(
111
+ session as StructuredPromptSession,
112
+ prompt,
113
+ {
114
+ temperature: params?.temperature ?? DEFAULT_TEMPERATURE,
115
+ seed: params?.seed ?? DEFAULT_SEED,
116
+ maxTokens: params?.maxTokens ?? DEFAULT_MAX_TOKENS,
117
+ },
118
+ grammar
119
+ );
76
120
 
77
121
  return { ok: true, value: response };
78
122
  } catch (e) {
79
123
  return { ok: false, error: inferenceFailedError(this.modelUri, e) };
80
124
  } finally {
81
- await context.dispose().catch(() => {
125
+ await context?.dispose().catch(() => {
82
126
  // Ignore disposal errors
83
127
  });
84
128
  }
package/src/llm/types.ts CHANGED
@@ -58,8 +58,12 @@ export interface GenParams {
58
58
  contextSize?: number;
59
59
  /** Stop sequences */
60
60
  stop?: string[];
61
+ /** Closed JSON Schema enforced by a capable generation backend. */
62
+ jsonSchema?: Readonly<Record<string, unknown>>;
61
63
  }
62
64
 
65
+ export type StructuredOutputCapability = "json_schema" | "none";
66
+
63
67
  // ─────────────────────────────────────────────────────────────────────────────
64
68
  // Rerank Types
65
69
  // ─────────────────────────────────────────────────────────────────────────────
@@ -90,6 +94,8 @@ export interface EmbeddingPort {
90
94
 
91
95
  export interface GenerationPort {
92
96
  readonly modelUri: string;
97
+ /** Undefined is treated as unsupported for backwards-compatible ports. */
98
+ readonly structuredOutput?: StructuredOutputCapability;
93
99
  generate(prompt: string, params?: GenParams): Promise<LlmResult<string>>;
94
100
  dispose(): Promise<void>;
95
101
  }
@@ -0,0 +1,228 @@
1
+ /** MCP verified Ask tool over the shared closed-evidence application boundary. */
2
+
3
+ import { z } from "zod";
4
+
5
+ import type { RetrievalTraceSession } from "../../core/retrieval-trace-session";
6
+ import type { QueryModeInput } from "../../pipeline/types";
7
+ import type { ToolContext } from "../server";
8
+ import type { ToolResult } from "./index";
9
+
10
+ import { buildVerifiedAsk } from "../../app/verified-ask";
11
+ import {
12
+ finishRetrievalTraceAfterError,
13
+ retrievalTraceFilters,
14
+ startRetrievalTraceRequest,
15
+ } from "../../core/retrieval-trace-request";
16
+ import { attachRetrievalTraceMetadata } from "../../core/retrieval-trace-session";
17
+ import { normalizeStructuredQueryInput } from "../../core/structured-query";
18
+ import { resolveModelUri } from "../../llm/registry";
19
+ import { answerTraceTerminalStatus } from "../../pipeline/answer";
20
+ import { createMcpModelPorts, type McpModelPortFactory } from "./context";
21
+ import { normalizeTagFilters, runTool } from "./index";
22
+
23
+ const queryModeSchema = z
24
+ .object({
25
+ mode: z.enum(["term", "intent", "hyde"]),
26
+ text: z.string().trim().min(1),
27
+ })
28
+ .strict();
29
+
30
+ export const askInputSchema = z
31
+ .object({
32
+ query: z.string().trim().min(1),
33
+ verify: z.literal(true),
34
+ collection: z.string().optional(),
35
+ limit: z.number().int().min(1).max(100).default(5),
36
+ minScore: z.number().min(0).max(1).optional(),
37
+ lang: z.string().optional(),
38
+ intent: z.string().optional(),
39
+ candidateLimit: z.number().int().min(1).max(100).optional(),
40
+ exclude: z.array(z.string()).optional(),
41
+ queryModes: z.array(queryModeSchema).optional(),
42
+ tagsAll: z.array(z.string()).optional(),
43
+ tagsAny: z.array(z.string()).optional(),
44
+ since: z.string().optional(),
45
+ until: z.string().optional(),
46
+ categories: z.array(z.string()).optional(),
47
+ author: z.string().optional(),
48
+ graph: z.boolean().optional(),
49
+ noGraph: z.boolean().optional(),
50
+ noRerank: z.boolean().optional(),
51
+ maxAnswerTokens: z.number().int().positive().optional(),
52
+ contextBudgetTokens: z.number().int().positive().optional(),
53
+ contextBudgetBytes: z.number().int().positive().optional(),
54
+ })
55
+ .strict();
56
+
57
+ type AskInput = z.infer<typeof askInputSchema>;
58
+
59
+ export interface HandleAskDependencies {
60
+ modelPortFactory?: McpModelPortFactory;
61
+ }
62
+
63
+ const exactSpan = (uri: string, startLine: number, endLine: number): string =>
64
+ `${uri}:L${startLine}${startLine === endLine ? "" : `-L${endLine}`}`;
65
+
66
+ export const formatVerifiedAskReadable = (
67
+ result: Awaited<ReturnType<typeof buildVerifiedAsk>>
68
+ ): string => {
69
+ const numbers = new Map(
70
+ (result.citations ?? []).flatMap((citation, index) =>
71
+ citation.evidenceId ? [[citation.evidenceId, index + 1] as const] : []
72
+ )
73
+ );
74
+ const answer = (result.answer ?? "").replace(
75
+ /\[evidence:([a-f0-9]{64})\]/g,
76
+ (_marker, evidenceId: string) => {
77
+ const number = numbers.get(evidenceId);
78
+ return number === undefined ? "" : `[${number}]`;
79
+ }
80
+ );
81
+ const claims = result.verification?.claims;
82
+ const lines = [
83
+ answer || "No verified answer.",
84
+ "",
85
+ `Verification: ${claims?.answerStatus ?? "unavailable"}`,
86
+ ];
87
+ if (claims?.abstentionReason) {
88
+ lines.push(`Reason: ${claims.abstentionReason}`);
89
+ }
90
+ if (claims) {
91
+ lines.push(
92
+ `Claims: ${claims.coverage.supportedClaims}/${claims.coverage.totalClaims} supported`
93
+ );
94
+ }
95
+ const semantic = result.verification?.semantic;
96
+ if (semantic) {
97
+ lines.push(`Semantic verifier: ${semantic.status} (${semantic.reason})`);
98
+ }
99
+ for (const claim of claims?.claims ?? []) {
100
+ lines.push(`- ${claim.status}: ${claim.text}`);
101
+ for (const evidence of claim.evidence) {
102
+ lines.push(
103
+ ` ${exactSpan(evidence.uri, evidence.startLine, evidence.endLine)} (${evidence.evidenceId})`
104
+ );
105
+ }
106
+ }
107
+ for (const gap of result.verification?.capsule.coverage.gaps ?? []) {
108
+ lines.push(`Gap: ${gap.facet} (${gap.code})`);
109
+ }
110
+ for (const facet of result.verification?.capsule.coverage.unresolvedFacets ??
111
+ []) {
112
+ lines.push(`Unresolved facet: ${facet}`);
113
+ }
114
+ for (const [name, state] of Object.entries(
115
+ result.verification?.capsule.retrieval.capabilityStates ?? {}
116
+ )) {
117
+ if (state.requested && state.outcome !== "used") {
118
+ lines.push(
119
+ `Capability: ${name} ${state.outcome}${state.fallbackReasons.length > 0 ? ` (${state.fallbackReasons.join(", ")})` : ""}`
120
+ );
121
+ }
122
+ }
123
+ for (const [index, citation] of (result.citations ?? []).entries()) {
124
+ const span =
125
+ citation.startLine === undefined || citation.endLine === undefined
126
+ ? citation.uri
127
+ : exactSpan(citation.uri, citation.startLine, citation.endLine);
128
+ lines.push(`[${index + 1}] ${span} (${citation.evidenceId ?? "unknown"})`);
129
+ }
130
+ return lines.join("\n");
131
+ };
132
+
133
+ export const handleAsk = (
134
+ args: AskInput,
135
+ context: ToolContext,
136
+ dependencies: HandleAskDependencies = {}
137
+ ): Promise<ToolResult> =>
138
+ runTool(
139
+ context,
140
+ "gno_ask",
141
+ async () => {
142
+ if (
143
+ args.collection &&
144
+ !context.collections.some(
145
+ (collection) => collection.name === args.collection
146
+ )
147
+ ) {
148
+ throw new Error(`Collection not found: ${args.collection}`);
149
+ }
150
+ const normalized = normalizeStructuredQueryInput(
151
+ args.query,
152
+ (args.queryModes ?? []) as QueryModeInput[]
153
+ );
154
+ if (!normalized.ok) throw new Error(normalized.error.message);
155
+ const query = normalized.value.query;
156
+ const options = {
157
+ ...args,
158
+ queryModes:
159
+ normalized.value.queryModes.length > 0
160
+ ? normalized.value.queryModes
161
+ : undefined,
162
+ tagsAll: normalizeTagFilters(args.tagsAll),
163
+ tagsAny: normalizeTagFilters(args.tagsAny),
164
+ };
165
+ let traceSession: RetrievalTraceSession | undefined;
166
+ let modelPorts: Awaited<ReturnType<typeof createMcpModelPorts>> | null =
167
+ null;
168
+ try {
169
+ const started = await startRetrievalTraceRequest({
170
+ store: context.store,
171
+ config: context.config,
172
+ query,
173
+ filters: retrievalTraceFilters(options),
174
+ pipeline: "ask",
175
+ indexName: context.indexName,
176
+ modelUris: [
177
+ resolveModelUri(
178
+ context.config,
179
+ "embed",
180
+ undefined,
181
+ args.collection
182
+ ),
183
+ resolveModelUri(
184
+ context.config,
185
+ "rerank",
186
+ undefined,
187
+ args.collection
188
+ ),
189
+ resolveModelUri(context.config, "gen", undefined, args.collection),
190
+ ],
191
+ });
192
+ if (!started.ok) throw new Error(started.error.message);
193
+ traceSession = started.value ?? undefined;
194
+ modelPorts = await createMcpModelPorts(
195
+ context,
196
+ args.collection,
197
+ dependencies.modelPortFactory,
198
+ { generation: true }
199
+ );
200
+ if (!modelPorts.genPort) {
201
+ throw new Error(
202
+ "Answer generation requested but no generation model is available"
203
+ );
204
+ }
205
+ const result = await buildVerifiedAsk(query, options, {
206
+ store: context.store,
207
+ config: context.config,
208
+ indexName: context.indexName,
209
+ vectorIndex: modelPorts.vectorIndex,
210
+ embedPort: modelPorts.embedPort,
211
+ rerankPort: modelPorts.rerankPort,
212
+ genPort: modelPorts.genPort,
213
+ traceSession,
214
+ });
215
+ const finished = await traceSession?.finish(
216
+ answerTraceTerminalStatus(result.citations)
217
+ );
218
+ if (finished && !finished.ok) throw new Error(finished.error.message);
219
+ return attachRetrievalTraceMetadata(result, traceSession);
220
+ } catch (error) {
221
+ await finishRetrievalTraceAfterError(traceSession, error);
222
+ throw error;
223
+ } finally {
224
+ await modelPorts?.dispose();
225
+ }
226
+ },
227
+ formatVerifiedAskReadable
228
+ );
@@ -2,7 +2,11 @@
2
2
 
3
3
  import type { RetrievalTraceSession } from "../../core/retrieval-trace-session";
4
4
  import type { ModelLease } from "../../llm/nodeLlamaCpp/lifecycle";
5
- import type { EmbeddingPort, RerankPort } from "../../llm/types";
5
+ import type {
6
+ EmbeddingPort,
7
+ GenerationPort,
8
+ RerankPort,
9
+ } from "../../llm/types";
6
10
  import type { VectorIndexPort } from "../../store/vector";
7
11
  import type { ToolContext } from "../server";
8
12
  import type { ToolResult } from "./index";
@@ -85,13 +89,15 @@ const runContextTool = async (
85
89
 
86
90
  interface McpModelPorts {
87
91
  embedPort: EmbeddingPort | null;
92
+ genPort: GenerationPort | null;
88
93
  rerankPort: RerankPort | null;
89
94
  vectorIndex: VectorIndexPort | null;
90
95
  dispose(): Promise<void>;
91
96
  }
92
97
 
93
- interface McpModelPortFactory {
98
+ export interface McpModelPortFactory {
94
99
  createEmbeddingPort: LlmAdapter["createEmbeddingPort"];
100
+ createGenerationPort?: LlmAdapter["createGenerationPort"];
95
101
  createRerankPort: LlmAdapter["createRerankPort"];
96
102
  acquireModelLease?: LlmAdapter["acquireModelLease"];
97
103
  }
@@ -109,7 +115,8 @@ export const disposeContextModelOwners = async (
109
115
  export const createMcpModelPorts = async (
110
116
  context: ToolContext,
111
117
  collection?: string,
112
- factoryOverride?: McpModelPortFactory
118
+ factoryOverride?: McpModelPortFactory,
119
+ options: { generation?: boolean } = {}
113
120
  ): Promise<McpModelPorts> => {
114
121
  const llm = new LlmAdapter(context.config);
115
122
  const factory = factoryOverride ?? llm;
@@ -125,6 +132,7 @@ export const createMcpModelPorts = async (
125
132
  let embedPort: EmbeddingPort | null = null;
126
133
  let ownedEmbedPort: EmbeddingPort | null = null;
127
134
  let rerankPort: RerankPort | null = null;
135
+ let genPort: GenerationPort | null = null;
128
136
  let vectorIndex: VectorIndexPort | null = null;
129
137
  try {
130
138
  const embedResult = await factory.createEmbeddingPort(embedUri, {
@@ -152,14 +160,26 @@ export const createMcpModelPorts = async (
152
160
  }
153
161
  );
154
162
  if (rerankResult.ok) rerankPort = rerankResult.value;
163
+ if (options.generation && factory.createGenerationPort) {
164
+ const genResult = await factory.createGenerationPort(
165
+ resolveModelUri(context.config, "gen", undefined, collection),
166
+ {
167
+ policy,
168
+ onProgress: (value) => progress("gen", value),
169
+ }
170
+ );
171
+ if (genResult.ok) genPort = genResult.value;
172
+ }
155
173
  return {
156
174
  embedPort,
175
+ genPort,
157
176
  rerankPort,
158
177
  vectorIndex,
159
178
  async dispose() {
160
179
  await disposeContextModelOwners(
161
- [ownedEmbedPort, rerankPort].filter(
162
- (port): port is EmbeddingPort | RerankPort => port !== null
180
+ [ownedEmbedPort, rerankPort, genPort].filter(
181
+ (port): port is EmbeddingPort | RerankPort | GenerationPort =>
182
+ port !== null
163
183
  ),
164
184
  lease
165
185
  );
@@ -167,8 +187,9 @@ export const createMcpModelPorts = async (
167
187
  };
168
188
  } catch (error) {
169
189
  await disposeContextModelOwners(
170
- [ownedEmbedPort, rerankPort].filter(
171
- (port): port is EmbeddingPort | RerankPort => port !== null
190
+ [ownedEmbedPort, rerankPort, genPort].filter(
191
+ (port): port is EmbeddingPort | RerankPort | GenerationPort =>
192
+ port !== null
172
193
  ),
173
194
  lease
174
195
  );
@@ -19,6 +19,7 @@ import { NOTE_PRESETS, type NotePresetId } from "../../core/note-presets";
19
19
  import { RETRIEVAL_TRACE_METADATA } from "../../core/retrieval-trace-session";
20
20
  import { normalizeTag } from "../../core/tags";
21
21
  import { handleAddCollection } from "./add-collection";
22
+ import { askInputSchema, handleAsk } from "./ask";
22
23
  import { handleCapture } from "./capture";
23
24
  import { handleClearCollectionEmbeddings } from "./clear-collection-embeddings";
24
25
  import { handleContext, handleContextVerify } from "./context";
@@ -96,6 +97,7 @@ export const MCP_TOOL_DESCRIPTIONS = {
96
97
  "Compile one deterministic, budgeted, extractive evidence Capsule with exact line spans, coverage gaps, omissions, provenance, and verification fingerprints. Raw search/get tools remain available for manual retrieval.",
97
98
  contextVerify:
98
99
  "Verify a saved Context Capsule without rebuilding or mutating it. Reports unchanged, stale, missing, reranked, and fingerprint drift states against the active index.",
100
+ ask: "Generate one answer from a deterministic Context Capsule, verify every substantive claim against exact retained spans, and abstain unless support coverage is complete. Read-only; returns the Capsule, freshness receipt, claim verdicts, gaps, and evidence IDs.",
99
101
  } as const;
100
102
 
101
103
  /** Tool names whose execution mutates disk, config, or index state. */
@@ -978,6 +980,13 @@ export function registerTools(server: McpServer, ctx: ToolContext): void {
978
980
  (args) => handleContextVerify(args, ctx)
979
981
  );
980
982
 
983
+ server.tool(
984
+ "gno_ask",
985
+ MCP_TOOL_DESCRIPTIONS.ask,
986
+ askInputSchema.shape,
987
+ (args) => handleAsk(args, ctx)
988
+ );
989
+
981
990
  server.tool(
982
991
  "gno_search",
983
992
  MCP_TOOL_DESCRIPTIONS.search,
@@ -0,0 +1,235 @@
1
+ import { z } from "zod";
2
+
3
+ export const CLAIM_VERIFICATION_SCHEMA_VERSION = "1.0" as const;
4
+ export const CLAIM_COORDINATE_SPACE = "utf16_code_units" as const;
5
+
6
+ const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/);
7
+
8
+ export const semanticClaimJudgmentSchema = z
9
+ .object({
10
+ claimId: sha256Schema,
11
+ verdict: z.enum(["supported", "contradicted"]),
12
+ confidence: z.number().min(0).max(1),
13
+ evidenceIds: z.array(sha256Schema).min(1).max(256),
14
+ rationaleCode: z.enum(["semantic_entailment", "semantic_contradiction"]),
15
+ verifierFingerprint: sha256Schema,
16
+ })
17
+ .strict()
18
+ .superRefine((value, context) => {
19
+ const expected =
20
+ value.verdict === "supported"
21
+ ? "semantic_entailment"
22
+ : "semantic_contradiction";
23
+ if (
24
+ value.rationaleCode !== expected ||
25
+ new Set(value.evidenceIds).size !== value.evidenceIds.length
26
+ ) {
27
+ context.addIssue({
28
+ code: "custom",
29
+ message: "semantic judgment is incoherent",
30
+ });
31
+ }
32
+ });
33
+
34
+ export type SemanticClaimJudgment = z.infer<typeof semanticClaimJudgmentSchema>;
35
+ const evidenceReferenceSchema = z
36
+ .object({
37
+ evidenceId: sha256Schema,
38
+ uri: z.string().min(1).max(2048).startsWith("gno://"),
39
+ startLine: z.number().int().positive(),
40
+ endLine: z.number().int().positive(),
41
+ text: z.string().min(1),
42
+ sourceHash: sha256Schema,
43
+ mirrorHash: sha256Schema,
44
+ passageHash: sha256Schema,
45
+ })
46
+ .strict()
47
+ .refine((value) => value.endLine >= value.startLine, {
48
+ message: "evidence line range is reversed",
49
+ path: ["endLine"],
50
+ });
51
+
52
+ const rejectedCitationSchema = z
53
+ .object({
54
+ marker: z.string().min(1),
55
+ start: z.number().int().nonnegative(),
56
+ end: z.number().int().positive(),
57
+ evidenceId: sha256Schema.nullable(),
58
+ reason: z.enum([
59
+ "malformed_citation",
60
+ "out_of_capsule",
61
+ "freshness_unavailable",
62
+ "freshness_receipt_invalid",
63
+ "freshness_receipt_mismatch",
64
+ "evidence_stale",
65
+ "evidence_missing",
66
+ "orphan_citation",
67
+ ]),
68
+ })
69
+ .strict()
70
+ .refine((value) => value.end > value.start, {
71
+ message: "citation span must be non-empty",
72
+ path: ["end"],
73
+ });
74
+
75
+ const verifiedClaimSchema = z
76
+ .object({
77
+ claimId: sha256Schema,
78
+ text: z.string().min(1),
79
+ start: z.number().int().nonnegative(),
80
+ end: z.number().int().positive(),
81
+ status: z.enum(["supported", "contradicted", "insufficient", "uncertain"]),
82
+ confidence: z.number().min(0).max(1).nullable(),
83
+ rationaleCode: z.enum([
84
+ "semantic_entailment",
85
+ "semantic_contradiction",
86
+ "no_valid_evidence",
87
+ "semantic_judgment_unavailable",
88
+ ]),
89
+ verifierFingerprint: sha256Schema.nullable(),
90
+ evidence: z.array(evidenceReferenceSchema).max(256),
91
+ rejectedCitations: z.array(rejectedCitationSchema).max(256),
92
+ })
93
+ .strict()
94
+ .superRefine((value, context) => {
95
+ if (value.end <= value.start) {
96
+ context.addIssue({
97
+ code: "custom",
98
+ message: "claim span must be non-empty",
99
+ path: ["end"],
100
+ });
101
+ }
102
+ const semantic =
103
+ value.status === "supported" || value.status === "contradicted";
104
+ const expectedRationale = {
105
+ supported: "semantic_entailment",
106
+ contradicted: "semantic_contradiction",
107
+ insufficient: "no_valid_evidence",
108
+ uncertain: "semantic_judgment_unavailable",
109
+ }[value.status];
110
+ const evidenceCountValid =
111
+ value.status === "insufficient"
112
+ ? value.evidence.length === 0
113
+ : value.evidence.length > 0;
114
+ if (
115
+ value.rationaleCode !== expectedRationale ||
116
+ !evidenceCountValid ||
117
+ (semantic &&
118
+ (value.confidence === null || value.verifierFingerprint === null)) ||
119
+ (!semantic &&
120
+ (value.confidence !== null || value.verifierFingerprint !== null))
121
+ ) {
122
+ context.addIssue({
123
+ code: "custom",
124
+ message: "claim status fields are incoherent",
125
+ });
126
+ }
127
+ const ids = value.evidence.map((item) => item.evidenceId);
128
+ if (new Set(ids).size !== ids.length) {
129
+ context.addIssue({
130
+ code: "custom",
131
+ message: "claim evidence must be unique",
132
+ path: ["evidence"],
133
+ });
134
+ }
135
+ });
136
+
137
+ const coverageSchema = z
138
+ .object({
139
+ totalClaims: z.number().int().nonnegative(),
140
+ supportedClaims: z.number().int().nonnegative(),
141
+ contradictedClaims: z.number().int().nonnegative(),
142
+ insufficientClaims: z.number().int().nonnegative(),
143
+ uncertainClaims: z.number().int().nonnegative(),
144
+ supportedRatio: z.number().min(0).max(1),
145
+ })
146
+ .strict();
147
+
148
+ export const claimVerificationResultSchema = z
149
+ .object({
150
+ schemaVersion: z.literal(CLAIM_VERIFICATION_SCHEMA_VERSION),
151
+ coordinateSpace: z.literal(CLAIM_COORDINATE_SPACE),
152
+ capsuleId: sha256Schema,
153
+ answerHash: sha256Schema,
154
+ coverageThreshold: z.literal(1),
155
+ claims: z.array(verifiedClaimSchema).max(256),
156
+ rejectedCitations: z.array(rejectedCitationSchema).max(256),
157
+ coverage: coverageSchema,
158
+ answerStatus: z.enum(["verified", "abstained"]),
159
+ abstained: z.boolean(),
160
+ abstentionReason: z
161
+ .enum([
162
+ "contradiction_detected",
163
+ "coverage_below_threshold",
164
+ "no_substantive_claims",
165
+ "citation_hygiene_failed",
166
+ ])
167
+ .nullable(),
168
+ abstentionText: z.string().min(1).nullable(),
169
+ })
170
+ .strict()
171
+ .superRefine((value, context) => {
172
+ const counts = {
173
+ supportedClaims: value.claims.filter(
174
+ (claim) => claim.status === "supported"
175
+ ).length,
176
+ contradictedClaims: value.claims.filter(
177
+ (claim) => claim.status === "contradicted"
178
+ ).length,
179
+ insufficientClaims: value.claims.filter(
180
+ (claim) => claim.status === "insufficient"
181
+ ).length,
182
+ uncertainClaims: value.claims.filter(
183
+ (claim) => claim.status === "uncertain"
184
+ ).length,
185
+ };
186
+ const supportedRatio =
187
+ value.claims.length === 0
188
+ ? 0
189
+ : counts.supportedClaims / value.claims.length;
190
+ const expectedReason =
191
+ value.claims.length === 0
192
+ ? "no_substantive_claims"
193
+ : counts.contradictedClaims > 0
194
+ ? "contradiction_detected"
195
+ : value.rejectedCitations.length > 0 ||
196
+ value.claims.some((claim) => claim.rejectedCitations.length > 0)
197
+ ? "citation_hygiene_failed"
198
+ : supportedRatio < 1
199
+ ? "coverage_below_threshold"
200
+ : null;
201
+ const coverageValid =
202
+ value.coverage.totalClaims === value.claims.length &&
203
+ Object.entries(counts).every(
204
+ ([key, count]) => value.coverage[key as keyof typeof counts] === count
205
+ ) &&
206
+ value.coverage.supportedRatio === supportedRatio;
207
+ const abstentionValid =
208
+ value.abstentionReason === expectedReason &&
209
+ value.abstained === (expectedReason !== null) &&
210
+ value.answerStatus ===
211
+ (expectedReason === null ? "verified" : "abstained") &&
212
+ (expectedReason === null
213
+ ? value.abstentionText === null
214
+ : value.abstentionText !== null);
215
+ const spansValid = value.claims.every(
216
+ (claim, index) =>
217
+ index === 0 || claim.start >= (value.claims[index - 1]?.end ?? 0)
218
+ );
219
+ const claimIds = value.claims.map((claim) => claim.claimId);
220
+ if (
221
+ !coverageValid ||
222
+ !abstentionValid ||
223
+ !spansValid ||
224
+ new Set(claimIds).size !== claimIds.length
225
+ ) {
226
+ context.addIssue({
227
+ code: "custom",
228
+ message: "claim verification aggregate is incoherent",
229
+ });
230
+ }
231
+ });
232
+
233
+ export type ClaimVerificationResult = z.infer<
234
+ typeof claimVerificationResultSchema
235
+ >;