llmnav 0.5.1

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 (68) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/LICENSE +21 -0
  3. package/README.md +294 -0
  4. package/ROADMAP.md +71 -0
  5. package/bin/llmnav.js +16 -0
  6. package/docs/agent-integration.md +114 -0
  7. package/docs/api.md +290 -0
  8. package/docs/architecture.md +286 -0
  9. package/docs/benchmarking.md +164 -0
  10. package/docs/ci.md +196 -0
  11. package/docs/cli.md +233 -0
  12. package/docs/configuration.md +117 -0
  13. package/docs/editor-integration.md +29 -0
  14. package/docs/faq.md +59 -0
  15. package/docs/graph.md +92 -0
  16. package/docs/language-examples.md +130 -0
  17. package/docs/migration.md +130 -0
  18. package/docs/performance-v0.2.md +42 -0
  19. package/docs/provider-neutral-integration.md +66 -0
  20. package/docs/publishing.md +86 -0
  21. package/docs/quickstart.md +139 -0
  22. package/docs/research.md +31 -0
  23. package/docs/spec.md +424 -0
  24. package/examples/provider-neutral-host.d.mts +17 -0
  25. package/examples/provider-neutral-host.mjs +40 -0
  26. package/package.json +79 -0
  27. package/schema/config.schema.json +296 -0
  28. package/src/agent-protocol.js +117 -0
  29. package/src/agent-tools.js +61 -0
  30. package/src/agents.js +127 -0
  31. package/src/boundaries.js +50 -0
  32. package/src/changes.js +168 -0
  33. package/src/cli.js +459 -0
  34. package/src/config.js +305 -0
  35. package/src/contracts.js +70 -0
  36. package/src/declaration.js +334 -0
  37. package/src/doctor.js +124 -0
  38. package/src/editor.js +107 -0
  39. package/src/evaluation.js +67 -0
  40. package/src/files.js +81 -0
  41. package/src/formatter.js +23 -0
  42. package/src/generator.js +528 -0
  43. package/src/graph-input.js +157 -0
  44. package/src/graph.js +403 -0
  45. package/src/incremental.js +262 -0
  46. package/src/index.d.ts +673 -0
  47. package/src/index.js +115 -0
  48. package/src/initializer.js +137 -0
  49. package/src/inverted-index.js +350 -0
  50. package/src/parser.js +449 -0
  51. package/src/project.js +65 -0
  52. package/src/prompt-bundle.js +108 -0
  53. package/src/registry.js +107 -0
  54. package/src/sarif.js +70 -0
  55. package/src/search-shards.js +75 -0
  56. package/src/search.js +636 -0
  57. package/src/spec.d.ts +27 -0
  58. package/src/spec.js +237 -0
  59. package/src/tokenizer.js +37 -0
  60. package/src/transaction.js +557 -0
  61. package/src/util.js +256 -0
  62. package/src/validator.js +635 -0
  63. package/templates/file-card.txt +8 -0
  64. package/templates/lexicon.json +7 -0
  65. package/templates/line-card.txt +9 -0
  66. package/templates/module-card.txt +9 -0
  67. package/templates/queries.jsonl +1 -0
  68. package/templates/symbol-card.txt +10 -0
