knolo-core 0.1.4 → 0.2.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.
package/README.md CHANGED
@@ -1,23 +1,29 @@
1
1
 
2
-
3
2
  # 🧠 KnoLo Core
4
3
 
5
4
  [![npm version](https://img.shields.io/npm/v/knolo-core.svg)](https://www.npmjs.com/package/knolo-core)
6
5
  [![npm downloads](https://img.shields.io/npm/dm/knolo-core.svg)](https://www.npmjs.com/package/knolo-core)
7
6
  [![License](https://img.shields.io/npm/l/knolo-core.svg)](./LICENSE)
8
7
 
9
- **KnoLo Core** is a **local-first knowledge base system** for small language models (LLMs).
10
- It lets you package your own documents into a compact `.knolo` file and query them deterministically — **no embeddings, no vector DBs, no cloud**. Perfect for **on-device LLMs**.
8
+ **KnoLo Core** is a **local-first knowledge base** for small LLMs.
9
+ Package documents into a compact `.knolo` file and query them deterministically —
10
+ **no embeddings, no vector DB, no cloud**. Ideal for **on‑device / offline** assistants.
11
11
 
12
12
  ---
13
13
 
14
- ## ✨ Features
14
+ ## ✨ Highlights (v0.2.0)
15
+
16
+ * 🔎 **Stronger relevance:**
15
17
 
16
- * 📦 **Single-file packs** (`.knolo`) you can ship or load offline
17
- * 🔎 **Deterministic lexical retrieval** (BM25L + phrase + heading boosts)
18
- * **Tiny & fast** runs in Node, browsers, and Expo
19
- * 📑 **Context Patches**: structured snippets optimized for LLM prompts
20
- * 🔒 **Privacy-first**: all data stays local
18
+ * **Required phrase enforcement** (quoted & `requirePhrases`)
19
+ * **Proximity bonus** using minimal term-span cover
20
+ * **Optional heading boosts** when headings are present
21
+ * 🌀 **Duplicate-free results:** **near-duplicate suppression** + **MMR diversity**
22
+ * 🧮 **KNS tie‑breaker:** lightweight numeric signature to stabilize close ties
23
+ * ⚡ **Faster & leaner:** precomputed `avgBlockLen` in pack metadata
24
+ * 📱 **Works in Expo/React Native:** safe TextEncoder/TextDecoder ponyfills
25
+ * 📑 **Context Patches:** LLM‑friendly snippets for prompts
26
+ * 🔒 **Local & private:** everything runs on device
21
27
 
22
28
  ---
23
29
 
@@ -27,7 +33,7 @@ It lets you package your own documents into a compact `.knolo` file and query th
27
33
  npm install knolo-core
28
34
  ```
29
35
 
30
- For local development (building from source):
36
+ Dev from source:
31
37
 
32
38
  ```bash
33
39
  git clone https://github.com/yourname/knolo-core.git
@@ -38,113 +44,242 @@ npm run build
38
44
 
39
45
  ---
40
46
 
41
- ## 🚀 Usage Examples
47
+ ## 🚀 Usage
42
48
 
43
- ### 1. Node.js (in-memory build + query)
49
+ ### 1) Node.js (build mount → query → patch)
44
50
 
45
- ```js
51
+ ```ts
46
52
  import { buildPack, mountPack, query, makeContextPatch } from "knolo-core";
47
53
 
48
54
  const docs = [
49
- { heading: "React Native Bridge", text: "The bridge sends messages between JS and native. You can throttle events to reduce jank." },
50
- { heading: "Throttling", text: "Throttling reduces frequency of events to avoid flooding the bridge." },
51
- { heading: "Debounce vs Throttle", text: "Debounce waits for silence, throttle guarantees a max rate." }
55
+ { id: "guide", heading: "React Native Bridge", text: "The bridge sends messages between JS and native. You can throttle events..." },
56
+ { id: "throttle", heading: "Throttling", text: "Throttling reduces frequency of events to avoid flooding the bridge." },
57
+ { id: "dvst", heading: "Debounce vs Throttle", text: "Debounce waits for silence; throttle guarantees a max rate." }
52
58
  ];
53
59
 
54
- // Build a pack in memory
55
- const bytes = await buildPack(docs);
60
+ const bytes = await buildPack(docs); // build .knolo bytes
61
+ const kb = await mountPack({ src: bytes }); // mount in-memory
62
+ const hits = query(kb, '“react native” throttle', // quotes enforce phrase
63
+ { topK: 5, requirePhrases: ["max rate"] });
56
64
 
57
- // Mount it
58
- const kb = await mountPack({ src: bytes });
59
-
60
- // Query
61
- const hits = query(kb, "react native bridge throttling", { topK: 5 });
62
- console.log("Top hits:", hits);
65
+ console.log(hits);
66
+ /*
67
+ [
68
+ { blockId: 2, score: 6.73, text: "...", source: "dvst" },
69
+ ...
70
+ ]
71
+ */
63
72
 
64
- // Turn into an LLM-friendly context patch
65
73
  const patch = makeContextPatch(hits, { budget: "small" });
66
- console.log("Context Patch:", patch);
74
+ console.log(patch);
67
75
  ```
68
76
 
69
- ---
70
-
71
- ### 2. CLI (build `.knolo` file)
77
+ ### 2) CLI (build a `.knolo` file)
72
78
 
73
- **Prepare `docs.json`:**
79
+ Create `docs.json`:
74
80
 
75
81
  ```json
76
82
  [
77
- { "heading": "Guide", "text": "Install deps.\n\n## Throttle\nLimit frequency of events." },
78
- { "heading": "FAQ", "text": "What is throttling? It reduces event frequency." }
83
+ { "id": "guide", "heading": "Guide", "text": "Install deps...\n\n## Throttle\nLimit frequency of events." },
84
+ { "id": "faq", "heading": "FAQ", "text": "What is throttling? It reduces event frequency." }
79
85
  ]
80
86
  ```
81
87
 
82
- **Build the pack:**
88
+ Build:
83
89
 
84
90
  ```bash
85
- # writes mypack.knolo
86
- npx knolo docs.json mypack.knolo
91
+ # writes knowledge.knolo
92
+ npx knolo docs.json knowledge.knolo
87
93
  ```
88
94
 
89
- **Query it in a script:**
95
+ Then load it in your app:
90
96
 
91
- ```js
97
+ ```ts
92
98
  import { mountPack, query } from "knolo-core";
93
-
94
- const kb = await mountPack({ src: "./mypack.knolo" });
99
+ const kb = await mountPack({ src: "./knowledge.knolo" });
95
100
  const hits = query(kb, "throttle events", { topK: 3 });
96
- console.log(hits);
97
101
  ```
98
102
 
99
- ---
100
-
101
- ### 3. React / Expo (load from asset)
103
+ ### 3) React / Expo
102
104
 
103
105
  ```ts
104
106
  import { Asset } from "expo-asset";
105
107
  import * as FileSystem from "expo-file-system";
106
- import { mountPack, query, makeContextPatch } from "knolo-core";
108
+ import { mountPack, query } from "knolo-core";
107
109
 
108
- async function loadKnowledge() {
109
- const asset = Asset.fromModule(require("./assets/mypack.knolo"));
110
+ async function loadKB() {
111
+ const asset = Asset.fromModule(require("./assets/knowledge.knolo"));
110
112
  await asset.downloadAsync();
111
113
 
112
114
  const base64 = await FileSystem.readAsStringAsync(asset.localUri!, { encoding: FileSystem.EncodingType.Base64 });
113
115
  const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
114
116
 
115
117
  const kb = await mountPack({ src: bytes.buffer });
116
- const hits = query(kb, "bridge throttling", { topK: 5 });
117
- return makeContextPatch(hits, { budget: "mini" });
118
+ return query(kb, `“react native” throttling`, { topK: 5 });
118
119
  }
119
120
  ```
120
121
 
121
122
  ---
122
123
 
123
- ## 📑 API Quick Reference
124
+ ## 📑 API
125
+
126
+ ### `buildPack(docs) -> Promise<Uint8Array>`
127
+
128
+ Builds a pack from an array of documents.
124
129
 
125
130
  ```ts
126
- // Build a pack from docs
127
- buildPack([{ heading: string, text: string }[]]) -> Promise<Uint8Array>
131
+ type BuildInputDoc = {
132
+ id?: string; // optional doc id (exposed as hit.source)
133
+ heading?: string; // optional heading (used for boosts)
134
+ text: string; // raw markdown accepted (lightly stripped)
135
+ };
136
+ ```
137
+
138
+ * Stores optional `heading` and `id` alongside each block.
139
+ * Computes and persists `meta.stats.avgBlockLen` for faster queries.
140
+
141
+ ### `mountPack({ src }) -> Promise<Pack>`
142
+
143
+ Loads a pack from a URL, `Uint8Array`, or `ArrayBuffer`.
144
+
145
+ ```ts
146
+ type Pack = {
147
+ meta: { version: number; stats: { docs: number; blocks: number; terms: number; avgBlockLen?: number } };
148
+ lexicon: Map<string, number>;
149
+ postings: Uint32Array;
150
+ blocks: string[];
151
+ headings?: (string | null)[];
152
+ docIds?: (string | null)[];
153
+ };
154
+ ```
155
+
156
+ > **Compatibility:** v0.2.0 reads both v1 packs (string-only blocks) and v2 packs (objects with `text/heading/docId`).
128
157
 
129
- // Load a pack
130
- mountPack({ src: string | Uint8Array | ArrayBuffer }) -> Promise<Pack>
158
+ ### `query(pack, q, opts) -> Hit[]`
131
159
 
132
- // Query
133
- query(pack, "your query", { topK?: number, requirePhrases?: string[] }) -> Hit[]
160
+ Deterministic lexical search with phrase enforcement, proximity, and de‑duplication.
134
161
 
135
- // Create LLM-friendly patch
136
- makeContextPatch(hits, { budget?: "mini" | "small" | "full" }) -> ContextPatch
162
+ ```ts
163
+ type QueryOptions = {
164
+ topK?: number; // default 10
165
+ requirePhrases?: string[]; // additional phrases to require (unquoted)
166
+ };
167
+
168
+ type Hit = {
169
+ blockId: number;
170
+ score: number;
171
+ text: string;
172
+ source?: string; // docId if provided at build time
173
+ };
174
+ ```
175
+
176
+ **What happens under the hood (v0.2.0):**
177
+
178
+ * Tokenize + **enforce all phrases** (quoted in `q` and `requirePhrases`)
179
+ * Candidate generation via inverted index
180
+ * **Proximity bonus** using minimal window covering all query terms
181
+ * Optional **heading overlap boost** (when headings are present)
182
+ * Tiny **KNS** numeric-signature tie‑breaker (\~±2% influence)
183
+ * **Near-duplicate suppression** (5‑gram Jaccard) + **MMR** diversity for top‑K
184
+
185
+ ### `makeContextPatch(hits, { budget }) -> ContextPatch`
186
+
187
+ Create structured snippets for LLM prompts.
188
+
189
+ ```ts
190
+ type ContextPatch = {
191
+ background: string[];
192
+ snippets: Array<{ text: string; source?: string }>;
193
+ definitions: Array<{ term: string; def: string; evidence?: number[] }>;
194
+ facts: Array<{ s: string; p: string; o: string; evidence?: number[] }>;
195
+ };
137
196
  ```
138
197
 
198
+ Budgets: `"mini" | "small" | "full"`.
199
+
200
+ ---
201
+
202
+ ## 🧠 Relevance & De‑dupe Details
203
+
204
+ * **Phrases:**
205
+ Quoted phrases in the query (e.g., `“react native”`) and any `requirePhrases` **must appear** in results. Candidates failing this are dropped before ranking.
206
+
207
+ * **Proximity:**
208
+ We compute the **minimum span** that covers all query terms and apply a gentle multiplier:
209
+ `1 + 0.15 / (1 + span)` (bounded, stable).
210
+
211
+ * **Heading Boost:**
212
+ If you provide headings at build time, overlap with query terms boosts the score proportionally to the fraction of unique query terms present in the heading.
213
+
214
+ * **Duplicate Control:**
215
+ We use **5‑gram Jaccard** to filter near‑duplicates and **MMR** (λ≈0.8) to promote diversity within the top‑K.
216
+
217
+ * **KNS Signature (optional spice):**
218
+ A tiny numeric signature provides deterministic tie‑breaking without changing the overall retrieval behavior.
219
+
220
+ ---
221
+
222
+ ## 🛠 Input Format & Pack Layout
223
+
224
+ **Input docs:**
225
+ `{ id?: string, heading?: string, text: string }`
226
+
227
+ **Pack layout (binary):**
228
+ `[metaLen:u32][meta JSON][lexLen:u32][lexicon JSON][postCount:u32][postings][blocksLen:u32][blocks JSON]`
229
+
230
+ * `meta.stats.avgBlockLen` is persisted (v2).
231
+ * `blocks JSON` may be:
232
+
233
+ * **v1:** `string[]` (text only)
234
+ * **v2:** `{ text, heading?, docId? }[]`
235
+
236
+ The runtime auto‑detects either format.
237
+
139
238
  ---
140
239
 
141
- ## 🔮 Roadmap
240
+ ## 🔁 Migration (0.1.x → 0.2.0)
241
+
242
+ * **No API breaks.** `buildPack`, `mountPack`, `query`, `makeContextPatch` unchanged.
243
+ * Packs built with 0.1.x still load and query fine.
244
+ * If you want heading boosts and `hit.source`, pass `heading` and `id` to `buildPack`.
245
+ * React Native/Expo users no longer need polyfills—ponyfills are included.
246
+
247
+ ---
248
+
249
+ ## ⚡ Performance Tips
250
+
251
+ * Prefer multiple smaller blocks (≈512 tokens) over giant ones for better recall + proximity.
252
+ * Provide `heading` for each block: cheap, high‑signal boost.
253
+ * For large corpora, consider sharding packs by domain/topic to keep per‑pack size modest.
254
+
255
+ ---
256
+
257
+ ## ❓ FAQ
258
+
259
+ **Q: Does this use embeddings?**
260
+ No. Pure lexical retrieval (index, positions, BM25L, proximity, phrases).
261
+
262
+ **Q: Can I run this offline?**
263
+ Yes. Everything is local.
264
+
265
+ **Q: How do I prevent duplicates?**
266
+ It’s built in (Jaccard + MMR). You can tune λ and similarity threshold in code if you fork.
267
+
268
+ **Q: Is RN/Expo supported?**
269
+ Yes—TextEncoder/TextDecoder ponyfills are included.
270
+
271
+ ---
272
+
273
+ ## 🗺️ Roadmap
142
274
 
143
275
  * Multi-resolution packs (summaries + facts)
144
- * Overlay store for user notes
145
- * WASM core for very large packs
276
+ * Overlay layers (user annotations)
277
+ * WASM core for big-browser indexing
278
+ * Delta updates / append-only patch packs
146
279
 
147
280
  ---
148
281
 
149
- 👉 With **KnoLo**, you can **carry knowledge with your model** — no servers, no dependencies, just a tiny portable pack.
282
+ ## 📄 License
283
+
284
+ Apache-2.0 — see [LICENSE](./LICENSE).
150
285
 
package/dist/builder.d.ts CHANGED
@@ -3,9 +3,4 @@ export type BuildInputDoc = {
3
3
  heading?: string;
4
4
  text: string;
5
5
  };
6
- /** Build a `.knolo` pack from an array of input documents. At present each
7
- * document becomes a single block. Future versions may split documents into
8
- * multiple blocks based on headings or token count to improve retrieval
9
- * granularity.
10
- */
11
6
  export declare function buildPack(docs: BuildInputDoc[]): Promise<Uint8Array>;
package/dist/builder.js CHANGED
@@ -1,34 +1,44 @@
1
1
  /*
2
2
  * builder.ts
3
3
  *
4
- * Provides a programmatic interface for building `.knolo` knowledge packs
5
- * from arrays of input documents. The input format accepts objects with
6
- * `id`, `heading` and `text` fields. The builder performs simple
7
- * Markdown stripping and calls the indexer to generate the inverted index. The
8
- * resulting pack binary can be persisted to disk or served directly to
9
- * clients.
4
+ * Build `.knolo` packs from input docs. Now persists optional headings/docIds
5
+ * and stores avgBlockLen in meta for faster/easier normalization at query-time.
10
6
  */
11
7
  import { buildIndex } from './indexer.js';
12
- /** Build a `.knolo` pack from an array of input documents. At present each
13
- * document becomes a single block. Future versions may split documents into
14
- * multiple blocks based on headings or token count to improve retrieval
15
- * granularity.
16
- */
8
+ import { tokenize } from './tokenize.js';
9
+ import { getTextEncoder } from './utils/utf8.js';
17
10
  export async function buildPack(docs) {
18
- // Convert docs into blocks. For v0 each doc is a single block; heading is
19
- // ignored except possibly for future heading boosting in ranking.
20
- const blocks = docs.map((d, i) => ({ id: i, text: stripMd(d.text), heading: d.heading }));
11
+ // Prepare blocks (strip MD) and carry heading/docId for optional boosts.
12
+ const blocks = docs.map((d, i) => ({
13
+ id: i,
14
+ text: stripMd(d.text),
15
+ heading: d.heading,
16
+ }));
17
+ // Build index
21
18
  const { lexicon, postings } = buildIndex(blocks);
19
+ // Compute avg token length once (store in meta)
20
+ const totalTokens = blocks.reduce((sum, b) => sum + tokenize(b.text).length, 0);
21
+ const avgBlockLen = blocks.length ? totalTokens / blocks.length : 1;
22
22
  const meta = {
23
- version: 1,
24
- stats: { docs: docs.length, blocks: blocks.length, terms: lexicon.length },
23
+ version: 2,
24
+ stats: {
25
+ docs: docs.length,
26
+ blocks: blocks.length,
27
+ terms: lexicon.length,
28
+ avgBlockLen,
29
+ },
25
30
  };
26
- // Encode sections to bytes
27
- const enc = new TextEncoder();
31
+ // Persist blocks as objects to optionally carry heading/docId
32
+ const blocksPayload = blocks.map((b, i) => ({
33
+ text: b.text,
34
+ heading: b.heading ?? null,
35
+ docId: docs[i]?.id ?? null,
36
+ }));
37
+ // Encode sections
38
+ const enc = getTextEncoder();
28
39
  const metaBytes = enc.encode(JSON.stringify(meta));
29
40
  const lexBytes = enc.encode(JSON.stringify(lexicon));
30
- const blocksBytes = enc.encode(JSON.stringify(blocks.map((b) => b.text)));
31
- // Compute lengths and allocate output
41
+ const blocksBytes = enc.encode(JSON.stringify(blocksPayload));
32
42
  const totalLength = 4 + metaBytes.length +
33
43
  4 + lexBytes.length +
34
44
  4 + postings.length * 4 +
@@ -61,19 +71,12 @@ export async function buildPack(docs) {
61
71
  }
62
72
  /** Strip Markdown syntax with lightweight regexes (no deps). */
63
73
  function stripMd(md) {
64
- // Remove code fences
65
- let text = md.replace(/```[^```]*```/g, ' ');
66
- // Remove inline code backticks
74
+ let text = md.replace(/```[\s\S]*?```/g, ' ');
67
75
  text = text.replace(/`[^`]*`/g, ' ');
68
- // Remove emphasis markers (*, _, ~)
69
76
  text = text.replace(/[\*_~]+/g, ' ');
70
- // Remove headings (#)
71
77
  text = text.replace(/^#+\s*/gm, '');
72
- // Links [text](url) -> text
73
78
  text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
74
- // Remove any remaining brackets
75
79
  text = text.replace(/[\[\]()]/g, ' ');
76
- // Collapse whitespace
77
80
  text = text.replace(/\s+/g, ' ').trim();
78
81
  return text;
79
82
  }
package/dist/pack.d.ts CHANGED
@@ -1,47 +1,21 @@
1
1
  export type MountOptions = {
2
2
  src: string | ArrayBufferLike | Uint8Array;
3
3
  };
4
- /** Metadata about the pack. Version numbers should increment with format
5
- * changes, allowing the runtime to adapt accordingly. */
6
4
  export type PackMeta = {
7
5
  version: number;
8
6
  stats: {
9
7
  docs: number;
10
8
  blocks: number;
11
9
  terms: number;
10
+ avgBlockLen?: number;
12
11
  };
13
12
  };
14
- /**
15
- * A mounted pack exposing the inverted index, block text and optional field
16
- * metadata. The core runtime reads from these structures directly at query
17
- * time.
18
- */
19
13
  export type Pack = {
20
14
  meta: PackMeta;
21
- /** Map of token to term identifier used in the postings list. */
22
15
  lexicon: Map<string, number>;
23
- /** Flattened postings list where each term section starts with the termId
24
- * followed by (blockId, positions..., 0) tuples, ending with a 0.
25
- */
26
16
  postings: Uint32Array;
27
- /** Array of block texts. Each block corresponds to a chunk of the original
28
- * documents. The blockId used in the postings list indexes into this array.
29
- */
30
17
  blocks: string[];
18
+ headings?: (string | null)[];
19
+ docIds?: (string | null)[];
31
20
  };
32
- /**
33
- * Load a `.knolo` pack from a variety of sources. The pack binary layout is
34
- * currently:
35
- *
36
- * [metaLen:u32][meta JSON][lexLen:u32][lexicon JSON][postCount:u32][postings][blocksLen:u32][blocks JSON]
37
- *
38
- * All integers are little endian. `metaLen`, `lexLen` and `blocksLen` denote
39
- * the byte lengths of the subsequent JSON sections. `postCount` is the number
40
- * of 32‑bit integers in the postings array. This simple layout is sufficient
41
- * for v0 and avoids any additional dependencies beyond standard typed arrays.
42
- *
43
- * @param opts Options specifying how to load the pack. Accepts a URL string,
44
- * ArrayBuffer, or Uint8Array.
45
- * @returns A Promise resolving to a mounted pack with the index and blocks.
46
- */
47
21
  export declare function mountPack(opts: MountOptions): Promise<Pack>;
package/dist/pack.js CHANGED
@@ -1,45 +1,31 @@
1
1
  /*
2
2
  * pack.ts
3
3
  *
4
- * Defines the pack structure and implements the mountPack function that loads
5
- * a `.knolo` pack from a variety of sources. A pack consists of a header
6
- * containing metadata, followed by JSON‑encoded lexicon and blocks and a
7
- * flattened postings list encoded as a 32‑bit integer array. The pack is
8
- * portable across Node.js and browser environments.
9
- */
10
- /**
11
- * Load a `.knolo` pack from a variety of sources. The pack binary layout is
12
- * currently:
13
- *
14
- * [metaLen:u32][meta JSON][lexLen:u32][lexicon JSON][postCount:u32][postings][blocksLen:u32][blocks JSON]
15
- *
16
- * All integers are little endian. `metaLen`, `lexLen` and `blocksLen` denote
17
- * the byte lengths of the subsequent JSON sections. `postCount` is the number
18
- * of 32‑bit integers in the postings array. This simple layout is sufficient
19
- * for v0 and avoids any additional dependencies beyond standard typed arrays.
20
- *
21
- * @param opts Options specifying how to load the pack. Accepts a URL string,
22
- * ArrayBuffer, or Uint8Array.
23
- * @returns A Promise resolving to a mounted pack with the index and blocks.
4
+ * Mount `.knolo` packs across Node, browsers, and RN/Expo. Now tolerant of:
5
+ * - blocks as string[] (v1) or object[] with { text, heading?, docId? } (v2)
6
+ * - meta.stats.avgBlockLen (optional)
7
+ * Includes RN/Expo-safe TextDecoder via ponyfill.
24
8
  */
9
+ import { getTextDecoder } from './utils/utf8.js';
25
10
  export async function mountPack(opts) {
26
11
  const buf = await resolveToBuffer(opts.src);
27
12
  const dv = new DataView(buf);
13
+ const dec = getTextDecoder();
28
14
  let offset = 0;
29
- // Read meta section
15
+ // meta
30
16
  const metaLen = dv.getUint32(offset, true);
31
17
  offset += 4;
32
- const metaJson = new TextDecoder().decode(new Uint8Array(buf, offset, metaLen));
18
+ const metaJson = dec.decode(new Uint8Array(buf, offset, metaLen));
33
19
  offset += metaLen;
34
20
  const meta = JSON.parse(metaJson);
35
- // Read lexicon
21
+ // lexicon
36
22
  const lexLen = dv.getUint32(offset, true);
37
23
  offset += 4;
38
- const lexJson = new TextDecoder().decode(new Uint8Array(buf, offset, lexLen));
24
+ const lexJson = dec.decode(new Uint8Array(buf, offset, lexLen));
39
25
  offset += lexLen;
40
26
  const lexEntries = JSON.parse(lexJson);
41
27
  const lexicon = new Map(lexEntries);
42
- // Read postings (alignment-safe via DataView)
28
+ // postings
43
29
  const postCount = dv.getUint32(offset, true);
44
30
  offset += 4;
45
31
  const postings = new Uint32Array(postCount);
@@ -47,29 +33,49 @@ export async function mountPack(opts) {
47
33
  postings[i] = dv.getUint32(offset, true);
48
34
  offset += 4;
49
35
  }
50
- // Read blocks
36
+ // blocks (v1: string[]; v2: {text, heading?, docId?}[])
51
37
  const blocksLen = dv.getUint32(offset, true);
52
38
  offset += 4;
53
- const blocksJson = new TextDecoder().decode(new Uint8Array(buf, offset, blocksLen));
54
- const blocks = JSON.parse(blocksJson);
55
- return { meta, lexicon, postings, blocks };
39
+ const blocksJson = dec.decode(new Uint8Array(buf, offset, blocksLen));
40
+ const parsed = JSON.parse(blocksJson);
41
+ let blocks = [];
42
+ let headings;
43
+ let docIds;
44
+ if (Array.isArray(parsed) && parsed.length && typeof parsed[0] === 'string') {
45
+ // v1
46
+ blocks = parsed;
47
+ }
48
+ else if (Array.isArray(parsed)) {
49
+ blocks = [];
50
+ headings = [];
51
+ docIds = [];
52
+ for (const it of parsed) {
53
+ if (it && typeof it === 'object') {
54
+ blocks.push(String(it.text ?? ''));
55
+ headings.push(it.heading ?? null);
56
+ docIds.push(it.docId ?? null);
57
+ }
58
+ else {
59
+ blocks.push(String(it ?? ''));
60
+ headings.push(null);
61
+ docIds.push(null);
62
+ }
63
+ }
64
+ }
65
+ else {
66
+ blocks = [];
67
+ }
68
+ return { meta, lexicon, postings, blocks, headings, docIds };
56
69
  }
57
- /** Resolve the `src` field of MountOptions into an ArrayBuffer. Supports:
58
- * - strings interpreted as URLs (via fetch)
59
- * - Uint8Array and ArrayBuffer inputs
60
- */
61
70
  async function resolveToBuffer(src) {
62
71
  if (typeof src === 'string') {
63
72
  const res = await fetch(src);
64
- const ab = await res.arrayBuffer();
65
- return ab;
73
+ return await res.arrayBuffer();
66
74
  }
67
75
  if (src instanceof Uint8Array) {
68
- // If the view covers the whole buffer, return it directly (cast to ArrayBuffer).
69
76
  if (src.byteOffset === 0 && src.byteLength === src.buffer.byteLength) {
70
77
  return src.buffer;
71
78
  }
72
- // Otherwise, copy to a new buffer so we return exactly the bytes for this view.
73
79
  const copy = src.slice();
74
80
  return copy.buffer;
75
81
  }
@@ -0,0 +1,13 @@
1
+ export type HitLike = {
2
+ blockId: number;
3
+ score: number;
4
+ text: string;
5
+ source?: string;
6
+ };
7
+ export type DiversifyOptions = {
8
+ k: number;
9
+ lambda?: number;
10
+ simThreshold?: number;
11
+ sim?: (a: HitLike, b: HitLike) => number;
12
+ };
13
+ export declare function diversifyAndDedupe(hits: HitLike[], opts: DiversifyOptions): HitLike[];
@@ -0,0 +1,41 @@
1
+ // src/quality/diversify.ts
2
+ import { jaccard5 } from './similarity.js';
3
+ export function diversifyAndDedupe(hits, opts) {
4
+ const { k, lambda = 0.8, simThreshold = 0.92, sim = (a, b) => jaccard5(a.text, b.text) } = opts;
5
+ const pool = [...hits].sort((a, b) => b.score - a.score);
6
+ const kept = [];
7
+ while (pool.length && kept.length < k) {
8
+ // compute MMR for current pool against kept
9
+ let bestIdx = 0;
10
+ let bestMMR = -Infinity;
11
+ for (let i = 0; i < pool.length; i++) {
12
+ const h = pool[i];
13
+ let maxSim = 0;
14
+ for (const s of kept) {
15
+ const v = sim(h, s);
16
+ if (v > maxSim)
17
+ maxSim = v;
18
+ if (v >= simThreshold) {
19
+ maxSim = v;
20
+ break;
21
+ } // early out
22
+ }
23
+ // skip near-duplicates
24
+ if (maxSim >= simThreshold)
25
+ continue;
26
+ const mmr = lambda * h.score - (1 - lambda) * maxSim;
27
+ if (mmr > bestMMR) {
28
+ bestMMR = mmr;
29
+ bestIdx = i;
30
+ }
31
+ }
32
+ // if everything was a near-duplicate, just take the next best by score
33
+ const pick = pool.splice(bestMMR === -Infinity ? 0 : bestIdx, 1)[0];
34
+ if (!pick)
35
+ break;
36
+ // final dedupe check before push
37
+ if (!kept.some((x) => sim(x, pick) >= simThreshold))
38
+ kept.push(pick);
39
+ }
40
+ return kept;
41
+ }
@@ -0,0 +1,2 @@
1
+ export declare function minCoverSpan(posMap?: Map<number, number[]>): number | null;
2
+ export declare function proximityMultiplier(span: number | null, strength?: number): number;