@hraness/wordcell 0.23.0 → 0.24.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.
@@ -0,0 +1,409 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /** Maximum UTF-8 bytes in one selected passage; matches the reranker snippet budget. */
4
+ export const SELECTED_PASSAGE_MAX_BYTES = 512;
5
+ /** Maximum UTF-8 bytes of one heading line returned as section context. */
6
+ export const SELECTED_PASSAGE_MAX_HEADING_BYTES = 256;
7
+ /** Notes larger than this report `source-limit` instead of being scanned. */
8
+ export const SELECTED_PASSAGE_MAX_SOURCE_BYTES = 2 * 1024 * 1024;
9
+ /** Total note bytes one search may scan for selected passages. */
10
+ export const SELECTED_PASSAGE_MAX_SEARCH_BYTES = 8 * 1024 * 1024;
11
+ const MAX_QUERY_BYTES = 16 * 1024;
12
+ const MAX_QUERY_TERMS = 64;
13
+ const MAX_CANDIDATES = 10_000;
14
+ const WORD = /[\p{L}\p{N}][\p{L}\p{N}\p{M}]*/gu;
15
+ // Generic grammatical words, not corpus terms or benchmark-specific expansions.
16
+ const STOP_WORDS = new Set((
17
+ "a an and are as at be been being but by can could did do does doing each either "
18
+ + "for from had has have having he her here hers herself him himself his how i "
19
+ + "if in into is it its itself may me might mine more most must my myself neither "
20
+ + "no nor not of on once only or our ours ourselves out own same she should so "
21
+ + "some such than that the their theirs them themselves then there these they "
22
+ + "this those through to too under until up us very was we were what when where "
23
+ + "which while who whom why will with would you your yours yourself yourselves"
24
+ ).split(" "));
25
+
26
+ /** One enclosing ATX heading line, returned as its own exact source span. */
27
+ export type SelectedPassageHeading = Readonly<{
28
+ level: number;
29
+ text: string;
30
+ /** Half-open offsets into the UTF-8 encoding of the note snapshot. */
31
+ startByte: number;
32
+ endByte: number;
33
+ startLine: number;
34
+ endLine: number;
35
+ /** True when the heading line was shortened to the heading byte budget. */
36
+ truncated: boolean;
37
+ }>;
38
+
39
+ export type SelectedPassage = Readonly<{
40
+ status: "selected";
41
+ /** Exact contiguous source text; never rewritten, joined, or prefixed. */
42
+ text: string;
43
+ /** Half-open offsets into the UTF-8 encoding of the note snapshot. */
44
+ startByte: number;
45
+ endByte: number;
46
+ /** One-based inclusive lines occupied by the selected bytes. */
47
+ startLine: number;
48
+ endLine: number;
49
+ /** SHA-256 of the UTF-8 encoded snapshot text these offsets address. */
50
+ sourceSha256: string;
51
+ /** The snapshot is decoded text re-encoded as UTF-8, so a leading BOM is absent. */
52
+ sourceEncoding: "utf8-snapshot";
53
+ /** True when the passage starts or ends inside a longer paragraph. */
54
+ clippedStart: boolean;
55
+ clippedEnd: boolean;
56
+ /** Enclosing headings from outermost to innermost, excluding any heading inside `text`. */
57
+ headings: readonly SelectedPassageHeading[];
58
+ }>;
59
+
60
+ export type SelectedPassageNone = Readonly<{
61
+ status: "none";
62
+ reason: "no-query-terms" | "no-lexical-match";
63
+ }>;
64
+
65
+ export type SelectedPassageUnavailable = Readonly<{
66
+ status: "unavailable";
67
+ reason:
68
+ | "query-limit"
69
+ | "source-limit"
70
+ | "search-budget"
71
+ | "candidate-limit"
72
+ | "grapheme-limit"
73
+ | "invalid-source"
74
+ | "display-sanitized";
75
+ }>;
76
+
77
+ export type SelectedPassageResult = SelectedPassage | SelectedPassageNone | SelectedPassageUnavailable;
78
+
79
+ export const SELECTED_PASSAGE_NONE_REASONS: readonly SelectedPassageNone["reason"][] = Object.freeze([
80
+ "no-query-terms",
81
+ "no-lexical-match",
82
+ ]);
83
+
84
+ export const SELECTED_PASSAGE_UNAVAILABLE_REASONS: readonly SelectedPassageUnavailable["reason"][] = Object.freeze([
85
+ "query-limit",
86
+ "source-limit",
87
+ "search-budget",
88
+ "candidate-limit",
89
+ "grapheme-limit",
90
+ "invalid-source",
91
+ "display-sanitized",
92
+ ]);
93
+
94
+ /** Tracks the note bytes one search has scanned. */
95
+ export type SelectedPassageBudget = {
96
+ readonly take: (bytes: number) => boolean;
97
+ };
98
+
99
+ export function createSelectedPassageBudget(
100
+ maximumBytes = SELECTED_PASSAGE_MAX_SEARCH_BYTES,
101
+ ): SelectedPassageBudget {
102
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) {
103
+ throw new RangeError("Selected passage budget must be a non-negative safe integer.");
104
+ }
105
+ let remaining = maximumBytes;
106
+ return Object.freeze({
107
+ take: (bytes: number) => {
108
+ if (bytes > remaining) return false;
109
+ remaining -= bytes;
110
+ return true;
111
+ },
112
+ });
113
+ }
114
+
115
+ function unavailable(reason: SelectedPassageUnavailable["reason"]): SelectedPassageUnavailable {
116
+ return Object.freeze({ status: "unavailable", reason });
117
+ }
118
+
119
+ function none(reason: SelectedPassageNone["reason"]): SelectedPassageNone {
120
+ return Object.freeze({ status: "none", reason });
121
+ }
122
+
123
+ function normalize(value: string): string {
124
+ return value.normalize("NFC").toLocaleLowerCase("en-US");
125
+ }
126
+
127
+ function wellFormed(value: string): boolean {
128
+ return !/\p{Surrogate}/u.test(value);
129
+ }
130
+
131
+ function lowerBound(values: readonly number[], target: number): number {
132
+ let low = 0;
133
+ let high = values.length;
134
+ while (low < high) {
135
+ const middle = (low + high) >>> 1;
136
+ if (values[middle]! < target) low = middle + 1;
137
+ else high = middle;
138
+ }
139
+ return low;
140
+ }
141
+
142
+ function upperBound(values: readonly number[], target: number): number {
143
+ let low = 0;
144
+ let high = values.length;
145
+ while (low < high) {
146
+ const middle = (low + high) >>> 1;
147
+ if (values[middle]! <= target) low = middle + 1;
148
+ else high = middle;
149
+ }
150
+ return low;
151
+ }
152
+
153
+ type Paragraph = { start: number; end: number; headingOnly: boolean };
154
+ type HeadingLine = { start: number; end: number; level: number; line: number };
155
+
156
+ function sourceLayout(text: string): {
157
+ bodyStart: number;
158
+ lineStarts: number[];
159
+ paragraphs: Paragraph[];
160
+ headings: HeadingLine[];
161
+ } {
162
+ const lineStarts = [0];
163
+ for (let index = text.indexOf("\n"); index >= 0; index = text.indexOf("\n", index + 1)) {
164
+ lineStarts.push(index + 1);
165
+ }
166
+ const lineEnd = (index: number): number => {
167
+ const next = lineStarts[index + 1];
168
+ let end = next === undefined ? text.length : next - 1;
169
+ if (end > lineStarts[index]! && text[end - 1] === "\r") end--;
170
+ return end;
171
+ };
172
+ let firstBodyLine = 0;
173
+ if (text.slice(0, lineEnd(0)).trim() === "---") {
174
+ for (let index = 1; index < lineStarts.length; index++) {
175
+ if (text.slice(lineStarts[index], lineEnd(index)).trim() === "---") {
176
+ firstBodyLine = index + 1;
177
+ break;
178
+ }
179
+ }
180
+ }
181
+ const bodyStart = lineStarts[firstBodyLine] ?? text.length;
182
+ const paragraphs: Paragraph[] = [];
183
+ const headings: HeadingLine[] = [];
184
+ let fence: { character: string; length: number } | undefined;
185
+ let current: Paragraph | undefined;
186
+ for (let index = firstBodyLine; index < lineStarts.length; index++) {
187
+ const start = lineStarts[index]!;
188
+ const end = lineEnd(index);
189
+ const line = text.slice(start, end);
190
+ if (line.trim() === "") {
191
+ if (current !== undefined) paragraphs.push(current);
192
+ current = undefined;
193
+ continue;
194
+ }
195
+ const delimiter = /^\s{0,3}(`{3,}|~{3,})/u.exec(line)?.[1];
196
+ const marker = fence === undefined ? /^\s{0,3}(#{1,6})(?:\s|$)/u.exec(line)?.[1] : undefined;
197
+ const heading = marker !== undefined;
198
+ if (heading) headings.push({ start, end, level: marker.length, line: index + 1 });
199
+ if (delimiter !== undefined) {
200
+ if (fence === undefined) fence = { character: delimiter[0]!, length: delimiter.length };
201
+ else if (delimiter[0] === fence.character && delimiter.length >= fence.length
202
+ && line.trim() === delimiter) fence = undefined;
203
+ }
204
+ if (current === undefined) current = { start, end, headingOnly: heading };
205
+ else {
206
+ current.end = end;
207
+ current.headingOnly &&= heading;
208
+ }
209
+ }
210
+ if (current !== undefined) paragraphs.push(current);
211
+ return { bodyStart, lineStarts, paragraphs, headings };
212
+ }
213
+
214
+ /** Longest grapheme-aligned prefix of `value` within `maximumBytes` UTF-8 bytes. */
215
+ function graphemePrefix(value: string, maximumBytes: number): { text: string; truncated: boolean } {
216
+ if (Buffer.byteLength(value, "utf8") <= maximumBytes) return { text: value, truncated: false };
217
+ let end = 0;
218
+ let bytes = 0;
219
+ for (const part of new Intl.Segmenter("en-US", { granularity: "grapheme" }).segment(value)) {
220
+ const width = Buffer.byteLength(part.segment, "utf8");
221
+ if (bytes + width > maximumBytes) break;
222
+ bytes += width;
223
+ end = part.index + part.segment.length;
224
+ }
225
+ return { text: value.slice(0, end), truncated: true };
226
+ }
227
+
228
+ function enclosingHeadings(
229
+ text: string,
230
+ headings: readonly HeadingLine[],
231
+ passageStart: number,
232
+ passageEnd: number,
233
+ ): readonly SelectedPassageHeading[] {
234
+ const stack: HeadingLine[] = [];
235
+ const pop = (level: number): void => {
236
+ while (stack.length > 0 && stack[stack.length - 1]!.level >= level) stack.pop();
237
+ };
238
+ for (const heading of headings) {
239
+ if (heading.start >= passageEnd) break;
240
+ if (heading.start >= passageStart) {
241
+ // Leading headings inside the passage close earlier sections but are already in the text.
242
+ const before = text.slice(passageStart, heading.start).split("\n");
243
+ if (!before.every((line) => line.trim() === "" || /^\s{0,3}#{1,6}(?:\s|$)/u.test(line))) break;
244
+ pop(heading.level);
245
+ continue;
246
+ }
247
+ pop(heading.level);
248
+ stack.push(heading);
249
+ }
250
+ return Object.freeze(stack.map((heading) => {
251
+ // A window may begin inside a long heading line; the context span stops where the passage starts.
252
+ const end = Math.min(heading.end, passageStart);
253
+ const clipped = graphemePrefix(text.slice(heading.start, end), SELECTED_PASSAGE_MAX_HEADING_BYTES);
254
+ const startByte = Buffer.byteLength(text.slice(0, heading.start), "utf8");
255
+ return Object.freeze({
256
+ level: heading.level,
257
+ text: clipped.text,
258
+ startByte,
259
+ endByte: startByte + Buffer.byteLength(clipped.text, "utf8"),
260
+ startLine: heading.line,
261
+ endLine: heading.line,
262
+ truncated: clipped.truncated || end < heading.end,
263
+ });
264
+ }));
265
+ }
266
+
267
+ type Candidate = {
268
+ start: number;
269
+ end: number;
270
+ startByte: number;
271
+ endByte: number;
272
+ regionStart: number;
273
+ regionEnd: number;
274
+ score: number;
275
+ };
276
+
277
+ /**
278
+ * Select one exact passage from a note that search already returned. Ranking,
279
+ * retrieval evidence, and reranker input are unaffected.
280
+ *
281
+ * Passages score by distinct non-stop-word query terms with a small density
282
+ * tie-break, so term order and repetition do not matter. Closed opening
283
+ * frontmatter is skipped, headings travel with the following paragraph instead
284
+ * of scoring on their own when body text exists, fenced code stays eligible,
285
+ * and paragraphs longer than the budget use half-overlapping grapheme-aligned
286
+ * byte windows. Every bound reports a status rather than a partial scan.
287
+ */
288
+ export function selectSearchPassage(input: {
289
+ readonly query: string;
290
+ readonly text: string;
291
+ readonly budget: SelectedPassageBudget;
292
+ }): SelectedPassageResult {
293
+ const { query, text, budget } = input;
294
+ if (typeof query !== "string" || Buffer.byteLength(query, "utf8") > MAX_QUERY_BYTES) {
295
+ return unavailable("query-limit");
296
+ }
297
+ if (typeof text !== "string" || !wellFormed(text)) return unavailable("invalid-source");
298
+ const allTerms = new Set([...normalize(query).matchAll(WORD)].map((match) => match[0]));
299
+ if (allTerms.size > MAX_QUERY_TERMS) return unavailable("query-limit");
300
+ const terms = new Set([...allTerms].filter((term) => !STOP_WORDS.has(term)));
301
+ if (terms.size === 0) return none("no-query-terms");
302
+ const sourceBytes = Buffer.byteLength(text, "utf8");
303
+ if (sourceBytes > SELECTED_PASSAGE_MAX_SOURCE_BYTES) return unavailable("source-limit");
304
+ if (!budget.take(sourceBytes)) return unavailable("search-budget");
305
+ if (text.length === 0) return none("no-lexical-match");
306
+
307
+ const maxBytes = SELECTED_PASSAGE_MAX_BYTES;
308
+ const { bodyStart, lineStarts, paragraphs, headings } = sourceLayout(text);
309
+ const headingStarts = new Set(headings.map(({ start }) => start));
310
+ const headingOnlyFile = paragraphs.every((paragraph) => paragraph.headingOnly);
311
+ const positions = [bodyStart];
312
+ const bytePositions = [Buffer.byteLength(text.slice(0, bodyStart), "utf8")];
313
+ let bytes = bytePositions[0]!;
314
+ for (const part of new Intl.Segmenter("en-US", { granularity: "grapheme" }).segment(text.slice(bodyStart))) {
315
+ const width = Buffer.byteLength(part.segment, "utf8");
316
+ if (width > maxBytes) return unavailable("grapheme-limit");
317
+ bytes += width;
318
+ positions.push(bodyStart + part.index + part.segment.length);
319
+ bytePositions.push(bytes);
320
+ }
321
+
322
+ const tokens: { start: number; end: number; term: string | undefined }[] = [];
323
+ const tokenStarts: number[] = [];
324
+ for (const match of text.slice(bodyStart).matchAll(WORD)) {
325
+ const start = bodyStart + match.index;
326
+ const term = normalize(match[0]);
327
+ const inHeading = headingStarts.has(lineStarts[upperBound(lineStarts, start) - 1]!);
328
+ tokenStarts.push(start);
329
+ tokens.push({
330
+ start,
331
+ end: start + match[0].length,
332
+ term: terms.has(term) && (headingOnlyFile || !inHeading) ? term : undefined,
333
+ });
334
+ }
335
+
336
+ const regions: { start: number; end: number }[] = [];
337
+ let pendingHeading: Paragraph | undefined;
338
+ for (const paragraph of paragraphs) {
339
+ if (paragraph.headingOnly) {
340
+ pendingHeading = pendingHeading === undefined
341
+ ? paragraph
342
+ : { ...pendingHeading, end: paragraph.end };
343
+ } else {
344
+ regions.push({ start: pendingHeading?.start ?? paragraph.start, end: paragraph.end });
345
+ pendingHeading = undefined;
346
+ }
347
+ }
348
+ if (regions.length === 0 && pendingHeading !== undefined) regions.push(pendingHeading);
349
+
350
+ let best: Candidate | undefined;
351
+ let considered = 0;
352
+ for (const region of regions) {
353
+ // Line and paragraph boundaries must also be grapheme boundaries; CRLF stays whole.
354
+ const first = lowerBound(positions, region.start);
355
+ const last = upperBound(positions, region.end) - 1;
356
+ if (first >= last) continue;
357
+ let startIndex = first;
358
+ for (;;) {
359
+ const endIndex = Math.min(last, upperBound(bytePositions, bytePositions[startIndex]! + maxBytes) - 1);
360
+ if (endIndex <= startIndex) return unavailable("grapheme-limit");
361
+ if (++considered > MAX_CANDIDATES) return unavailable("candidate-limit");
362
+ const start = positions[startIndex]!;
363
+ const end = positions[endIndex]!;
364
+ const matched = new Set<string>();
365
+ let tokenCount = 0;
366
+ for (let index = lowerBound(tokenStarts, start); index < tokens.length; index++) {
367
+ const token = tokens[index]!;
368
+ if (token.start >= end) break;
369
+ if (token.end > end) continue;
370
+ tokenCount++;
371
+ if (token.term !== undefined) matched.add(token.term);
372
+ }
373
+ if (matched.size > 0) {
374
+ const score = matched.size + matched.size / (tokenCount + 1) / 10;
375
+ // Candidates arrive in source order, so a strict comparison keeps the earliest tie.
376
+ if (best === undefined || score > best.score) {
377
+ best = {
378
+ start,
379
+ end,
380
+ startByte: bytePositions[startIndex]!,
381
+ endByte: bytePositions[endIndex]!,
382
+ regionStart: positions[first]!,
383
+ regionEnd: positions[last]!,
384
+ score,
385
+ };
386
+ }
387
+ }
388
+ if (endIndex === last) break;
389
+ const stepped = lowerBound(bytePositions, bytePositions[startIndex]! + Math.max(1, Math.floor(maxBytes / 2)));
390
+ const finalStart = lowerBound(bytePositions, bytePositions[last]! - maxBytes);
391
+ startIndex = Math.max(startIndex + 1, Math.min(stepped, finalStart));
392
+ }
393
+ }
394
+ if (best === undefined) return none("no-lexical-match");
395
+
396
+ return Object.freeze({
397
+ status: "selected",
398
+ text: text.slice(best.start, best.end),
399
+ startByte: best.startByte,
400
+ endByte: best.endByte,
401
+ startLine: upperBound(lineStarts, best.start),
402
+ endLine: upperBound(lineStarts, best.end - 1),
403
+ sourceSha256: createHash("sha256").update(text, "utf8").digest("hex"),
404
+ sourceEncoding: "utf8-snapshot",
405
+ clippedStart: best.start > best.regionStart,
406
+ clippedEnd: best.end < best.regionEnd,
407
+ headings: enclosingHeadings(text, headings, best.start, best.end),
408
+ });
409
+ }