package/src/index.js ADDED
@@ -0,0 +1,115 @@
1
+ export { AGENT_PROTOCOL, installAgentInstructions } from "./agents.js";
2
+ export {
3
+ AGENT_OPERATION_SCHEMA_VERSION,
4
+ AGENT_TOOL_SCHEMA_VERSION,
5
+ executeAgentOperation,
6
+ getAgentToolDefinitions,
7
+ } from "./agent-protocol.js";
8
+ export {
9
+ buildPromptPrefixBundle,
10
+ isCompatiblePromptPrefixBundle,
11
+ loadPromptPrefixBundle,
12
+ PROMPT_BUNDLE_SCHEMA_VERSION,
13
+ renderPromptPrefixBundle,
14
+ } from "./prompt-bundle.js";
15
+ export { compareCardIndexes, describeAffectedBoundaries, describeAffectedCatalogs } from "./changes.js";
16
+ export { loadConfig, validateConfig } from "./config.js";
17
+ export { BOUNDARY_KINDS, detectBoundaries } from "./boundaries.js";
18
+ export {
19
+ diagnosticsToEditor,
20
+ EDITOR_DIAGNOSTIC_SCHEMA_VERSION,
21
+ EDITOR_INTEGRATION_SCHEMA_VERSION,
22
+ getEditorIntegration,
23
+ renderEditorDiagnostics,
24
+ } from "./editor.js";
25
+ export { diagnosticsToSarif, SARIF_SCHEMA, SARIF_VERSION } from "./sarif.js";
26
+ export { GRAPH_INPUT_SCHEMA_VERSION, loadGraphInputs, normalizeGraphInput } from "./graph-input.js";
27
+ export {
28
+ buildRepositoryGraph,
29
+ buildRepositoryGraphIncremental,
30
+ GRAPH_SCHEMA_VERSION,
31
+ GRAPH_STATE_SCHEMA_VERSION,
32
+ compatibleGraphState,
33
+ isCompatibleRepositoryGraph,
34
+ renderGraphState,
35
+ renderGraphNode,
36
+ renderRepositoryGraph,
37
+ resolveGraphNode,
38
+ } from "./graph.js";
39
+ export {
40
+ buildSearchShards,
41
+ SEARCH_SHARD_ENCODING,
42
+ SEARCH_SHARD_SCHEMA_VERSION,
43
+ } from "./search-shards.js";
44
+ export {
45
+ buildContractFingerprints,
46
+ compareContractFingerprints,
47
+ CONTRACT_FINGERPRINT_SCHEMA_VERSION,
48
+ } from "./contracts.js";
49
+ export { findAttachedDeclaration, extractImports } from "./declaration.js";
50
+ export { doctorProject } from "./doctor.js";
51
+ export { evaluateProject } from "./evaluation.js";
52
+ export { collectSourceFiles, findProjectRoot } from "./files.js";
53
+ export { formatProject } from "./formatter.js";
54
+ export {
55
+ buildArtifacts,
56
+ buildArtifactSet,
57
+ generateProject,
58
+ renderCompactCard,
59
+ renderSemanticCard,
60
+ } from "./generator.js";
61
+ export {
62
+ buildFileStateFromProject,
63
+ FILE_STATE_SCHEMA_VERSION,
64
+ renderFileState,
65
+ scanProjectIncremental,
66
+ SOURCE_INDEXER_VERSION,
67
+ usableFileState,
68
+ } from "./incremental.js";
69
+ export { initializeProject } from "./initializer.js";
70
+ export {
71
+ buildInvertedIndex,
72
+ buildSearchDocument,
73
+ isCompatibleSearchIndex,
74
+ renderSearchIndex,
75
+ SEARCH_FIELD_ORDER,
76
+ SEARCH_FIELD_WEIGHTS,
77
+ SEARCH_INDEX_ENCODING,
78
+ SEARCH_INDEX_SCHEMA_VERSION,
79
+ searchCardSetHash,
80
+ searchDocumentHash,
81
+ verifySearchIndex,
82
+ } from "./inverted-index.js";
83
+ export {
84
+ canonicalizeSource,
85
+ cardToCanonicalObject,
86
+ formatLlmnavBlock,
87
+ parseLlmnavBlocks,
88
+ } from "./parser.js";
89
+ export { scanProject } from "./project.js";
90
+ export { ensureActiveIds, loadRegistry, mergeActiveIds, renderRegistryRecords, resolveRegistryId } from "./registry.js";
91
+ export {
92
+ buildContext,
93
+ createProjectSession,
94
+ loadSearchData,
95
+ queryIndex,
96
+ queryIndexLegacy,
97
+ queryPreparedIndex,
98
+ queryProject,
99
+ showProjectCard,
100
+ tokenize,
101
+ } from "./search.js";
102
+ export { normalizeSearchText, TOKENIZER_VERSION } from "./tokenizer.js";
103
+ export {
104
+ acquireGenerationLock,
105
+ commitGeneratedCache,
106
+ recoverGenerationTransaction,
107
+ releaseGenerationLock,
108
+ removeWithRetry,
109
+ renameWithRetry,
110
+ TRANSACTION_ABORT_EXIT_CODE,
111
+ TRANSACTION_SCHEMA_VERSION,
112
+ withGenerationLock,
113
+ } from "./transaction.js";
114
+ export * from "./spec.js";
115
+ export { countDiagnostics, diagnostic, validateProject } from "./validator.js";
@@ -0,0 +1,137 @@
1
+ /* llmnav/1 module
2
+ id=llmnav.project.initialize
3
+ role=Create a repository's LLMNav configuration, registry, schemas, agent instructions, and initial index.
4
+ owns=project bootstrap|configuration templates|initial generation
5
+ excludes=package installation|automatic annotation
6
+ search=llmnav init|repository bootstrap|agent setup
7
+ rel=workflow>llmnav.agent.install
8
+ rel=workflow>llmnav.index.generate
9
+ stability=architecture
10
+ */
11
+
12
+ import { copyFile, mkdir } from "node:fs/promises";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { installAgentInstructions } from "./agents.js";
16
+ import { generateProject } from "./generator.js";
17
+ import { DEFAULT_CONFIG } from "./spec.js";
18
+ import { assertNoSymlinkTraversal, atomicWrite, readJson, readText, stableStringify } from "./util.js";
19
+
20
+ const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
21
+
22
+ export async function initializeProject(root, options = {}) {
23
+ await assertNoSymlinkTraversal(root, path.join(root, ".llmnav"), ".llmnav");
24
+ await mkdir(path.join(root, ".llmnav", "eval"), { recursive: true });
25
+ await mkdir(path.join(root, ".llmnav", "schema"), { recursive: true });
26
+ const repositoryId = await inferRepositoryId(root);
27
+ const changed = [];
28
+
29
+ const config = structuredClone(DEFAULT_CONFIG);
30
+ config.repositoryId = repositoryId;
31
+ config.$schema = "./schema/config.schema.json";
32
+ await writeIfMissingOrForced(
33
+ path.join(root, ".llmnav", "config.json"),
34
+ stableStringify(config),
35
+ options.force,
36
+ changed,
37
+ ".llmnav/config.json",
38
+ );
39
+
40
+ await writeIfMissingOrForced(
41
+ path.join(root, ".llmnav", "lexicon.json"),
42
+ stableStringify({ version: 1, aliases: {} }),
43
+ false,
44
+ changed,
45
+ ".llmnav/lexicon.json",
46
+ );
47
+ await writeIfMissingOrForced(
48
+ path.join(root, ".llmnav", "ids.jsonl"),
49
+ "",
50
+ false,
51
+ changed,
52
+ ".llmnav/ids.jsonl",
53
+ );
54
+ await writeIfMissingOrForced(
55
+ path.join(root, ".llmnav", "order.lock"),
56
+ "",
57
+ false,
58
+ changed,
59
+ ".llmnav/order.lock",
60
+ );
61
+ await writeIfMissingOrForced(
62
+ path.join(root, ".llmnav", "eval", "queries.jsonl"),
63
+ '# One JSON object per line: {"query":"...","expected":["domain.feature.action"]}\n',
64
+ false,
65
+ changed,
66
+ ".llmnav/eval/queries.jsonl",
67
+ );
68
+ await writeIfMissingOrForced(
69
+ path.join(root, ".llmnav", ".gitignore"),
70
+ "tmp/\nstate/\n.transactions/\ngeneration-transaction.json\ngeneration.lock\ngeneration.lock.release-*\n*.tmp-*\n",
71
+ options.force,
72
+ changed,
73
+ ".llmnav/.gitignore",
74
+ );
75
+
76
+ const schemaSource = path.join(PACKAGE_ROOT, "schema", "config.schema.json");
77
+ const schemaTarget = path.join(root, ".llmnav", "schema", "config.schema.json");
78
+ if (options.force || (await readText(schemaTarget, null)) === null) {
79
+ await copyFile(schemaSource, schemaTarget);
80
+ changed.push(".llmnav/schema/config.schema.json");
81
+ }
82
+
83
+ changed.push(...(await installAgentInstructions(root, options.agents ?? ["agents"])));
84
+
85
+ if (options.packageScripts) {
86
+ if (await addPackageScripts(root)) changed.push("package.json");
87
+ }
88
+
89
+ const generated = await generateProject(root, { check: false });
90
+ if (!generated.ok) {
91
+ return { ok: false, changed: [...new Set(changed)].sort(), generated };
92
+ }
93
+ changed.push(...generated.changedFiles);
94
+ return { ok: true, changed: [...new Set(changed)].sort(), generated };
95
+ }
96
+
97
+ async function inferRepositoryId(root) {
98
+ const packageJson = await readJson(path.join(root, "package.json"), null);
99
+ const candidate = packageJson?.name ?? path.basename(root);
100
+ const normalized = String(candidate)
101
+ .replace(/^@[^/]+\//u, "")
102
+ .normalize("NFKC")
103
+ .toLowerCase()
104
+ .replace(/[^a-z0-9-]+/gu, "-")
105
+ .replace(/^-+|-+$/gu, "");
106
+ return normalized || "repository";
107
+ }
108
+
109
+ async function writeIfMissingOrForced(filePath, content, force, changed, displayPath) {
110
+ const existing = await readText(filePath, null);
111
+ if (existing !== null && !force) return;
112
+ if (existing === content) return;
113
+ await atomicWrite(filePath, content);
114
+ changed.push(displayPath);
115
+ }
116
+
117
+ async function addPackageScripts(root) {
118
+ const packagePath = path.join(root, "package.json");
119
+ const text = await readText(packagePath, null);
120
+ if (text === null) return false;
121
+ const parsed = JSON.parse(text);
122
+ parsed.scripts ??= {};
123
+ const desired = {
124
+ "llmnav:check": "llmnav check",
125
+ "llmnav:format": "llmnav format",
126
+ "llmnav:generate": "llmnav generate",
127
+ "llmnav:eval": "llmnav eval",
128
+ };
129
+ let changed = false;
130
+ for (const [name, command] of Object.entries(desired)) {
131
+ if (parsed.scripts[name] === command) continue;
132
+ parsed.scripts[name] = command;
133
+ changed = true;
134
+ }
135
+ if (changed) await atomicWrite(packagePath, `${JSON.stringify(parsed, null, 2)}\n`);
136
+ return changed;
137
+ }
@@ -0,0 +1,350 @@
1
+ /* llmnav/1 module
2
+ id=llmnav.index.inverted
3
+ role=Compile searchable card fields into a deterministic inverted index that can be updated one card at a time.
4
+ owns=posting lists|card search documents|incremental token reuse
5
+ excludes=semantic source syntax|query presentation
6
+ search=inverted index|incremental search index|posting list
7
+ rel=workflow>llmnav.search.tokenize
8
+ rel=workflow>llmnav.index.generate
9
+ stability=architecture
10
+ */
11
+
12
+ import { compareText, sha256, stableJson, stableStringify } from "./util.js";
13
+ import { normalizeSearchText, tokenize, TOKENIZER_VERSION } from "./tokenizer.js";
14
+
15
+ export const SEARCH_INDEX_SCHEMA_VERSION = 2;
16
+ export const SEARCH_INDEX_ENCODING = "compact-v1";
17
+
18
+ export const SEARCH_FIELD_ORDER = Object.freeze([
19
+ "id",
20
+ "role",
21
+ "search",
22
+ "owns",
23
+ "excludes",
24
+ "invariant",
25
+ "effect",
26
+ "risk",
27
+ "symbol",
28
+ "path",
29
+ "signature",
30
+ ]);
31
+
32
+ export const SEARCH_FIELD_WEIGHTS = Object.freeze({
33
+ id: 12,
34
+ role: 8,
35
+ search: 10,
36
+ owns: 7,
37
+ excludes: 2,
38
+ invariant: 6,
39
+ effect: 5,
40
+ risk: 4,
41
+ symbol: 5,
42
+ path: 3,
43
+ signature: 3,
44
+ });
45
+
46
+ export function buildInvertedIndex(index, previous = null) {
47
+ let previousUsable = isCompatibleSearchIndex(previous, index.repositoryId);
48
+ let postings = new Map();
49
+ let previousDocuments = new Map();
50
+ if (previousUsable) {
51
+ try {
52
+ ({ postings, documents: previousDocuments } = deserializeSearchIndex(previous));
53
+ } catch {
54
+ previousUsable = false;
55
+ postings = new Map();
56
+ previousDocuments = new Map();
57
+ }
58
+ }
59
+
60
+ const documents = new Map();
61
+ const currentById = new Map(index.cards.map((card) => [card.id, card]));
62
+ const changedIds = [];
63
+ const removedIds = [];
64
+ let reusedCards = 0;
65
+ let indexedCards = 0;
66
+
67
+ if (previousUsable) {
68
+ for (const id of [...previousDocuments.keys()].sort(compareText)) {
69
+ if (currentById.has(id)) continue;
70
+ removeDocumentFromPostings(postings, id, previousDocuments.get(id));
71
+ removedIds.push(id);
72
+ }
73
+ }
74
+
75
+ for (const card of [...index.cards].sort((left, right) => compareText(left.id, right.id))) {
76
+ const hash = searchDocumentHash(card);
77
+ const oldDocument = previousDocuments.get(card.id);
78
+ if (oldDocument?.hash === hash) {
79
+ documents.set(card.id, oldDocument);
80
+ reusedCards += 1;
81
+ continue;
82
+ }
83
+
84
+ if (oldDocument) removeDocumentFromPostings(postings, card.id, oldDocument);
85
+ const document = buildSearchDocument(card, hash);
86
+ addDocumentToPostings(postings, card.id, document);
87
+ documents.set(card.id, document);
88
+ changedIds.push(card.id);
89
+ indexedCards += 1;
90
+ }
91
+
92
+ const cardIds = [...documents.keys()].sort(compareText);
93
+ const tokens = [...postings.keys()].sort(compareText);
94
+ const cardIndex = new Map(cardIds.map((id, indexValue) => [id, indexValue]));
95
+ const serializedDocuments = cardIds.map((id) => {
96
+ const document = documents.get(id);
97
+ return [document.hash, document.phrases];
98
+ });
99
+ const serializedPostings = tokens.map((token) =>
100
+ [...(postings.get(token)?.entries() ?? [])]
101
+ .sort(([left], [right]) => compareText(left, right))
102
+ .map(([id, vector]) => [cardIndex.get(id), vector]),
103
+ );
104
+ const cardSetHash = searchCardSetHash(index.cards);
105
+ const searchIndex = {
106
+ schemaVersion: SEARCH_INDEX_SCHEMA_VERSION,
107
+ encoding: SEARCH_INDEX_ENCODING,
108
+ tokenizerVersion: TOKENIZER_VERSION,
109
+ repositoryId: index.repositoryId ?? "",
110
+ cardSetHash,
111
+ documentCount: index.cards.length,
112
+ fieldOrder: [...SEARCH_FIELD_ORDER],
113
+ cardIds,
114
+ tokens,
115
+ documents: serializedDocuments,
116
+ postings: serializedPostings,
117
+ };
118
+
119
+ return {
120
+ searchIndex,
121
+ stats: {
122
+ previousUsable,
123
+ totalCards: index.cards.length,
124
+ reusedCards,
125
+ indexedCards,
126
+ removedCards: removedIds.length,
127
+ changedIds: changedIds.sort(compareText),
128
+ removedIds: removedIds.sort(compareText),
129
+ tokenCount: tokens.length,
130
+ },
131
+ };
132
+ }
133
+
134
+ export function buildSearchDocument(card, hash = searchDocumentHash(card)) {
135
+ const fieldValues = {
136
+ id: card.id.replaceAll(".", " "),
137
+ role: card.role,
138
+ search: (card.search ?? []).join(" "),
139
+ owns: (card.owns ?? []).join(" "),
140
+ excludes: (card.excludes ?? []).join(" "),
141
+ invariant: (card.invariant ?? []).join(" "),
142
+ effect: (card.effect ?? []).join(" "),
143
+ risk: (card.risk ?? []).join(" "),
144
+ symbol: card.location?.symbol ?? "",
145
+ path: card.location?.path ?? "",
146
+ signature: card.location?.signature ?? "",
147
+ };
148
+
149
+ const termMap = new Map();
150
+ for (const [fieldIndex, field] of SEARCH_FIELD_ORDER.entries()) {
151
+ const counts = countTokens(tokenize(fieldValues[field]));
152
+ for (const [token, count] of counts) {
153
+ const sparse = termMap.get(token) ?? new Map();
154
+ sparse.set(fieldIndex, count);
155
+ termMap.set(token, sparse);
156
+ }
157
+ }
158
+
159
+ const terms = [...termMap.entries()]
160
+ .sort(([left], [right]) => compareText(left, right))
161
+ .map(([token, sparse]) => [token, serializeSparseVector(sparse)]);
162
+ const phrases = [card.role, ...(card.search ?? []), ...(card.invariant ?? []), ...(card.owns ?? [])]
163
+ .filter(Boolean)
164
+ .map(normalizeSearchText);
165
+
166
+ return {
167
+ hash,
168
+ phrases,
169
+ terms,
170
+ tokens: terms.map(([token]) => token),
171
+ };
172
+ }
173
+
174
+ export function searchDocumentHash(card) {
175
+ return sha256(
176
+ stableJson({
177
+ id: card.id,
178
+ role: card.role,
179
+ search: card.search ?? [],
180
+ owns: card.owns ?? [],
181
+ excludes: card.excludes ?? [],
182
+ invariant: card.invariant ?? [],
183
+ effect: card.effect ?? [],
184
+ risk: card.risk ?? [],
185
+ symbol: card.location?.symbol ?? null,
186
+ path: card.location?.path ?? null,
187
+ signature: card.location?.signature ?? null,
188
+ }),
189
+ );
190
+ }
191
+
192
+ export function searchCardSetHash(cards) {
193
+ return sha256(
194
+ [...cards]
195
+ .sort((left, right) => compareText(left.id, right.id))
196
+ .map((card) => `${card.id}:${searchDocumentHash(card)}`)
197
+ .join("\n"),
198
+ );
199
+ }
200
+
201
+ export function isCompatibleSearchIndex(searchIndex, repositoryId = undefined) {
202
+ return Boolean(
203
+ searchIndex &&
204
+ searchIndex.schemaVersion === SEARCH_INDEX_SCHEMA_VERSION &&
205
+ searchIndex.encoding === SEARCH_INDEX_ENCODING &&
206
+ searchIndex.tokenizerVersion === TOKENIZER_VERSION &&
207
+ Array.isArray(searchIndex.fieldOrder) &&
208
+ SEARCH_FIELD_ORDER.every((field, index) => searchIndex.fieldOrder[index] === field) &&
209
+ Array.isArray(searchIndex.cardIds) &&
210
+ isSortedUniqueStrings(searchIndex.cardIds) &&
211
+ Array.isArray(searchIndex.tokens) &&
212
+ isSortedUniqueStrings(searchIndex.tokens) &&
213
+ Array.isArray(searchIndex.documents) &&
214
+ searchIndex.documents.length === searchIndex.cardIds.length &&
215
+ Array.isArray(searchIndex.postings) &&
216
+ searchIndex.postings.length === searchIndex.tokens.length &&
217
+ (repositoryId === undefined || searchIndex.repositoryId === (repositoryId ?? "")),
218
+ );
219
+ }
220
+
221
+ export function verifySearchIndex(index, searchIndex) {
222
+ if (!isCompatibleSearchIndex(searchIndex, index.repositoryId)) return false;
223
+ if (searchIndex.documentCount !== index.cards.length) return false;
224
+ if (searchIndex.cardSetHash !== searchCardSetHash(index.cards)) return false;
225
+ const cards = new Map(index.cards.map((card) => [card.id, card]));
226
+ if (searchIndex.cardIds.length !== cards.size) return false;
227
+ for (const [cardIndex, id] of searchIndex.cardIds.entries()) {
228
+ const card = cards.get(id);
229
+ const document = searchIndex.documents[cardIndex];
230
+ if (!card || !Array.isArray(document) || document[0] !== searchDocumentHash(card)) return false;
231
+ }
232
+ try {
233
+ deserializeSearchIndex(searchIndex);
234
+ } catch {
235
+ return false;
236
+ }
237
+ return true;
238
+ }
239
+
240
+ export function renderSearchIndex(searchIndex) {
241
+ return stableStringify(searchIndex);
242
+ }
243
+
244
+ function countTokens(tokens) {
245
+ const counts = new Map();
246
+ for (const token of tokens) counts.set(token, (counts.get(token) ?? 0) + 1);
247
+ return counts;
248
+ }
249
+
250
+ function addDocumentToPostings(postings, id, document) {
251
+ for (const [token, vector] of document.terms ?? []) {
252
+ const tokenPostings = postings.get(token) ?? new Map();
253
+ tokenPostings.set(id, vector);
254
+ postings.set(token, tokenPostings);
255
+ }
256
+ }
257
+
258
+ function removeDocumentFromPostings(postings, id, document) {
259
+ for (const token of document?.tokens ?? []) {
260
+ const tokenPostings = postings.get(token);
261
+ if (!tokenPostings) continue;
262
+ tokenPostings.delete(id);
263
+ if (tokenPostings.size === 0) postings.delete(token);
264
+ }
265
+ }
266
+
267
+ function deserializeSearchIndex(searchIndex) {
268
+ const documents = new Map();
269
+ for (const [cardIndex, id] of searchIndex.cardIds.entries()) {
270
+ const serialized = searchIndex.documents[cardIndex];
271
+ if (
272
+ typeof id !== "string" ||
273
+ !Array.isArray(serialized) ||
274
+ serialized.length !== 2 ||
275
+ typeof serialized[0] !== "string" ||
276
+ !Array.isArray(serialized[1]) ||
277
+ !serialized[1].every((phrase) => typeof phrase === "string")
278
+ ) {
279
+ throw new Error("Malformed compact search document.");
280
+ }
281
+ documents.set(id, {
282
+ hash: serialized[0],
283
+ phrases: serialized[1],
284
+ tokens: [],
285
+ });
286
+ }
287
+
288
+ const postings = new Map();
289
+ for (const [tokenIndex, token] of searchIndex.tokens.entries()) {
290
+ if (typeof token !== "string") throw new Error("Malformed compact search token.");
291
+ const tokenPostings = new Map();
292
+ let previousCardIndex = -1;
293
+ for (const entry of searchIndex.postings[tokenIndex] ?? []) {
294
+ if (!Array.isArray(entry) || entry.length !== 2 || !Number.isInteger(entry[0]) || !Array.isArray(entry[1])) {
295
+ throw new Error("Malformed compact search posting.");
296
+ }
297
+ const cardIndex = entry[0];
298
+ const vector = entry[1];
299
+ if (cardIndex <= previousCardIndex || cardIndex < 0 || cardIndex >= searchIndex.cardIds.length) {
300
+ throw new Error("Compact search postings are not strictly ordered card ordinals.");
301
+ }
302
+ validateSparseVector(vector);
303
+ previousCardIndex = cardIndex;
304
+ const id = searchIndex.cardIds[cardIndex];
305
+ const document = documents.get(id);
306
+ if (!document) throw new Error("Compact search posting references an unknown card.");
307
+ tokenPostings.set(id, vector);
308
+ document.tokens.push(token);
309
+ }
310
+ if (tokenPostings.size > 0) postings.set(token, tokenPostings);
311
+ }
312
+ return { documents, postings };
313
+ }
314
+
315
+ function validateSparseVector(vector) {
316
+ if (vector.length === 0 || vector.length % 2 !== 0) throw new Error("Malformed compact search field vector.");
317
+ let previousFieldIndex = -1;
318
+ for (let index = 0; index < vector.length; index += 2) {
319
+ const fieldIndex = vector[index];
320
+ const count = vector[index + 1];
321
+ if (
322
+ !Number.isInteger(fieldIndex) ||
323
+ fieldIndex <= previousFieldIndex ||
324
+ fieldIndex < 0 ||
325
+ fieldIndex >= SEARCH_FIELD_ORDER.length ||
326
+ !Number.isInteger(count) ||
327
+ count <= 0
328
+ ) {
329
+ throw new Error("Malformed compact search field vector.");
330
+ }
331
+ previousFieldIndex = fieldIndex;
332
+ }
333
+ }
334
+
335
+ function isSortedUniqueStrings(values) {
336
+ let previous = null;
337
+ for (const value of values) {
338
+ if (typeof value !== "string" || (previous !== null && compareText(previous, value) >= 0)) return false;
339
+ previous = value;
340
+ }
341
+ return true;
342
+ }
343
+
344
+ function serializeSparseVector(sparse) {
345
+ const output = [];
346
+ for (const [fieldIndex, count] of [...sparse.entries()].sort(([left], [right]) => left - right)) {
347
+ output.push(fieldIndex, count);
348
+ }
349
+ return output;
350
+ }