fux-engine 2.0.0-alpha.7 → 2.0.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.
@@ -1,263 +0,0 @@
1
- /** Passages, derived from the document's own heading DEPTH.
2
- * Twin of `src/fux/refer/_chunk.py`.
3
- *
4
- * **There is no strategy parameter and no CHUNK to declare.** What a passage
5
- * is — a table row, a slide, a prose section, the whole file — falls out of
6
- * heading depth, so a caller cannot get it wrong because there is nothing to
7
- * get wrong.
8
- *
9
- * Deterministic and TOTAL: every byte lands in exactly one passage, with one
10
- * documented exception — a banded table repeats its header into every band,
11
- * because a row whose columns have no names is a citation nobody can read.
12
- *
13
- * ⚠ **Transient. Never written.** L2: chunks are re-derived from fetched bytes
14
- * at answer time and stored nowhere.
15
- */
16
- import { headings } from "../decode/markdown.mjs";
17
-
18
- export const MIN_PASSAGE_BYTES = 120;
19
- export const MAX_PASSAGE_BYTES = 4000;
20
- /** Measured 2026-09-06: row-per-passage took hit@1 from 0.229 to 0.875. */
21
- export const TABLE_ROWS_PER_PASSAGE = 1;
22
-
23
- const TABLE_ROW_RE = /^\s*\|/;
24
- const TABLE_SEP_RE = /^\s*\|[\s:|-]+\|?\s*$/;
25
-
26
- const enc = new TextEncoder();
27
- const nbytes = (s) => enc.encode(s).length;
28
- const countNl = (s) => { let n = 0; for (let i = 0; i < s.length; i++) if (s[i] === "\n") n++; return n; };
29
-
30
- /** `[heading, level, text, lineStart, lineEnd]`, 1-based and inclusive.
31
- * Line numbers are tracked HERE rather than recovered later, because the text
32
- * is stripped and the offset would be gone with the blank lines. */
33
- function sections(content) {
34
- // `split("\n")`, never a line-splitter that also breaks on \r \v \f U+2028:
35
- // the heading grammar splits the same way, so its line numbers index this
36
- // list exactly, and any other splitter desynchronises the two.
37
- const lines = content.split("\n");
38
- const starts = new Map();
39
- for (const h of headings(content)) starts.set(h.lineno, h);
40
-
41
- // Level 0 is the preamble — not a heading of level 0, the ABSENCE of one,
42
- // which is right because nothing can be nested inside it.
43
- const acc = [["", 0, [], 1]];
44
- for (let i = 0; i < lines.length; i++) {
45
- const found = starts.get(i + 1);
46
- if (found !== undefined) acc.push([found.text, found.level, [lines[i]], i + 1]);
47
- else acc[acc.length - 1][2].push(lines[i]);
48
- }
49
-
50
- const out = [];
51
- for (const [heading, level, ls, start] of acc) {
52
- const joined = ls.join("\n");
53
- if (!joined.trim()) continue;
54
- const leading = ls.length - joined.replace(/^\n+/, "").split("\n").length;
55
- const text = joined.replace(/^\n+/, "").replace(/\n+$/, "");
56
- const realStart = start + Math.max(0, leading);
57
- out.push([heading, level, text, realStart, realStart + countNl(text)]);
58
- }
59
- return out;
60
- }
61
-
62
- /** Index of the document TITLE section, or -1. Three structural conditions:
63
- * first headed section, strictly shallower than every other, and NO BODY of
64
- * its own. The third does the work — `# deck.pptx` above a run of slides is a
65
- * name for the file; `## Notes` above `### Detail` is a real section. */
66
- function titleIndex(secs) {
67
- const headed = [];
68
- for (let i = 0; i < secs.length; i++) if (secs[i][0]) headed.push(i);
69
- if (headed.length < 2) return -1;
70
- const first = headed[0];
71
- const [, level, text] = secs[first];
72
- if (text.trim() !== text.trim().split("\n")[0].trim()) return -1; // has a body
73
- return headed.slice(1).every((i) => secs[i][1] > level) ? first : -1;
74
- }
75
-
76
- /** Fold a short section forward **only into a section NESTED INSIDE it**.
77
- *
78
- * 🔴 Depth, not size. The old rule folded on size alone, so a short slide
79
- * between two long ones was cited under its NEIGHBOUR's heading —
80
- * systematically the wrong attribution, which is worse than a coarse citation
81
- * because it is confidently wrong. Siblings never fold, so a lone short
82
- * sibling cannot be absorbed.
83
- *
84
- * ⚠ The PARENT's heading survives the fold, with one exception: the document
85
- * TITLE carries its text but never its name, or `# deck.pptx` ends up cited as
86
- * the source of slide 1's content. */
87
- function fold(secs, minPassageBytes = MIN_PASSAGE_BYTES) {
88
- const out = [];
89
- let carry = [], carryHeading = "", carryLevel = 0, carryStart = 0;
90
- const title = titleIndex(secs);
91
- for (let index = 0; index < secs.length; index++) {
92
- const [heading, level, text, start, end] = secs[index];
93
- const nxt = index + 1 < secs.length ? secs[index + 1] : null;
94
- const short = nbytes(text) < minPassageBytes;
95
- // Strictly deeper: `>` and never `>=` — `>=` is the sibling case, and the
96
- // sibling case is the defect.
97
- const nested = nxt !== null && nxt[1] > level;
98
- if (short && nested) {
99
- if (!carry.length) carryStart = start;
100
- if (!carryHeading && index !== title) { carryHeading = heading; carryLevel = level; }
101
- carry.push(text);
102
- continue;
103
- }
104
- if (carry.length) {
105
- out.push([carryHeading || heading, carryLevel || level,
106
- [...carry, text].join("\n\n"), carryStart, end]);
107
- carry = []; carryHeading = ""; carryLevel = 0;
108
- } else {
109
- out.push([heading, level, text, start, end]);
110
- }
111
- }
112
- if (carry.length) {
113
- // Unreachable — a section only carries when a DEEPER one follows it. Kept
114
- // total rather than asserted: a chunker that raises loses the document.
115
- out.push([carryHeading, carryLevel, carry.join("\n\n"), carryStart || 1, secs[secs.length - 1][4]]);
116
- }
117
- return out;
118
- }
119
-
120
- /** A Markdown table split into row passages, or `null` if not a table.
121
- * The header and its separator are repeated into every band. */
122
- function tableBands(paragraph) {
123
- const lines = paragraph.split("\n");
124
- if (lines.length < 3) return null;
125
- for (const line of lines) if (line.trim() && !TABLE_ROW_RE.test(line)) return null;
126
-
127
- const header = lines[0];
128
- const separator = TABLE_SEP_RE.test(lines[1]) ? lines[1] : null;
129
- const prefix = separator ? [header, separator] : [header];
130
- const body = lines.slice(prefix.length);
131
- if (!body.length) return null;
132
-
133
- const bands = [];
134
- for (let i = 0; i < body.length; i += TABLE_ROWS_PER_PASSAGE) {
135
- const rows = body.slice(i, i + TABLE_ROWS_PER_PASSAGE);
136
- bands.push([[...prefix, ...rows].join("\n"), rows.length]);
137
- }
138
- if (bands.length < 2) return null; // nothing gained; leave it ordinary
139
- bands[0] = [bands[0][0], bands[0][1] + prefix.length];
140
- return bands;
141
- }
142
-
143
- /** One oversized paragraph -> `[piece, sourceLines, rung]`.
144
- *
145
- * **There is no sentence rung.** UAX #29 says plain text gives inadequate
146
- * information for sentence boundaries; doing it properly needs CLDR locale
147
- * data — a dependency (L1) — and doing it improperly cuts inside `e.g.`,
148
- * `Dr.` and `3.5`. So: paragraph -> line -> word, boundaries that need no
149
- * knowledge of any language. */
150
- function descend(paragraph, maxPassageBytes) {
151
- const lines = paragraph.split("\n");
152
- if (lines.length > 1) {
153
- const out = [];
154
- let current = [], size = 0;
155
- for (const line of lines) {
156
- const lineSize = nbytes(line) + 1;
157
- if (current.length && size + lineSize > maxPassageBytes) {
158
- out.push([current.join("\n"), current.length, "line"]);
159
- current = []; size = 0;
160
- }
161
- current.push(line); size += lineSize;
162
- }
163
- if (current.length) out.push([current.join("\n"), current.length, "line"]);
164
- if (out.length > 1) return out;
165
- // One line's worth after all — fall through to the word rung rather than
166
- // returning the same oversized piece under a different name.
167
- paragraph = out.length ? out[0][0] : paragraph;
168
- }
169
-
170
- const words = paragraph.split(" ");
171
- if (words.length < 2) return [[paragraph, countNl(paragraph) + 1, ""]];
172
- const out = [];
173
- let current = [], size = 0;
174
- for (const word of words) {
175
- const wordSize = nbytes(word) + 1;
176
- if (current.length && size + wordSize > maxPassageBytes) {
177
- out.push([current.join(" "), 1, "word"]);
178
- current = []; size = 0;
179
- }
180
- current.push(word); size += wordSize;
181
- }
182
- if (current.length) out.push([current.join(" "), 1, "word"]);
183
- return out;
184
- }
185
-
186
- /** `[piece, lineOffset, sourceLines, rung]` — the oversized split.
187
- * `sourceLines` is tracked separately from a piece's own line count because a
188
- * banded table repeats its header: the band holds more lines than it covers. */
189
- function pieces(text, maxPassageBytes = MAX_PASSAGE_BYTES) {
190
- const out = [];
191
- let cursor = 0, current = [], size = 0;
192
-
193
- const flush = () => {
194
- if (!current.length) return;
195
- const piece = current.join("\n\n");
196
- const span = countNl(piece) + 1;
197
- out.push([piece, cursor, span, ""]);
198
- cursor += span + 1; // the blank line that separated this piece from the next
199
- current = []; size = 0;
200
- };
201
-
202
- for (const paragraph of text.split("\n\n")) {
203
- const paragraphSize = nbytes(paragraph) + 2;
204
- // ⚠ Tables split at EVERY size, not only when oversized. A ten-row table
205
- // is ten answers, and it simply never crossed the byte ceiling to be
206
- // noticed — that was the coarse-citation defect.
207
- const bands = tableBands(paragraph);
208
- if (bands !== null) {
209
- if (current.length) {
210
- const [head, span] = bands[0];
211
- const pending = current.join("\n\n");
212
- bands[0] = [pending + "\n\n" + head, countNl(pending) + 1 + 1 + span];
213
- current = []; size = 0;
214
- }
215
- for (const [band, span] of bands) { out.push([band, cursor, span, ""]); cursor += span; }
216
- cursor += 1; // ...but a blank line does follow the table itself
217
- continue;
218
- }
219
- if (paragraphSize > maxPassageBytes) {
220
- flush();
221
- for (const [piece, span, rung] of descend(paragraph, maxPassageBytes)) {
222
- out.push([piece, cursor, span, rung]);
223
- cursor += span;
224
- }
225
- cursor += 1;
226
- continue;
227
- }
228
- if (current.length && size + paragraphSize > maxPassageBytes) flush();
229
- current.push(paragraph); size += paragraphSize;
230
- }
231
- flush();
232
- return out;
233
- }
234
-
235
- /** Split into passages, in document order.
236
- * `lineNumbers=false` suppresses the range for a document whose text was
237
- * GENERATED rather than read — a `.docx`'s Markdown exists nowhere on disk,
238
- * so a line number would be a confident lie. */
239
- export function chunk(content, {
240
- minPassageBytes = MIN_PASSAGE_BYTES,
241
- maxPassageBytes = MAX_PASSAGE_BYTES,
242
- lineNumbers = true,
243
- } = {}) {
244
- const merged = fold(sections(content), minPassageBytes);
245
- const passages = [];
246
- for (const [heading, , text, start, end] of merged) {
247
- for (const [piece, offset, span, rung] of pieces(text, maxPassageBytes)) {
248
- const pieceStart = start + offset;
249
- passages.push({
250
- heading,
251
- text: piece,
252
- ordinal: passages.length,
253
- line_start: lineNumbers ? pieceStart : 0,
254
- line_end: lineNumbers ? Math.min(end, pieceStart + span - 1) : 0,
255
- cut: rung,
256
- nbytes: nbytes(piece),
257
- });
258
- }
259
- }
260
- return passages;
261
- }
262
-
263
- export { nbytes };
@@ -1,51 +0,0 @@
1
- /** The five verdicts, and what each actually claims.
2
- * Twin of `src/fux/refer/freshness.py`, minus everything that fetches.
3
- *
4
- * 🔴 **Node never fetches** (W-107 R6), so only three of the five are
5
- * reachable here: `current`/`stale` from reading the local checkout, and
6
- * `as-ingested`/`unverified` for a `url:` document. `cached` requires a TTL
7
- * fetch cache, which requires fetching.
8
- */
9
-
10
- export const CURRENT = "current";
11
- export const STALE = "stale";
12
- export const CACHED = "cached";
13
- export const AS_INGESTED = "as-ingested";
14
- export const UNVERIFIED = "unverified";
15
-
16
- export class Verdict {
17
- constructor({ indexedSha = "", fetchedSha = null, current = null,
18
- fromAcquired = false, ageSeconds = null, note = "" } = {}) {
19
- Object.assign(this, { indexedSha, fetchedSha, current, fromAcquired, ageSeconds, note });
20
- Object.freeze(this);
21
- }
22
-
23
- /** Checked in this order — each answers a different question, and folding
24
- * any two of them together loses the distinction that makes it useful. */
25
- get label() {
26
- if (this.ageSeconds !== null) return CACHED; // we looked RECENTLY
27
- if (this.fromAcquired) return AS_INGESTED; // we could not look, but the index is self-consistent
28
- if (this.current === null) return UNVERIFIED; // we did not look
29
- return this.current ? CURRENT : STALE;
30
- }
31
-
32
- /** The source matched the index, or it did not. */
33
- static verify(indexedSha, fetchedSha, note = "") {
34
- if (fetchedSha === null) return new Verdict({ indexedSha, note });
35
- return new Verdict({ indexedSha, fetchedSha, current: fetchedSha === indexedSha, note });
36
- }
37
-
38
- /** We could not reach the source; the bytes came from `.fux/acquired/`.
39
- * A mismatch HERE is an index defect, not a stale source. */
40
- static asIngested(indexedSha, blobSha, note = "") {
41
- return new Verdict({
42
- indexedSha, fetchedSha: blobSha, current: blobSha === indexedSha,
43
- fromAcquired: true, note,
44
- });
45
- }
46
-
47
- /** We did not look at all. */
48
- static unverified(indexedSha, note = "") {
49
- return new Verdict({ indexedSha, note });
50
- }
51
- }
@@ -1,106 +0,0 @@
1
- /** Re-score fetched passages with the SAME BM25F scorer, over the passage
2
- * set's own corpus statistics. Twin of `src/fux/refer/_rescore.py`.
3
- *
4
- * A passage is a **two-field document**: its own heading and its own text.
5
- * `title`, `path` and `ctx` are document-level and would add a constant to
6
- * every passage of a document — longer vectors, identical ordering.
7
- */
8
- import { scoreRecord, deriveWlen } from "../query/bm25f.mjs";
9
- import { queryTermHashes } from "../query/scan.mjs";
10
- import { termHash, TF_FIELDS } from "../store/format.mjs";
11
- import { tokenize } from "../query/tokenize.mjs";
12
- import { analyze } from "../query/analyzer.mjs";
13
- import { cmpCodePoints } from "../compat/pyfloat.mjs";
14
-
15
- const BODY_I = TF_FIELDS.indexOf("body");
16
- const HEADING_I = TF_FIELDS.indexOf("heading");
17
- const WIDTH = TF_FIELDS.length;
18
-
19
- /** A citable address an agent can act on: `path:L12-L40`.
20
- *
21
- * Falls back to `path#pN` when a passage carries no line range — which is the
22
- * case for GENERATED text, where a `.docx`'s Markdown exists nowhere on disk.
23
- * **A wrong line number is worse than an honest ordinal.** */
24
- export function locatorOf(loc, passage) {
25
- if (passage.line_start && passage.line_end) {
26
- return `${loc}:L${passage.line_start}-L${passage.line_end}`;
27
- }
28
- return `${loc}#p${passage.ordinal}`;
29
- }
30
-
31
- function termsOf(passage) {
32
- const terms = {};
33
- const headingTokens = tokenize(passage.heading);
34
- for (const word of headingTokens) {
35
- const h = termHash(word);
36
- if (!(h in terms)) terms[h] = new Array(WIDTH).fill(0);
37
- terms[h][HEADING_I] += 1;
38
- }
39
- const body = tokenize(passage.text);
40
- for (const word of body) {
41
- const h = termHash(word);
42
- if (!(h in terms)) terms[h] = new Array(WIDTH).fill(0);
43
- terms[h][BODY_I] += 1;
44
- }
45
- const flen = new Array(WIDTH).fill(0);
46
- flen[HEADING_I] = headingTokens.length;
47
- flen[BODY_I] = body.length;
48
- return [terms, flen];
49
- }
50
-
51
- /** The bounded MULTIPLICATIVE uplift, or the score untouched.
52
- *
53
- * ⚠ The early return is not an optimisation — it is the byte-identity
54
- * guarantee. At `weight <= 0` this is the identity on `score` with **no float
55
- * arithmetic performed at all**, so no last-bit difference can enter a bundle
56
- * on a repo that never turned the reranker on. */
57
- function uplift(score, queryTerms, passage, weight, boostFn) {
58
- if (weight <= 0) return score;
59
- return score * (1.0 + weight * boostFn(queryTerms, analyze(passage.text)));
60
- }
61
-
62
- /** Score every passage of every fetched document against the query.
63
- * `candidates` is `[docId, loc, sha, passages]` per fetched document.
64
- * Sorted `(-score, locator)` — ties break on the locator, never on iteration
65
- * order, because the assembler downstream promises byte-identical output.
66
- *
67
- * **One constant, not a second knob**: `weight` is `[ranking] rerank_weight`,
68
- * the same value the document reranker uses. Two knobs for one signal is how
69
- * they drift, and the day they disagree `answer` cites a passage the ranking
70
- * did not prefer for a reason nobody can name. It defaults to OFF. */
71
- export function rescore(query, candidates, { weight = 0.0, boostFn = null } = {}) {
72
- const hashes = queryTermHashes(query);
73
- if (!hashes.length) return [];
74
- const queryTerms = weight > 0 ? analyze(query) : [];
75
-
76
- const rows = [];
77
- const df = {};
78
- let totalWlen = 0;
79
- for (const [docId, loc, sha, passages] of candidates) {
80
- for (const passage of passages) {
81
- const [terms, flen] = termsOf(passage);
82
- rows.push([docId, loc, sha, passage, terms, flen]);
83
- totalWlen += deriveWlen(flen);
84
- for (const term of Object.keys(terms)) df[term] = (df[term] ?? 0) + 1;
85
- }
86
- }
87
- if (!rows.length) return [];
88
-
89
- const n = rows.length;
90
- const avgWlen = totalWlen / n;
91
- const scored = rows.map(([docId, loc, sha, passage, terms, flen]) => ({
92
- doc_id: docId, loc, sha, passage,
93
- score: uplift(
94
- scoreRecord(terms, flen, hashes, df, n, avgWlen),
95
- queryTerms, passage, weight, boostFn || (() => 0),
96
- ),
97
- get nbytes() { return this.passage.nbytes; },
98
- get locator() { return locatorOf(this.loc, this.passage); },
99
- }));
100
-
101
- scored.sort((a, b) => {
102
- if (a.score !== b.score) return a.score > b.score ? -1 : 1;
103
- return cmpCodePoints(a.locator, b.locator);
104
- });
105
- return scored;
106
- }
@@ -1,45 +0,0 @@
1
- /** Where a document's bytes come from — **the local checkout, and nothing
2
- * else.** Twin of the `GIT` half of `src/fux/refer/source.py`.
3
- *
4
- * 🔴 **Node never fetches, and this module is where that is enforced.** It
5
- * imports no transport and has no fetcher seam to inject one through. A `url:`
6
- * document reads `.fux/acquired/` if the blob is retained, and otherwise the
7
- * caller falls back to `source: "index"`.
8
- */
9
- import { readFileSync, existsSync } from "node:fs";
10
- import { join } from "node:path";
11
- import { contentSha } from "../store/format.mjs";
12
-
13
- export const GIT = "git";
14
- export const URL = "url";
15
-
16
- export function resolve(docId) {
17
- if (docId.startsWith("file:")) return [GIT, docId.slice(5)];
18
- if (docId.startsWith("url:")) return [URL, docId.slice(4)];
19
- return [GIT, docId];
20
- }
21
-
22
- /** Read a document from the working tree. **Reading your own checkout is not
23
- * a fetch**, so this happens under every policy. */
24
- export function readLocal(root, relPath) {
25
- const path = join(root, relPath);
26
- if (!existsSync(path)) {
27
- throw new Error(`cited document is no longer in the working tree: ${relPath}`);
28
- }
29
- const raw = readFileSync(path);
30
- return [raw, contentSha(raw)];
31
- }
32
-
33
- /** The retained bytes for a URL, from `.fux/acquired/`, or `null`.
34
- * NOT rebuildable — only re-acquirable, and only while the source still
35
- * exists, which is why the acquired plane has its own kind in `.fux/`. */
36
- export function fromAcquired(root, sha) {
37
- const path = join(root, ".fux", "acquired", "objects", sha.slice(0, 2), sha);
38
- for (const candidate of [path, path + ".md", path + ".txt", path + ".html"]) {
39
- if (existsSync(candidate)) {
40
- const raw = readFileSync(candidate);
41
- return [raw, contentSha(raw)];
42
- }
43
- }
44
- return null;
45
- }
@@ -1,37 +0,0 @@
1
- /** Constants and address functions for the committed store.
2
- * Twin of `src/fux/store/format.py`. Pure and dependency-free. */
3
- import { blake2bHex } from "../hash/blake2b.mjs";
4
-
5
- export const INDEX_DIR = ".fux/index";
6
- export const SCHEMA_ID = "fux.index.v2";
7
- export const ANALYZER_VERSION = "v2";
8
-
9
- /** **Order is load-bearing** — body first, trailing zeros omitted on the wire.
10
- * Reordering this changes every record and is a format bump, not a refactor. */
11
- export const TF_FIELDS = ["body", "heading", "title", "path", "ctx"];
12
-
13
- export const TITLE_HASH_PREFIX = "h:";
14
-
15
- const enc = new TextEncoder();
16
-
17
- /** 16-hex (8-byte) blake2b digest of a term — the postings key. */
18
- export function termHash(term) { return blake2bHex(enc.encode(term), 8); }
19
-
20
- /** 40-hex (20-byte) blake2b digest of raw file bytes — the ledger `sha`. */
21
- export function contentSha(bytes) { return blake2bHex(bytes, 20); }
22
-
23
- /** 2-hex (1-byte) blake2b digest of the doc id — its shard bucket. */
24
- export function shardFor(docId) { return blake2bHex(enc.encode(docId), 1); }
25
-
26
- /** The title a verb shows. `rank()`'s call site passes no cache, so that path
27
- * always returns the bare hash — ranking stays a pure function of the record. */
28
- export function displayTitle(record, cache = null) {
29
- const title = record.title;
30
- if (title !== undefined && title !== null) return title;
31
- let hexpart = record.title_h || "";
32
- if (hexpart.startsWith(TITLE_HASH_PREFIX)) hexpart = hexpart.slice(TITLE_HASH_PREFIX.length);
33
- if (cache === null) return hexpart;
34
- const materialised = cache.get(record.sha || "");
35
- if (materialised !== null && materialised !== undefined) return materialised;
36
- return `${hexpart} (uncached — title unavailable)`;
37
- }
@@ -1,87 +0,0 @@
1
- /** Reading the committed shards. Twin of `src/fux/store/reader.py`.
2
- *
3
- * 🔴 **Shards are read as Buffers and never as strings.** The scan's prefilter
4
- * is a substring match on RAW BYTES, and a line is JSON-parsed only on a hit.
5
- * `readFileSync().toString().split("\n")` allocates ~10 MB of UTF-16 per query
6
- * at 10 000 documents — that is how W-107's N4 fence (p95 <= 150 ms) gets
7
- * blown by a transcription that is otherwise perfectly correct.
8
- */
9
- import { readFileSync, readdirSync, existsSync } from "node:fs";
10
- import { join } from "node:path";
11
- import { INDEX_DIR, SCHEMA_ID, ANALYZER_VERSION, TF_FIELDS, shardFor } from "./format.mjs";
12
-
13
- const NL = 0x0a;
14
-
15
- export function indexDir(root) { return join(root, INDEX_DIR); }
16
-
17
- /** Shard paths in sorted order — order is load-bearing for `df` determinism. */
18
- export function iterShardPaths(root) {
19
- const dir = indexDir(root);
20
- if (!existsSync(dir)) return [];
21
- return readdirSync(dir).filter((f) => f.endsWith(".jsonl")).sort().map((f) => join(dir, f));
22
- }
23
-
24
- export class IndexFormatError extends Error {}
25
-
26
- /** The header line, refused rather than guessed at.
27
- *
28
- * A v1 shard silently mixed into a v2 read corrupts every `df` and is
29
- * undetectable at query time, so this is a REFUSAL. The message names both
30
- * versions because the fix ("upgrade the reader" vs "re-ingest") depends on
31
- * which way round they are. */
32
- export function checkHeader(header, path) {
33
- if (header._format !== SCHEMA_ID) {
34
- throw new IndexFormatError(
35
- `${path}: index format is ${JSON.stringify(header._format)}, this reader speaks ` +
36
- `${JSON.stringify(SCHEMA_ID)}. Upgrade fux-engine, or re-ingest with a matching fux.`,
37
- );
38
- }
39
- if (header.analyzer !== ANALYZER_VERSION) {
40
- throw new IndexFormatError(
41
- `${path}: analyzer is ${JSON.stringify(header.analyzer)}, this reader speaks ` +
42
- `${JSON.stringify(ANALYZER_VERSION)}. Two analyzers in one index corrupt every df.`,
43
- );
44
- }
45
- const tf = header.tf_fields;
46
- if (!Array.isArray(tf) || tf.length !== TF_FIELDS.length || tf.some((f, i) => f !== TF_FIELDS[i])) {
47
- throw new IndexFormatError(
48
- `${path}: tf_fields is ${JSON.stringify(tf)}, expected ${JSON.stringify(TF_FIELDS)}. ` +
49
- `Field ORDER is load-bearing — a reordered tf array scores the wrong field.`,
50
- );
51
- }
52
- return header;
53
- }
54
-
55
- /** `[header, lines]` — the header parsed, the records left as raw Buffers. */
56
- export function rawRecordLines(path) {
57
- const buf = readFileSync(path);
58
- const lines = [];
59
- let start = 0;
60
- for (let i = 0; i < buf.length; i++) {
61
- if (buf[i] === NL) {
62
- if (i > start) lines.push(buf.subarray(start, i));
63
- start = i + 1;
64
- }
65
- }
66
- if (start < buf.length) lines.push(buf.subarray(start));
67
- if (!lines.length) throw new IndexFormatError(`${path}: empty shard, no header line`);
68
- const header = checkHeader(JSON.parse(lines[0].toString("utf8")), path);
69
- return [header, lines.slice(1)];
70
- }
71
-
72
- /** One record by id, from its own shard. Display-time only — `rank()` never
73
- * calls this, because ranking must stay a pure function of the record it was
74
- * already handed. */
75
- export function recordFor(root, docId) {
76
- const shard = shardFor(docId);
77
- const path = join(indexDir(root), `${shard}.jsonl`);
78
- if (!existsSync(path)) return null;
79
- const [, lines] = rawRecordLines(path);
80
- const needle = Buffer.from(JSON.stringify(docId), "utf8");
81
- for (const line of lines) {
82
- if (!line.includes(needle)) continue;
83
- const record = JSON.parse(line.toString("utf8"));
84
- if (record.id === docId) return record;
85
- }
86
- return null;
87
- }