@gmickel/gno 1.16.0 → 1.17.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 (46) hide show
  1. package/README.md +20 -16
  2. package/assets/skill/SKILL.md +8 -5
  3. package/package.json +1 -1
  4. package/src/app/context-agent-projection.ts +303 -0
  5. package/src/app/context-format.ts +249 -0
  6. package/src/app/context-runtime-contract.ts +325 -0
  7. package/src/app/context-runtime-input.ts +362 -0
  8. package/src/app/context-runtime-types.ts +65 -0
  9. package/src/app/context-runtime.ts +170 -0
  10. package/src/app/context-surface.ts +145 -0
  11. package/src/cli/commands/context-build.ts +149 -0
  12. package/src/cli/commands/context-verify.ts +90 -0
  13. package/src/cli/options.ts +4 -0
  14. package/src/cli/program.ts +178 -0
  15. package/src/core/context-budget.ts +461 -0
  16. package/src/core/context-capsule-index-schema.ts +15 -0
  17. package/src/core/context-capsule-retrieval-schema.ts +81 -0
  18. package/src/core/context-capsule-schema.ts +473 -0
  19. package/src/core/context-capsule-validation.ts +416 -0
  20. package/src/core/context-capsule-verification.ts +218 -0
  21. package/src/core/context-capsule.ts +439 -0
  22. package/src/core/context-compiler.ts +513 -0
  23. package/src/core/context-evidence-metadata.ts +33 -0
  24. package/src/core/context-evidence.ts +495 -0
  25. package/src/core/context-facets.ts +163 -0
  26. package/src/core/context-guidance.ts +69 -0
  27. package/src/core/context-scope.ts +32 -0
  28. package/src/core/context-verifier-canonical.ts +90 -0
  29. package/src/core/context-verifier-input.ts +66 -0
  30. package/src/core/context-verifier.ts +447 -0
  31. package/src/core/sections.ts +63 -0
  32. package/src/mcp/server.ts +10 -4
  33. package/src/mcp/tools/context.ts +229 -0
  34. package/src/mcp/tools/index.ts +27 -0
  35. package/src/pipeline/chunk-lookup.ts +33 -0
  36. package/src/pipeline/hybrid.ts +79 -57
  37. package/src/pipeline/types.ts +14 -0
  38. package/src/sdk/client.ts +68 -6
  39. package/src/sdk/index.ts +21 -0
  40. package/src/sdk/types.ts +24 -0
  41. package/src/serve/background-runtime.ts +1 -0
  42. package/src/serve/context-capsule.ts +136 -0
  43. package/src/serve/context.ts +10 -1
  44. package/src/serve/routes/api.ts +2 -0
  45. package/src/serve/server.ts +23 -0
  46. package/src/store/sqlite/adapter.ts +38 -20
@@ -210,6 +210,42 @@ function parseCsvValues(raw: unknown): string[] | undefined {
210
210
  return values.length > 0 ? values : undefined;
211
211
  }
212
212
 
213
+ function parseContextInteger(
214
+ name: string,
215
+ value: unknown,
216
+ allowZero = false
217
+ ): number {
218
+ const raw = typeof value === "string" ? value : String(value);
219
+ if (!/^\d+$/.test(raw)) {
220
+ throw new CliError("VALIDATION", `--${name} must be an integer`, {
221
+ details: { contextCode: "invalid_budget" },
222
+ });
223
+ }
224
+ const parsed = Number(raw);
225
+ if (!Number.isSafeInteger(parsed) || (allowZero ? parsed < 0 : parsed < 1)) {
226
+ throw new CliError(
227
+ "VALIDATION",
228
+ `--${name} must be ${allowZero ? "non-negative" : "positive"}`,
229
+ { details: { contextCode: "invalid_budget" } }
230
+ );
231
+ }
232
+ return parsed;
233
+ }
234
+
235
+ function validateContextOutputPath(value: unknown): string | undefined {
236
+ if (value === undefined) return undefined;
237
+ if (typeof value !== "string" || !value.trim() || value === "-") {
238
+ throw new CliError(
239
+ "VALIDATION",
240
+ "--output requires an explicit file path",
241
+ {
242
+ details: { contextCode: "invalid_filter" },
243
+ }
244
+ );
245
+ }
246
+ return value;
247
+ }
248
+
213
249
  // ─────────────────────────────────────────────────────────────────────────────
214
250
  // Program Factory
215
251
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1624,6 +1660,148 @@ function wireManagementCommands(program: Command): void {
1624
1660
  await contextCheck(format as "terminal" | "json" | "md");
1625
1661
  });
1626
1662
 
1663
+ contextCmd
1664
+ .command("build <goal...>")
1665
+ .description("Build a deterministic evidence Capsule")
1666
+ .requiredOption("--budget <tokens>", "global token budget")
1667
+ .option("--bytes <bytes>", "global byte budget")
1668
+ .option("--query <query>", "retrieval query (defaults to goal)")
1669
+ .option(
1670
+ "--query-mode <mode:text>",
1671
+ "structured mode entry (repeatable): term:<text>, intent:<text>, or hyde:<text>",
1672
+ (value: string, previous: string[] = []) => [...previous, value],
1673
+ []
1674
+ )
1675
+ .option(
1676
+ "-c, --collection <name>",
1677
+ "collection filter (repeatable)",
1678
+ (value: string, previous: string[] = []) => [...previous, value],
1679
+ []
1680
+ )
1681
+ .option("--uri-prefix <uri>", "canonical GNO URI prefix")
1682
+ .option("--tags-all <tags>", "require ALL tags (comma-separated)")
1683
+ .option("--tags-any <tags>", "require ANY tag (comma-separated)")
1684
+ .option("--category <values>", "require category match (comma-separated)")
1685
+ .option("--author <text>", "filter by author")
1686
+ .option("--lang <code>", "language hint (BCP-47)")
1687
+ .option("--since <date>", "modified-at lower bound")
1688
+ .option("--until <date>", "modified-at upper bound")
1689
+ .option("--graph", "enable graph neighbor expansion")
1690
+ .option("--fast", "use lexical-first fast retrieval")
1691
+ .option("--thorough", "use a wider retrieval pool")
1692
+ .option("-n, --limit <num>", "maximum retrieved results")
1693
+ .option("-C, --candidate-limit <num>", "maximum rerank candidates")
1694
+ .option("--safety-margin <tokens>", "reserved token margin", "0")
1695
+ .option("--safety-margin-bytes <bytes>", "reserved byte margin", "0")
1696
+ .option("--output <path>", "write only to this explicit file")
1697
+ .option("--json", "canonical JSON output")
1698
+ .option("--md", "readable Markdown output")
1699
+ .action(async (goalParts: string[], cmdOpts: Record<string, unknown>) => {
1700
+ const format = getFormat(cmdOpts);
1701
+ assertFormatSupported(CMD.contextBuild, format);
1702
+ const outputPath = validateContextOutputPath(cmdOpts.output);
1703
+ if (cmdOpts.fast && cmdOpts.thorough) {
1704
+ throw new CliError("VALIDATION", "Choose either --fast or --thorough");
1705
+ }
1706
+ const globals = getGlobals();
1707
+ const { contextBuild } = await import("./commands/context-build");
1708
+ let queryModes: import("../pipeline/types").QueryModeInput[] | undefined;
1709
+ if (Array.isArray(cmdOpts.queryMode) && cmdOpts.queryMode.length > 0) {
1710
+ const { parseQueryModeSpecs } = await import("../pipeline/query-modes");
1711
+ const parsed = parseQueryModeSpecs(cmdOpts.queryMode as string[]);
1712
+ if (!parsed.ok) {
1713
+ throw new CliError("VALIDATION", parsed.error.message, {
1714
+ details: { contextCode: "invalid_filter" },
1715
+ });
1716
+ }
1717
+ queryModes = parsed.value;
1718
+ }
1719
+ const output = await contextBuild(goalParts.join(" "), {
1720
+ configPath: globals.config,
1721
+ indexName: globals.index,
1722
+ budgetTokens: parseContextInteger("budget", cmdOpts.budget),
1723
+ budgetBytes:
1724
+ cmdOpts.bytes === undefined
1725
+ ? undefined
1726
+ : parseContextInteger("bytes", cmdOpts.bytes),
1727
+ safetyMarginTokens: parseContextInteger(
1728
+ "safety-margin",
1729
+ cmdOpts.safetyMargin ?? "0",
1730
+ true
1731
+ ),
1732
+ safetyMarginBytes: parseContextInteger(
1733
+ "safety-margin-bytes",
1734
+ cmdOpts.safetyMarginBytes ?? "0",
1735
+ true
1736
+ ),
1737
+ query: cmdOpts.query as string | undefined,
1738
+ queryModes,
1739
+ collections: cmdOpts.collection as string[],
1740
+ uriPrefix: cmdOpts.uriPrefix as string | undefined,
1741
+ tagsAll: parseCsvValues(cmdOpts.tagsAll),
1742
+ tagsAny: parseCsvValues(cmdOpts.tagsAny),
1743
+ categories: parseCsvValues(cmdOpts.category),
1744
+ author: cmdOpts.author as string | undefined,
1745
+ lang: cmdOpts.lang as string | undefined,
1746
+ since: cmdOpts.since as string | undefined,
1747
+ until: cmdOpts.until as string | undefined,
1748
+ graph: Boolean(cmdOpts.graph),
1749
+ limit:
1750
+ cmdOpts.limit === undefined
1751
+ ? undefined
1752
+ : parseContextInteger("limit", cmdOpts.limit),
1753
+ candidateLimit:
1754
+ cmdOpts.candidateLimit === undefined
1755
+ ? undefined
1756
+ : parseContextInteger("candidate-limit", cmdOpts.candidateLimit),
1757
+ depthPolicy: cmdOpts.fast
1758
+ ? "fast"
1759
+ : cmdOpts.thorough
1760
+ ? "thorough"
1761
+ : "balanced",
1762
+ format: format === "json" ? "json" : "md",
1763
+ });
1764
+ if (outputPath !== undefined) {
1765
+ await Bun.write(outputPath, output);
1766
+ return;
1767
+ }
1768
+ await writeOutput(output, format === "json" ? "json" : "md");
1769
+ });
1770
+
1771
+ contextCmd
1772
+ .command("verify <file>")
1773
+ .description("Verify a saved Context Capsule without rebuilding it")
1774
+ .option("--output <path>", "write only to this explicit file")
1775
+ .option("--json", "canonical JSON receipt")
1776
+ .option("--md", "readable Markdown receipt")
1777
+ .action(
1778
+ async (
1779
+ file: string,
1780
+ cmdOpts: Record<string, unknown>,
1781
+ command: Command
1782
+ ) => {
1783
+ const format = getFormat(cmdOpts);
1784
+ assertFormatSupported(CMD.contextVerify, format);
1785
+ const outputPath = validateContextOutputPath(cmdOpts.output);
1786
+ const globals = getGlobals();
1787
+ const explicitIndexName =
1788
+ command.getOptionValueSourceWithGlobals("index") === "cli"
1789
+ ? globals.index
1790
+ : undefined;
1791
+ const { contextVerify } = await import("./commands/context-verify");
1792
+ const output = await contextVerify(file, {
1793
+ configPath: globals.config,
1794
+ indexName: explicitIndexName,
1795
+ format: format === "json" ? "json" : "md",
1796
+ });
1797
+ if (outputPath !== undefined) {
1798
+ await Bun.write(outputPath, output);
1799
+ return;
1800
+ }
1801
+ await writeOutput(output, format === "json" ? "json" : "md");
1802
+ }
1803
+ );
1804
+
1627
1805
  contextCmd
1628
1806
  .command("rm <uri>")
1629
1807
  .description("Remove context item")
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Deterministic evidence selection under one canonical payload budget.
3
+ *
4
+ * Candidate text is already materialized into its final extractive form. The
5
+ * caller-owned projector is the budget authority: it must build the complete
6
+ * canonical Capsule payload, including coverage, omissions, and guidance.
7
+ */
8
+
9
+ export const CONTEXT_OMISSION_REASONS = [
10
+ "duplicate",
11
+ "overlap",
12
+ "global_budget",
13
+ "redundant_coverage",
14
+ "document_share_cap",
15
+ "filtered_by_scope",
16
+ "invalid_coordinates",
17
+ ] as const;
18
+
19
+ export type ContextOmissionReason = (typeof CONTEXT_OMISSION_REASONS)[number];
20
+
21
+ export type ContextGapReason =
22
+ | "facet_not_found"
23
+ | "global_budget_exhausted"
24
+ | "filtered_by_scope";
25
+
26
+ export interface ContextCandidateReference {
27
+ candidateId: string;
28
+ uri: string;
29
+ docid: string;
30
+ startLine: number | null;
31
+ endLine: number | null;
32
+ passageHash: string | null;
33
+ sourceHash: string;
34
+ mirrorHash: string;
35
+ }
36
+
37
+ export interface MaterializedContextCandidate<
38
+ T = unknown,
39
+ > extends ContextCandidateReference {
40
+ startLine: number;
41
+ endLine: number;
42
+ passageHash: string;
43
+ text: string;
44
+ facets: string[];
45
+ retrievalRank: number;
46
+ value: T;
47
+ }
48
+
49
+ export interface ContextOmission extends ContextCandidateReference {
50
+ reason: ContextOmissionReason;
51
+ }
52
+
53
+ export interface ContextReasonCounts {
54
+ duplicate: number;
55
+ overlap: number;
56
+ global_budget: number;
57
+ redundant_coverage: number;
58
+ document_share_cap: number;
59
+ filtered_by_scope: number;
60
+ invalid_coordinates: number;
61
+ }
62
+
63
+ export interface ContextCoverageState {
64
+ coveredFacets: string[];
65
+ unresolvedFacets: string[];
66
+ gaps: Array<{ facet: string; code: ContextGapReason }>;
67
+ }
68
+
69
+ export interface ContextSelectionState<T = unknown> {
70
+ selected: MaterializedContextCandidate<T>[];
71
+ omissions: ContextOmission[];
72
+ reasonCounts: ContextReasonCounts;
73
+ coverage: ContextCoverageState;
74
+ }
75
+
76
+ export interface ContextCanonicalProjection<T = unknown> {
77
+ value: T;
78
+ usedBytes: number;
79
+ usedTokens: number;
80
+ }
81
+
82
+ export interface ContextBudgetLimits {
83
+ requestedBytes: number;
84
+ requestedTokens: number;
85
+ safetyMarginBytes: number;
86
+ safetyMarginTokens: number;
87
+ /** Defaults to 3/5 of the spendable byte budget. */
88
+ documentShareNumerator?: number;
89
+ documentShareDenominator?: number;
90
+ }
91
+
92
+ export interface ContextSelectionOptions<T, P> {
93
+ candidates: MaterializedContextCandidate<T>[];
94
+ requestedFacets: string[];
95
+ initialOmissions?: ContextOmission[];
96
+ filteredFacetMatches?: ReadonlySet<string>;
97
+ limits: ContextBudgetLimits;
98
+ projectCanonical: (
99
+ state: ContextSelectionState<T>
100
+ ) => ContextCanonicalProjection<P> | null;
101
+ }
102
+
103
+ export interface ContextSelectionResult<T, P> extends ContextSelectionState<T> {
104
+ projection: ContextCanonicalProjection<P> | null;
105
+ }
106
+
107
+ const compareCodeUnits = (left: string, right: string): number => {
108
+ if (left < right) return -1;
109
+ if (left > right) return 1;
110
+ return 0;
111
+ };
112
+
113
+ const compareReferences = (
114
+ left: ContextCandidateReference,
115
+ right: ContextCandidateReference
116
+ ): number =>
117
+ compareCodeUnits(left.uri, right.uri) ||
118
+ (left.startLine ?? 0) - (right.startLine ?? 0) ||
119
+ (left.endLine ?? 0) - (right.endLine ?? 0) ||
120
+ compareCodeUnits(left.sourceHash, right.sourceHash) ||
121
+ compareCodeUnits(left.candidateId, right.candidateId);
122
+
123
+ const compareOmissions = (
124
+ left: ContextOmission,
125
+ right: ContextOmission
126
+ ): number =>
127
+ compareCodeUnits(left.reason, right.reason) || compareReferences(left, right);
128
+
129
+ const emptyReasonCounts = (): ContextReasonCounts => ({
130
+ duplicate: 0,
131
+ overlap: 0,
132
+ global_budget: 0,
133
+ redundant_coverage: 0,
134
+ document_share_cap: 0,
135
+ filtered_by_scope: 0,
136
+ invalid_coordinates: 0,
137
+ });
138
+
139
+ const dedupeOmissions = (omissions: ContextOmission[]): ContextOmission[] => {
140
+ const byId = new Map<string, ContextOmission>();
141
+ for (const omission of [...omissions].sort(compareOmissions)) {
142
+ if (!byId.has(omission.candidateId)) {
143
+ byId.set(omission.candidateId, omission);
144
+ }
145
+ }
146
+ return [...byId.values()].sort(compareOmissions);
147
+ };
148
+
149
+ const countReasons = (omissions: ContextOmission[]): ContextReasonCounts => {
150
+ const counts = emptyReasonCounts();
151
+ for (const omission of omissions) counts[omission.reason] += 1;
152
+ return counts;
153
+ };
154
+
155
+ const coveredFacetSet = <T>(
156
+ selected: MaterializedContextCandidate<T>[]
157
+ ): Set<string> => new Set(selected.flatMap((candidate) => candidate.facets));
158
+
159
+ const buildCoverage = <T>(
160
+ requestedFacets: string[],
161
+ selected: MaterializedContextCandidate<T>[],
162
+ candidates: MaterializedContextCandidate<T>[],
163
+ filteredFacetMatches: ReadonlySet<string>
164
+ ): ContextCoverageState => {
165
+ const covered = coveredFacetSet(selected);
166
+ const available = new Set(
167
+ candidates.flatMap((candidate) => candidate.facets)
168
+ );
169
+ const coveredFacets = requestedFacets.filter((facet) => covered.has(facet));
170
+ const unresolvedFacets = requestedFacets.filter(
171
+ (facet) => !covered.has(facet)
172
+ );
173
+ const gaps = unresolvedFacets.map((facet) => ({
174
+ facet,
175
+ code: available.has(facet)
176
+ ? ("global_budget_exhausted" as const)
177
+ : filteredFacetMatches.has(facet)
178
+ ? ("filtered_by_scope" as const)
179
+ : ("facet_not_found" as const),
180
+ }));
181
+ return { coveredFacets, unresolvedFacets, gaps };
182
+ };
183
+
184
+ const buildState = <T>(
185
+ requestedFacets: string[],
186
+ selected: MaterializedContextCandidate<T>[],
187
+ omissions: ContextOmission[],
188
+ candidates: MaterializedContextCandidate<T>[],
189
+ filteredFacetMatches: ReadonlySet<string>
190
+ ): ContextSelectionState<T> => {
191
+ const orderedOmissions = dedupeOmissions(omissions);
192
+ return {
193
+ selected: selected.map((candidate) => ({ ...candidate })),
194
+ omissions: orderedOmissions,
195
+ reasonCounts: countReasons(orderedOmissions),
196
+ coverage: buildCoverage(
197
+ requestedFacets,
198
+ selected,
199
+ candidates,
200
+ filteredFacetMatches
201
+ ),
202
+ };
203
+ };
204
+
205
+ const overlaps = <T>(
206
+ candidate: MaterializedContextCandidate<T>,
207
+ selected: MaterializedContextCandidate<T>[]
208
+ ): boolean =>
209
+ selected.some(
210
+ (item) =>
211
+ item.docid === candidate.docid &&
212
+ candidate.startLine <= item.endLine &&
213
+ item.startLine <= candidate.endLine
214
+ );
215
+
216
+ const utf8Bytes = (value: string): number =>
217
+ new TextEncoder().encode(value).byteLength;
218
+
219
+ const validateLimits = (limits: ContextBudgetLimits): void => {
220
+ const values = [
221
+ limits.requestedBytes,
222
+ limits.requestedTokens,
223
+ limits.safetyMarginBytes,
224
+ limits.safetyMarginTokens,
225
+ ];
226
+ if (
227
+ values.some((value) => !Number.isSafeInteger(value) || value < 0) ||
228
+ limits.requestedBytes <= limits.safetyMarginBytes ||
229
+ limits.requestedTokens <= limits.safetyMarginTokens
230
+ ) {
231
+ throw new Error("Context budget limits must be positive safe integers");
232
+ }
233
+ };
234
+
235
+ const projectionFits = <P>(
236
+ projection: ContextCanonicalProjection<P> | null,
237
+ limits: ContextBudgetLimits
238
+ ): projection is ContextCanonicalProjection<P> =>
239
+ projection !== null &&
240
+ Number.isSafeInteger(projection.usedBytes) &&
241
+ Number.isSafeInteger(projection.usedTokens) &&
242
+ projection.usedBytes >= 0 &&
243
+ projection.usedTokens >= 0 &&
244
+ projection.usedBytes + limits.safetyMarginBytes <= limits.requestedBytes &&
245
+ projection.usedTokens + limits.safetyMarginTokens <= limits.requestedTokens;
246
+
247
+ const documentShareExceeded = <T>(
248
+ candidate: MaterializedContextCandidate<T>,
249
+ selected: MaterializedContextCandidate<T>[],
250
+ candidateDocumentCount: number,
251
+ limits: ContextBudgetLimits
252
+ ): boolean => {
253
+ if (candidateDocumentCount <= 1) return false;
254
+ const numerator = limits.documentShareNumerator ?? 3;
255
+ const denominator = limits.documentShareDenominator ?? 5;
256
+ if (
257
+ !Number.isSafeInteger(numerator) ||
258
+ !Number.isSafeInteger(denominator) ||
259
+ numerator < 1 ||
260
+ denominator < numerator
261
+ ) {
262
+ throw new Error("Invalid Context document share ratio");
263
+ }
264
+ const spendableBytes = limits.requestedBytes - limits.safetyMarginBytes;
265
+ const shareLimit = Number(
266
+ (BigInt(spendableBytes) * BigInt(numerator)) / BigInt(denominator)
267
+ );
268
+ const sameDocumentBytes = selected
269
+ .filter((item) => item.docid === candidate.docid)
270
+ .reduce((sum, item) => sum + utf8Bytes(item.text), 0);
271
+ return sameDocumentBytes + utf8Bytes(candidate.text) > shareLimit;
272
+ };
273
+
274
+ const omissionReason = <T>(
275
+ candidate: MaterializedContextCandidate<T>,
276
+ selected: MaterializedContextCandidate<T>[],
277
+ covered: ReadonlySet<string>,
278
+ requestedFacetCount: number,
279
+ candidateDocumentCount: number,
280
+ limits: ContextBudgetLimits
281
+ ): ContextOmissionReason | null => {
282
+ if (requestedFacetCount > 0 && candidate.facets.length === 0) {
283
+ return "redundant_coverage";
284
+ }
285
+ if (overlaps(candidate, selected)) return "overlap";
286
+ if (
287
+ documentShareExceeded(candidate, selected, candidateDocumentCount, limits)
288
+ ) {
289
+ return "document_share_cap";
290
+ }
291
+ if (
292
+ selected.length > 0 &&
293
+ candidate.facets.every((facet) => covered.has(facet))
294
+ ) {
295
+ return "redundant_coverage";
296
+ }
297
+ return null;
298
+ };
299
+
300
+ const asOmission = <T>(
301
+ candidate: MaterializedContextCandidate<T>,
302
+ reason: ContextOmissionReason
303
+ ): ContextOmission => ({
304
+ candidateId: candidate.candidateId,
305
+ uri: candidate.uri,
306
+ docid: candidate.docid,
307
+ startLine: candidate.startLine,
308
+ endLine: candidate.endLine,
309
+ passageHash: candidate.passageHash,
310
+ sourceHash: candidate.sourceHash,
311
+ mirrorHash: candidate.mirrorHash,
312
+ reason,
313
+ });
314
+
315
+ const collapseDuplicates = <T>(
316
+ candidates: MaterializedContextCandidate<T>[]
317
+ ): {
318
+ candidates: MaterializedContextCandidate<T>[];
319
+ omissions: ContextOmission[];
320
+ } => {
321
+ const ordered = [...candidates].sort(
322
+ (left, right) =>
323
+ left.retrievalRank - right.retrievalRank || compareReferences(left, right)
324
+ );
325
+ const seenIds = new Set<string>();
326
+ const seenPassages = new Set<string>();
327
+ const kept: MaterializedContextCandidate<T>[] = [];
328
+ const omissions: ContextOmission[] = [];
329
+ for (const candidate of ordered) {
330
+ if (seenIds.has(candidate.candidateId)) continue;
331
+ seenIds.add(candidate.candidateId);
332
+ if (seenPassages.has(candidate.passageHash)) {
333
+ omissions.push(asOmission(candidate, "duplicate"));
334
+ continue;
335
+ }
336
+ seenPassages.add(candidate.passageHash);
337
+ kept.push(candidate);
338
+ }
339
+ return { candidates: kept, omissions };
340
+ };
341
+
342
+ const compareMarginalValue = <T>(
343
+ left: MaterializedContextCandidate<T>,
344
+ right: MaterializedContextCandidate<T>,
345
+ covered: ReadonlySet<string>,
346
+ poolSize: number
347
+ ): number => {
348
+ const uncovered = (candidate: MaterializedContextCandidate<T>): number =>
349
+ candidate.facets.filter((facet) => !covered.has(facet)).length;
350
+ const leftUncovered = uncovered(left);
351
+ const rightUncovered = uncovered(right);
352
+ const leftCost = BigInt(Math.max(1, utf8Bytes(left.text)));
353
+ const rightCost = BigInt(Math.max(1, utf8Bytes(right.text)));
354
+ const leftRatio = BigInt(leftUncovered) * rightCost;
355
+ const rightRatio = BigInt(rightUncovered) * leftCost;
356
+ if (leftRatio !== rightRatio) return leftRatio > rightRatio ? -1 : 1;
357
+ const relevance = (candidate: MaterializedContextCandidate<T>): number =>
358
+ Math.max(1, poolSize - candidate.retrievalRank + 1);
359
+ const relevanceDifference = relevance(right) - relevance(left);
360
+ if (relevanceDifference !== 0) return relevanceDifference;
361
+ if (left.retrievalRank !== right.retrievalRank) {
362
+ return left.retrievalRank - right.retrievalRank;
363
+ }
364
+ return compareReferences(left, right);
365
+ };
366
+
367
+ /** Select materialized candidates using exact full-payload fit checks. */
368
+ export const selectContextEvidence = <T, P>(
369
+ options: ContextSelectionOptions<T, P>
370
+ ): ContextSelectionResult<T, P> => {
371
+ validateLimits(options.limits);
372
+ const requestedFacets = [...new Set(options.requestedFacets)].sort(
373
+ compareCodeUnits
374
+ );
375
+ const filteredFacetMatches =
376
+ options.filteredFacetMatches ?? new Set<string>();
377
+ const collapsed = collapseDuplicates(options.candidates);
378
+ const candidates = collapsed.candidates;
379
+ const candidateDocumentCount = new Set(
380
+ candidates.map((candidate) => candidate.docid)
381
+ ).size;
382
+ const selected: MaterializedContextCandidate<T>[] = [];
383
+ const omissions = [
384
+ ...(options.initialOmissions ?? []),
385
+ ...collapsed.omissions,
386
+ ];
387
+ const remaining = [...candidates];
388
+ let projection: ContextCanonicalProjection<P> | null = null;
389
+
390
+ while (remaining.length > 0) {
391
+ const covered = coveredFacetSet(selected);
392
+ const eligible: MaterializedContextCandidate<T>[] = [];
393
+ for (const candidate of remaining) {
394
+ const reason = omissionReason(
395
+ candidate,
396
+ selected,
397
+ covered,
398
+ requestedFacets.length,
399
+ candidateDocumentCount,
400
+ options.limits
401
+ );
402
+ if (reason) omissions.push(asOmission(candidate, reason));
403
+ else eligible.push(candidate);
404
+ }
405
+ remaining.length = 0;
406
+ remaining.push(...eligible);
407
+ if (remaining.length === 0) break;
408
+
409
+ remaining.sort((left, right) =>
410
+ compareMarginalValue(left, right, covered, candidates.length)
411
+ );
412
+ const candidate = remaining.shift();
413
+ if (!candidate) break;
414
+ const proposedSelected = [...selected, candidate];
415
+ const proposedCovered = coveredFacetSet(proposedSelected);
416
+ const provisionalOmissions = [
417
+ ...omissions,
418
+ ...remaining.map((item) =>
419
+ asOmission(
420
+ item,
421
+ omissionReason(
422
+ item,
423
+ proposedSelected,
424
+ proposedCovered,
425
+ requestedFacets.length,
426
+ candidateDocumentCount,
427
+ options.limits
428
+ ) ?? "global_budget"
429
+ )
430
+ ),
431
+ ];
432
+ const state = buildState(
433
+ requestedFacets,
434
+ proposedSelected,
435
+ provisionalOmissions,
436
+ candidates,
437
+ filteredFacetMatches
438
+ );
439
+ const proposedProjection = options.projectCanonical(state);
440
+ if (projectionFits(proposedProjection, options.limits)) {
441
+ selected.push(candidate);
442
+ projection = proposedProjection;
443
+ } else {
444
+ omissions.push(asOmission(candidate, "global_budget"));
445
+ }
446
+ }
447
+
448
+ const finalState = buildState(
449
+ requestedFacets,
450
+ selected,
451
+ omissions,
452
+ candidates,
453
+ filteredFacetMatches
454
+ );
455
+ const finalProjection =
456
+ selected.length > 0 ? options.projectCanonical(finalState) : null;
457
+ if (selected.length > 0 && !projectionFits(finalProjection, options.limits)) {
458
+ throw new Error("Final canonical Context projection exceeds its budget");
459
+ }
460
+ return { ...finalState, projection: finalProjection ?? projection };
461
+ };
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+
3
+ const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/);
4
+
5
+ export const contextCapsuleIndexSnapshotSchema = z
6
+ .object({
7
+ before: sha256Schema,
8
+ after: sha256Schema,
9
+ stable: z.literal(true),
10
+ })
11
+ .strict()
12
+ .refine((value) => value.before === value.after, {
13
+ message: "index changed while the operation was running",
14
+ path: ["after"],
15
+ });