fux-engine 2.0.0-alpha.7
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 +45 -0
- package/fux.mjs +139 -0
- package/mcp-tools.json +66 -0
- package/package.json +16 -0
- package/src/compat/pyfloat.mjs +95 -0
- package/src/config/root.mjs +16 -0
- package/src/decode/markdown.mjs +52 -0
- package/src/graph/community.mjs +59 -0
- package/src/graph/model.mjs +67 -0
- package/src/graph/plane.mjs +51 -0
- package/src/hash/blake2b.mjs +208 -0
- package/src/index.mjs +38 -0
- package/src/ingest/priors.mjs +35 -0
- package/src/query/analyzer.mjs +81 -0
- package/src/query/bm25f.mjs +89 -0
- package/src/query/confidence.mjs +163 -0
- package/src/query/expand.mjs +55 -0
- package/src/query/fuse.mjs +49 -0
- package/src/query/headings.mjs +30 -0
- package/src/query/rank.mjs +158 -0
- package/src/query/run.mjs +74 -0
- package/src/query/scan.mjs +106 -0
- package/src/query/stem.mjs +172 -0
- package/src/query/tokenize.mjs +11 -0
- package/src/refer/assemble.mjs +82 -0
- package/src/refer/chunk.mjs +263 -0
- package/src/refer/freshness.mjs +51 -0
- package/src/refer/rescore.mjs +106 -0
- package/src/refer/source.mjs +45 -0
- package/src/store/format.mjs +37 -0
- package/src/store/reader.mjs +87 -0
- package/src/verbs/answer.mjs +143 -0
- package/src/verbs/ask.mjs +48 -0
- package/src/verbs/find.mjs +24 -0
- package/src/verbs/graph.mjs +110 -0
- package/src/verbs/mcp.mjs +145 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Reciprocal-rank fusion for `-q` — several phrasings, one ranked list.
|
|
2
|
+
* Twin of `fuse.py`. */
|
|
3
|
+
import { pyRound9, cmpCodePoints } from "../compat/pyfloat.mjs";
|
|
4
|
+
|
|
5
|
+
/** Cormack et al. 2009's constant. **Not tuned here and not a tune.toml key** —
|
|
6
|
+
* a knob on it would be a knob on a published constant measured on TREC
|
|
7
|
+
* collections, with nothing in this repo able to beat it at 10 documents. */
|
|
8
|
+
export const K = 60;
|
|
9
|
+
|
|
10
|
+
/** `id -> fused score`, from ranked id lists. Rank 0 is best.
|
|
11
|
+
* A document absent from a list contributes nothing — **not a penalty**. */
|
|
12
|
+
export function rrf(rankLists, k = K) {
|
|
13
|
+
const scores = {};
|
|
14
|
+
for (const ranks of rankLists) {
|
|
15
|
+
for (let i = 0; i < ranks.length; i++) {
|
|
16
|
+
scores[ranks[i]] = (scores[ranks[i]] ?? 0.0) + 1.0 / (k + i + 1);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return scores;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Fuse several ranked result lists into one, by rank.
|
|
23
|
+
*
|
|
24
|
+
* **The result object comes from the arm that ranked it best**, so its `loc`,
|
|
25
|
+
* `title` and `archived` are a real document's view, not a merge of several.
|
|
26
|
+
* Only `score` is replaced. Ordered by the same key `rank()` uses, so a fused
|
|
27
|
+
* list is as reproducible as an unfused one. */
|
|
28
|
+
export function fuseResults(resultLists, top, k = K) {
|
|
29
|
+
if (!resultLists.length) return [];
|
|
30
|
+
if (resultLists.length === 1) return resultLists[0].slice(0, top);
|
|
31
|
+
|
|
32
|
+
const scores = rrf(resultLists.map((rs) => rs.map((r) => r.id)), k);
|
|
33
|
+
|
|
34
|
+
const bestSeen = new Map();
|
|
35
|
+
for (const results of resultLists) {
|
|
36
|
+
for (let i = 0; i < results.length; i++) {
|
|
37
|
+
const cur = bestSeen.get(results[i].id);
|
|
38
|
+
if (cur === undefined || i < cur[0]) bestSeen.set(results[i].id, [i, results[i]]);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ordered = Object.keys(scores).sort((a, b) => {
|
|
43
|
+
const ra = -pyRound9(scores[a]), rb = -pyRound9(scores[b]);
|
|
44
|
+
if (ra !== rb) return ra < rb ? -1 : 1;
|
|
45
|
+
return cmpCodePoints(a, b);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return ordered.slice(0, top).map((id) => ({ ...bestSeen.get(id)[1], score: scores[id] }));
|
|
49
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Which of a record's headings match the query — DISPLAY ONLY.
|
|
2
|
+
*
|
|
3
|
+
* Twin of `headings.py`. Runs after `run_query` returns, over the record's
|
|
4
|
+
* committed `phrases`, so it can never reach a score. `[]` for a missing
|
|
5
|
+
* record, a `hashed` one, or a query no heading matches.
|
|
6
|
+
*/
|
|
7
|
+
import { tokenize } from "./tokenize.mjs";
|
|
8
|
+
|
|
9
|
+
export const MAX_HEADINGS = 3;
|
|
10
|
+
|
|
11
|
+
export function headingsFor(record, query, limit = MAX_HEADINGS) {
|
|
12
|
+
if (!record) return [];
|
|
13
|
+
const phrases = record.phrases || [];
|
|
14
|
+
if (!phrases.length) return [];
|
|
15
|
+
|
|
16
|
+
const wanted = new Set(tokenize(query));
|
|
17
|
+
if (!wanted.size) return [];
|
|
18
|
+
|
|
19
|
+
const scored = [];
|
|
20
|
+
for (let position = 0; position < phrases.length; position++) {
|
|
21
|
+
const phrase = phrases[position];
|
|
22
|
+
if (typeof phrase !== "string") continue; // a record is data, not a promise
|
|
23
|
+
let matches = 0;
|
|
24
|
+
for (const t of new Set(tokenize(phrase))) if (wanted.has(t)) matches++;
|
|
25
|
+
if (matches) scored.push([-matches, position, phrase]);
|
|
26
|
+
}
|
|
27
|
+
// `position` ascending is the tie-break, so the key sorts directly.
|
|
28
|
+
scored.sort((a, b) => (a[0] - b[0]) || (a[1] - b[1]));
|
|
29
|
+
return scored.slice(0, limit).map((s) => s[2]);
|
|
30
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/** The scorer and the sort — ONE function, both candidate paths.
|
|
2
|
+
*
|
|
3
|
+
* Twin of `src/fux/query/rank.py`. This is why the differential law is
|
|
4
|
+
* achievable: float addition is not associative, so a second implementation
|
|
5
|
+
* that accumulates its own scores would diverge in the last bit. The
|
|
6
|
+
* accelerator produces candidates and statistics; this does every arithmetic
|
|
7
|
+
* operation, once, for both.
|
|
8
|
+
*/
|
|
9
|
+
import { DEFAULT_SCORING, deriveWlen, scoreRecord } from "./bm25f.mjs";
|
|
10
|
+
import { displayTitle } from "../store/format.mjs";
|
|
11
|
+
import { recencyMultiplier } from "../ingest/priors.mjs";
|
|
12
|
+
import { pyRound9, cmpCodePoints } from "../compat/pyfloat.mjs";
|
|
13
|
+
|
|
14
|
+
export class Corpus {
|
|
15
|
+
constructor(n = 0, totalWlen = 0.0, newestMtime = 0) {
|
|
16
|
+
this.n = n; this.totalWlen = totalWlen; this.newestMtime = newestMtime;
|
|
17
|
+
}
|
|
18
|
+
get avgWlen() { return this.n ? this.totalWlen / this.n : 0.0; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `loc` is under one of the declared archived directories. */
|
|
22
|
+
export function isArchivedLoc(loc, archivedDirs) {
|
|
23
|
+
for (const d of archivedDirs) {
|
|
24
|
+
if (loc === d || loc.startsWith(d.endsWith("/") ? d : d + "/")) return true;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function recordIsArchived(record, archivedDirs) {
|
|
30
|
+
if (record.archived) return true;
|
|
31
|
+
return archivedDirs.size > 0 && isArchivedLoc(record.loc, archivedDirs);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The score multiplier policy, in ONE place, for both candidate paths. */
|
|
35
|
+
export class Weighting {
|
|
36
|
+
constructor({
|
|
37
|
+
archivedWeight = 1.0, archivedDirs = new Set(),
|
|
38
|
+
supersededWeight = 1.0, recencyHalfLifeDays = 0.0,
|
|
39
|
+
newestMtime = 0, priority = [],
|
|
40
|
+
} = {}) {
|
|
41
|
+
this.archivedWeight = archivedWeight;
|
|
42
|
+
this.archivedDirs = archivedDirs;
|
|
43
|
+
this.supersededWeight = supersededWeight;
|
|
44
|
+
this.recencyHalfLifeDays = recencyHalfLifeDays;
|
|
45
|
+
this.newestMtime = newestMtime;
|
|
46
|
+
this.priority = priority; // [[prefix, weight], ...], longest-prefix wins
|
|
47
|
+
Object.freeze(this);
|
|
48
|
+
}
|
|
49
|
+
get trivial() {
|
|
50
|
+
return this.archivedWeight === 1.0
|
|
51
|
+
&& this.supersededWeight === 1.0
|
|
52
|
+
&& this.recencyHalfLifeDays === 0.0
|
|
53
|
+
&& this.priority.length === 0;
|
|
54
|
+
}
|
|
55
|
+
priorityFor(loc) {
|
|
56
|
+
let best = 1.0, bestLen = -1;
|
|
57
|
+
for (const [prefix, w] of this.priority) {
|
|
58
|
+
if (loc === prefix || loc.startsWith(prefix.endsWith("/") ? prefix : prefix + "/")) {
|
|
59
|
+
if (prefix.length > bestLen) { best = w; bestLen = prefix.length; }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return best;
|
|
63
|
+
}
|
|
64
|
+
/** The multiplier for one record: archived x superseded x recency x priority. */
|
|
65
|
+
of(record) {
|
|
66
|
+
if (this.trivial) return 1.0;
|
|
67
|
+
let weight = 1.0;
|
|
68
|
+
if (this.archivedWeight !== 1.0 && recordIsArchived(record, this.archivedDirs)) {
|
|
69
|
+
weight *= this.archivedWeight;
|
|
70
|
+
}
|
|
71
|
+
if (this.supersededWeight !== 1.0 && record.superseded) weight *= this.supersededWeight;
|
|
72
|
+
if (this.recencyHalfLifeDays > 0) {
|
|
73
|
+
weight *= recencyMultiplier(record.mtime ?? null, this.newestMtime, this.recencyHalfLifeDays);
|
|
74
|
+
}
|
|
75
|
+
if (this.priority.length) weight *= this.priorityFor(record.loc || "");
|
|
76
|
+
return weight;
|
|
77
|
+
}
|
|
78
|
+
withNewestMtime(mtime) {
|
|
79
|
+
return new Weighting({
|
|
80
|
+
archivedWeight: this.archivedWeight, archivedDirs: this.archivedDirs,
|
|
81
|
+
supersededWeight: this.supersededWeight,
|
|
82
|
+
recencyHalfLifeDays: this.recencyHalfLifeDays,
|
|
83
|
+
newestMtime: mtime, priority: this.priority,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Rank candidates. `expansion` is `{hashes, required, weights}` or null. */
|
|
89
|
+
export function rank(
|
|
90
|
+
candidates, queryHashes, df, corpus, top,
|
|
91
|
+
{ weighting = null, scoring = DEFAULT_SCORING, statsOut = null, expansion = null } = {},
|
|
92
|
+
) {
|
|
93
|
+
const termWeights = expansion && expansion.weights && Object.keys(expansion.weights).length
|
|
94
|
+
? expansion.weights : null;
|
|
95
|
+
const required = expansion && expansion.required ? expansion.required : queryHashes;
|
|
96
|
+
const avgWlen = corpus.avgWlen;
|
|
97
|
+
|
|
98
|
+
let w = weighting || new Weighting();
|
|
99
|
+
if (w.recencyHalfLifeDays > 0 && corpus.newestMtime) w = w.withNewestMtime(corpus.newestMtime);
|
|
100
|
+
const demote = !w.trivial;
|
|
101
|
+
|
|
102
|
+
const scored = [];
|
|
103
|
+
for (const record of candidates) {
|
|
104
|
+
const terms = record.terms || {};
|
|
105
|
+
// 🔴 The hallucinated-citation guard, before anything is scored: a
|
|
106
|
+
// document matching ONLY expansion terms is not an answer to the question.
|
|
107
|
+
let matches = false;
|
|
108
|
+
for (const h of required) { if (h in terms) { matches = true; break; } }
|
|
109
|
+
if (!matches) continue;
|
|
110
|
+
|
|
111
|
+
let s = scoreRecord(
|
|
112
|
+
terms, record.flen || [], queryHashes, df, corpus.n, avgWlen, scoring, termWeights,
|
|
113
|
+
);
|
|
114
|
+
const archived = recordIsArchived(record, w.archivedDirs);
|
|
115
|
+
if (demote) s *= w.of(record);
|
|
116
|
+
if (s > 0) scored.push([record, s, archived]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// W-111 — the DECLARED tie-break, in Arpit's ratified order:
|
|
120
|
+
// superseded -> recency -> priority -> id
|
|
121
|
+
// Every one of these is already a Weighting multiplier and every one ships
|
|
122
|
+
// as a no-op; this reads the same FACTS, only where the rounded scores tie.
|
|
123
|
+
//
|
|
124
|
+
// 🔴 `id` is compared by CODE POINT, never with `<` — W-107 hazard H1.
|
|
125
|
+
scored.sort((A, B) => {
|
|
126
|
+
const ra = -pyRound9(A[1]), rb = -pyRound9(B[1]);
|
|
127
|
+
if (ra !== rb) return ra < rb ? -1 : 1;
|
|
128
|
+
const sa = A[0].superseded ? 1 : 0, sb = B[0].superseded ? 1 : 0;
|
|
129
|
+
if (sa !== sb) return sa - sb;
|
|
130
|
+
const ma = -(A[0].mtime || 0), mb = -(B[0].mtime || 0);
|
|
131
|
+
if (ma !== mb) return ma < mb ? -1 : 1;
|
|
132
|
+
const pa = -w.priorityFor(A[0].loc || ""), pb = -w.priorityFor(B[0].loc || "");
|
|
133
|
+
if (pa !== pb) return pa < pb ? -1 : 1;
|
|
134
|
+
return cmpCodePoints(A[0].id, B[0].id);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Marked AFTER the sort and over the WHOLE list: a neighbour comparison on
|
|
138
|
+
// the truncated window would un-mark the last row, which is the one most
|
|
139
|
+
// likely to have been decided by a coin-toss.
|
|
140
|
+
const tied = new Set();
|
|
141
|
+
for (let i = 0; i < scored.length - 1; i++) {
|
|
142
|
+
if (pyRound9(scored[i][1]) === pyRound9(scored[i + 1][1])) { tied.add(i); tied.add(i + 1); }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (statsOut !== null && scored.length) {
|
|
146
|
+
const topTerms = scored[0][0].terms || {};
|
|
147
|
+
statsOut.top_doc_hashes = queryHashes.filter((h) => h in topTerms);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return scored.slice(0, top).map(([record, s, archived], i) => ({
|
|
151
|
+
id: record.id,
|
|
152
|
+
title: displayTitle(record),
|
|
153
|
+
loc: record.loc,
|
|
154
|
+
score: s,
|
|
155
|
+
archived,
|
|
156
|
+
tie: tied.has(i),
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** `run_query` — the shared spine of `ask`, `find` and `answer`.
|
|
2
|
+
*
|
|
3
|
+
* Twin of the pure half of `src/fux/query/__init__.py`. Everything that
|
|
4
|
+
* decides an answer lives here; the verbs are projections of it, which is why
|
|
5
|
+
* `find` cannot rank differently from `ask`.
|
|
6
|
+
*/
|
|
7
|
+
import { ask as scanAsk, queryTermHashes } from "./scan.mjs";
|
|
8
|
+
import { rank } from "./rank.mjs";
|
|
9
|
+
import { signals } from "./confidence.mjs";
|
|
10
|
+
import { headingsFor } from "./headings.mjs";
|
|
11
|
+
import { fuseResults } from "./fuse.mjs";
|
|
12
|
+
import * as expandMod from "./expand.mjs";
|
|
13
|
+
import { tokenizePairs } from "./tokenize.mjs";
|
|
14
|
+
import { DEFAULT_SCORING } from "./bm25f.mjs";
|
|
15
|
+
|
|
16
|
+
/** Retrieval depth when the proximity reranker is on. Off by default, so the
|
|
17
|
+
* default depth is `top` and the extra 15 candidates are never scored. */
|
|
18
|
+
export const RERANK_DEPTH = 20;
|
|
19
|
+
|
|
20
|
+
/** One arm: scan, rank, band. `expand` is a term slot, not a second question. */
|
|
21
|
+
export function runQuery(root, query, top, {
|
|
22
|
+
weighting = null, scoring = DEFAULT_SCORING, expand = null,
|
|
23
|
+
separationFloor = undefined, docCoverageFloor = undefined,
|
|
24
|
+
wantConfidence = false, rerankWeight = 0.0,
|
|
25
|
+
} = {}) {
|
|
26
|
+
const queryHashes = queryTermHashes(query);
|
|
27
|
+
const expansion = expand
|
|
28
|
+
? expandMod.build(queryHashes, queryTermHashes(expand), expand.weight ?? 0.2)
|
|
29
|
+
: null;
|
|
30
|
+
|
|
31
|
+
const depth = rerankWeight > 0 ? Math.max(top, RERANK_DEPTH) : top;
|
|
32
|
+
const statsOut = {};
|
|
33
|
+
const results = scanAsk(root, query, depth, { weighting, scoring, statsOut, expansion });
|
|
34
|
+
|
|
35
|
+
const out = results.slice(0, top);
|
|
36
|
+
let confidence = null;
|
|
37
|
+
if (wantConfidence) {
|
|
38
|
+
const opts = { topDocHashes: statsOut.top_doc_hashes ?? null };
|
|
39
|
+
if (separationFloor !== undefined) opts.separationFloor = separationFloor;
|
|
40
|
+
if (docCoverageFloor !== undefined) opts.docCoverageFloor = docCoverageFloor;
|
|
41
|
+
confidence = signals(
|
|
42
|
+
tokenizePairs(query), queryHashes, statsOut.df ?? {}, statsOut.n ?? 0,
|
|
43
|
+
results.map((r) => r.score), opts,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return { results: out, confidence, queryHashes, stats: statsOut };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Several phrasings, fused by rank. Arm 1 owns the confidence block.
|
|
50
|
+
*
|
|
51
|
+
* ⚠ **The band always describes ARM 1 only**, and that is deliberate: the
|
|
52
|
+
* `separation_floor` is calibrated against BM25F, and a fused top-2 differs by
|
|
53
|
+
* ~0.0003, so a band computed over fused scores would be measuring a different
|
|
54
|
+
* quantity under the same name. */
|
|
55
|
+
export function runFused(root, queries, top, opts = {}) {
|
|
56
|
+
const unique = [...new Set(queries.filter((q) => q && q.trim()))];
|
|
57
|
+
if (unique.length <= 1) {
|
|
58
|
+
return { ...runQuery(root, unique[0] ?? "", top, opts), fused: false };
|
|
59
|
+
}
|
|
60
|
+
const first = runQuery(root, unique[0], top, opts);
|
|
61
|
+
const arms = [first.results];
|
|
62
|
+
for (const q of unique.slice(1)) {
|
|
63
|
+
arms.push(runQuery(root, q, top, { ...opts, wantConfidence: false }).results);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
results: fuseResults(arms, top),
|
|
67
|
+
confidence: first.confidence,
|
|
68
|
+
queryHashes: first.queryHashes,
|
|
69
|
+
stats: first.stats,
|
|
70
|
+
fused: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { headingsFor, rank, expandMod as expand };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** The reference scan — the DEFAULT path, and the one a bug report reproduces
|
|
2
|
+
* against. Twin of `src/fux/query/scan.py`.
|
|
3
|
+
*
|
|
4
|
+
* Every line in the corpus is touched; only a line whose raw bytes contain a
|
|
5
|
+
* query hash is parsed. `flen` and `mtime` are read by regex from the bytes on
|
|
6
|
+
* every line, candidate or not, because parsing every record to sum a length
|
|
7
|
+
* is exactly what the prefilter exists to avoid.
|
|
8
|
+
*/
|
|
9
|
+
import { rawRecordLines, iterShardPaths } from "../store/reader.mjs";
|
|
10
|
+
import { termHash, TF_FIELDS } from "../store/format.mjs";
|
|
11
|
+
import { DEFAULT_SCORING, deriveWlen } from "./bm25f.mjs";
|
|
12
|
+
import { Corpus, rank } from "./rank.mjs";
|
|
13
|
+
import { tokenize } from "./tokenize.mjs";
|
|
14
|
+
|
|
15
|
+
//: The byte-level oracle, over per-field counts. `wlen` is DERIVED from `flen`
|
|
16
|
+
//: at the weights in force, so this parses the array and applies `deriveWlen`
|
|
17
|
+
//: — the same function the scorer and the refer plane use, so they cannot drift.
|
|
18
|
+
const FLEN_RE = /"flen":\[([0-9,\s]*)\]/;
|
|
19
|
+
const MTIME_RE = /"mtime":(\d+)/;
|
|
20
|
+
|
|
21
|
+
function flenFromLine(text) {
|
|
22
|
+
const m = FLEN_RE.exec(text);
|
|
23
|
+
if (!m) return null;
|
|
24
|
+
const inner = m[1].trim();
|
|
25
|
+
if (!inner) return [];
|
|
26
|
+
return inner.split(",").map((p) => parseInt(p, 10));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Query terms as index hashes, deduped, ORDER PRESERVED.
|
|
30
|
+
* Order is load-bearing: `rank()` sums BM25F contributions in it, so both
|
|
31
|
+
* candidate generators must derive it identically from the same string. */
|
|
32
|
+
export function queryTermHashes(query) {
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const t of tokenize(query)) {
|
|
36
|
+
const h = termHash(t);
|
|
37
|
+
if (!seen.has(h)) { seen.add(h); out.push(h); }
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The B2 pass: candidate records, `df`, and the corpus statistics. */
|
|
43
|
+
export function scanCandidates(root, queryHashes, { scoring = DEFAULT_SCORING } = {}) {
|
|
44
|
+
const patterns = queryHashes.map((h) => Buffer.from(`"${h}"`, "ascii"));
|
|
45
|
+
|
|
46
|
+
let totalDocs = 0;
|
|
47
|
+
// Per-field totals, summed raw and weighted ONCE at the end. Summing
|
|
48
|
+
// `deriveWlen` per record would bake the weights into a running total the
|
|
49
|
+
// moment anything cached it — the accelerator's stats plane made exactly
|
|
50
|
+
// that mistake (ADR-TUNE, 2026-08-24).
|
|
51
|
+
const totalFlen = new Array(TF_FIELDS.length).fill(0);
|
|
52
|
+
let newestMtime = 0;
|
|
53
|
+
const df = {};
|
|
54
|
+
for (const h of queryHashes) df[h] = 0;
|
|
55
|
+
const candidates = [];
|
|
56
|
+
|
|
57
|
+
for (const path of iterShardPaths(root)) {
|
|
58
|
+
const [, lines] = rawRecordLines(path);
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
totalDocs++;
|
|
61
|
+
// latin1 keeps this a byte view: the regexes are ASCII-only and this
|
|
62
|
+
// avoids decoding UTF-8 for every line in the corpus.
|
|
63
|
+
const text = line.toString("latin1");
|
|
64
|
+
const flen = flenFromLine(text);
|
|
65
|
+
if (flen !== null) for (let i = 0; i < flen.length; i++) totalFlen[i] += flen[i];
|
|
66
|
+
const mt = MTIME_RE.exec(text);
|
|
67
|
+
if (mt !== null) {
|
|
68
|
+
const value = parseInt(mt[1], 10);
|
|
69
|
+
if (value > newestMtime) newestMtime = value;
|
|
70
|
+
}
|
|
71
|
+
// The substring check is a PREFILTER only: a query hash can appear as a
|
|
72
|
+
// literal 16-hex string outside `terms` (a title, an id, a sha) without
|
|
73
|
+
// the document containing that term. Once a line is worth parsing, `df`
|
|
74
|
+
// is counted from the parsed record's own `terms` keys, which is exact.
|
|
75
|
+
let hit = false;
|
|
76
|
+
for (const p of patterns) { if (line.includes(p)) { hit = true; break; } }
|
|
77
|
+
if (!hit) continue;
|
|
78
|
+
|
|
79
|
+
const record = JSON.parse(line.toString("utf8"));
|
|
80
|
+
const recordTerms = record.terms || {};
|
|
81
|
+
for (const h of queryHashes) if (h in recordTerms) df[h]++;
|
|
82
|
+
candidates.push(record);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return [candidates, df, new Corpus(totalDocs, deriveWlen(totalFlen, scoring), newestMtime)];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The reference path. */
|
|
90
|
+
export function ask(root, query, top = 5, opts = {}) {
|
|
91
|
+
const { weighting = null, scoring = DEFAULT_SCORING, statsOut = null, expansion = null } = opts;
|
|
92
|
+
const queryHashes = queryTermHashes(query);
|
|
93
|
+
if (!queryHashes.length) {
|
|
94
|
+
// A query that tokenizes to nothing still owes the caller its corpus
|
|
95
|
+
// statistics, or confidence cannot tell "no terms" from "not run".
|
|
96
|
+
if (statsOut !== null) {
|
|
97
|
+
if (!("df" in statsOut)) statsOut.df = {};
|
|
98
|
+
if (!("n" in statsOut)) statsOut.n = 0;
|
|
99
|
+
}
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const collect = expansion ? expansion.hashes : queryHashes;
|
|
103
|
+
const [candidates, df, corpus] = scanCandidates(root, collect, { scoring });
|
|
104
|
+
if (statsOut !== null) { statsOut.df = df; statsOut.n = corpus.n; }
|
|
105
|
+
return rank(candidates, collect, df, corpus, top, { weighting, scoring, statsOut, expansion });
|
|
106
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/** The Porter stemmer — analyzer v2's morphology step.
|
|
2
|
+
*
|
|
3
|
+
* Twin of `src/fux/query/stem.py`. **Transcribed, not reimplemented**: Porter
|
|
4
|
+
* is a fully specified algorithm with a published test vocabulary, and every
|
|
5
|
+
* departure fux makes from the textbook (see `shouldStem`) is a departure the
|
|
6
|
+
* Python side already made. A difference here is a silent no-match, not an
|
|
7
|
+
* error — the query hashes a string the index never wrote.
|
|
8
|
+
*
|
|
9
|
+
* Reference: M.F. Porter, "An algorithm for suffix stripping", Program 14(3),
|
|
10
|
+
* 1980. Step numbering follows the paper, as it does on the Python side.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const VOWELS = new Set(["a", "e", "i", "o", "u"]);
|
|
14
|
+
|
|
15
|
+
function isConsonant(word, i) {
|
|
16
|
+
const ch = word[i];
|
|
17
|
+
if (VOWELS.has(ch)) return false;
|
|
18
|
+
if (ch === "y") return i === 0 || !isConsonant(word, i - 1);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `m` — the number of VC sequences in [C](VC){m}[V]. */
|
|
23
|
+
function measure(stemStr) {
|
|
24
|
+
let m = 0;
|
|
25
|
+
let i = 0;
|
|
26
|
+
const n = stemStr.length;
|
|
27
|
+
while (i < n && isConsonant(stemStr, i)) i++;
|
|
28
|
+
while (i < n) {
|
|
29
|
+
while (i < n && !isConsonant(stemStr, i)) i++;
|
|
30
|
+
if (i >= n) break;
|
|
31
|
+
m++;
|
|
32
|
+
while (i < n && isConsonant(stemStr, i)) i++;
|
|
33
|
+
}
|
|
34
|
+
return m;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hasVowel(stemStr) {
|
|
38
|
+
for (let i = 0; i < stemStr.length; i++) if (!isConsonant(stemStr, i)) return true;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function doubleConsonantSuffix(word) {
|
|
43
|
+
return word.length >= 2
|
|
44
|
+
&& word[word.length - 1] === word[word.length - 2]
|
|
45
|
+
&& isConsonant(word, word.length - 1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** consonant-vowel-consonant where the final one is not w, x or y. */
|
|
49
|
+
function cvc(word) {
|
|
50
|
+
if (word.length < 3) return false;
|
|
51
|
+
const n = word.length;
|
|
52
|
+
if (!(isConsonant(word, n - 3) && !isConsonant(word, n - 2) && isConsonant(word, n - 1))) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return !"wxy".includes(word[n - 1]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function replace(word, suffix, repl, minM) {
|
|
59
|
+
if (!word.endsWith(suffix)) return null;
|
|
60
|
+
const s = word.slice(0, word.length - suffix.length);
|
|
61
|
+
return measure(s) > minM ? s + repl : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const STEP2 = [
|
|
65
|
+
["ational", "ate"], ["tional", "tion"], ["enci", "ence"], ["anci", "ance"],
|
|
66
|
+
["izer", "ize"], ["bli", "ble"], ["alli", "al"], ["entli", "ent"],
|
|
67
|
+
["eli", "e"], ["ousli", "ous"], ["ization", "ize"], ["ation", "ate"],
|
|
68
|
+
["ator", "ate"], ["alism", "al"], ["iveness", "ive"], ["fulness", "ful"],
|
|
69
|
+
["ousness", "ous"], ["aliti", "al"], ["iviti", "ive"], ["biliti", "ble"],
|
|
70
|
+
["logi", "log"],
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const STEP3 = [
|
|
74
|
+
["icate", "ic"], ["ative", ""], ["alize", "al"], ["iciti", "ic"],
|
|
75
|
+
["ical", "ic"], ["ful", ""], ["ness", ""],
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const STEP4 = [
|
|
79
|
+
"al", "ance", "ence", "er", "ic", "able", "ible", "ant", "ement",
|
|
80
|
+
"ment", "ent", "ou", "ism", "ate", "iti", "ous", "ive", "ize",
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/** Python's `str.isalpha()` — Unicode alphabetic, NOT `/^[a-z]+$/`.
|
|
84
|
+
*
|
|
85
|
+
* ⚠ This is a real divergence risk and the reason it is spelled out. Python's
|
|
86
|
+
* `isalpha` is true for `café` and `中文`; a naive `[a-zA-Z]` test is not, and
|
|
87
|
+
* would stem tokens Python leaves alone. `\p{Alphabetic}` is the Unicode
|
|
88
|
+
* property Python uses.
|
|
89
|
+
*/
|
|
90
|
+
const ALPHA_RE = /^\p{Alphabetic}+$/u;
|
|
91
|
+
|
|
92
|
+
/** Words only: no digits, no underscores, at least three characters. */
|
|
93
|
+
export function shouldStem(token) {
|
|
94
|
+
if (token.length < 3) return false;
|
|
95
|
+
return ALPHA_RE.test(token);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Porter-stem one already-lowercased token. */
|
|
99
|
+
export function stem(word) {
|
|
100
|
+
if (!shouldStem(word)) return word;
|
|
101
|
+
|
|
102
|
+
// Step 1a — plurals
|
|
103
|
+
if (word.endsWith("sses")) word = word.slice(0, -2);
|
|
104
|
+
else if (word.endsWith("ies")) word = word.slice(0, -2);
|
|
105
|
+
else if (word.endsWith("ss")) { /* unchanged */ }
|
|
106
|
+
else if (word.endsWith("s")) word = word.slice(0, -1);
|
|
107
|
+
|
|
108
|
+
// Step 1b — -ed / -ing
|
|
109
|
+
let secondPass = false;
|
|
110
|
+
if (word.endsWith("eed")) {
|
|
111
|
+
if (measure(word.slice(0, -3)) > 0) word = word.slice(0, -1);
|
|
112
|
+
} else if (word.endsWith("ed") && hasVowel(word.slice(0, -2))) {
|
|
113
|
+
word = word.slice(0, -2);
|
|
114
|
+
secondPass = true;
|
|
115
|
+
} else if (word.endsWith("ing") && hasVowel(word.slice(0, -3))) {
|
|
116
|
+
word = word.slice(0, -3);
|
|
117
|
+
secondPass = true;
|
|
118
|
+
}
|
|
119
|
+
if (secondPass) {
|
|
120
|
+
if (word.endsWith("at") || word.endsWith("bl") || word.endsWith("iz")) {
|
|
121
|
+
word += "e";
|
|
122
|
+
} else if (
|
|
123
|
+
doubleConsonantSuffix(word)
|
|
124
|
+
&& !(word.endsWith("l") || word.endsWith("s") || word.endsWith("z"))
|
|
125
|
+
) {
|
|
126
|
+
word = word.slice(0, -1);
|
|
127
|
+
} else if (measure(word) === 1 && cvc(word)) {
|
|
128
|
+
word += "e";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Step 1c — terminal y
|
|
133
|
+
if (word.endsWith("y") && hasVowel(word.slice(0, -1))) {
|
|
134
|
+
word = word.slice(0, -1) + "i";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Step 2 / 3 — derivational suffixes
|
|
138
|
+
for (const [suffix, repl] of STEP2) {
|
|
139
|
+
const out = replace(word, suffix, repl, 0);
|
|
140
|
+
if (out !== null) { word = out; break; }
|
|
141
|
+
}
|
|
142
|
+
for (const [suffix, repl] of STEP3) {
|
|
143
|
+
const out = replace(word, suffix, repl, 0);
|
|
144
|
+
if (out !== null) { word = out; break; }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Step 4 — strip when m > 1. Python's for/else: the `ion` branch runs only
|
|
148
|
+
// when NO suffix matched, which is why this needs an explicit flag.
|
|
149
|
+
let matched = false;
|
|
150
|
+
for (const suffix of STEP4) {
|
|
151
|
+
if (word.endsWith(suffix)) {
|
|
152
|
+
const s = word.slice(0, word.length - suffix.length);
|
|
153
|
+
matched = true;
|
|
154
|
+
if (suffix === "ion" && !(s.endsWith("s") || s.endsWith("t"))) continue;
|
|
155
|
+
if (measure(s) > 1) word = s;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (!matched && word.endsWith("ion")) {
|
|
160
|
+
const s = word.slice(0, -3);
|
|
161
|
+
if (measure(s) > 1 && (s.endsWith("s") || s.endsWith("t"))) word = s;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Step 5a / 5b — terminal e and doubled l
|
|
165
|
+
if (word.endsWith("e")) {
|
|
166
|
+
const m = measure(word.slice(0, -1));
|
|
167
|
+
if (m > 1 || (m === 1 && !cvc(word.slice(0, -1)))) word = word.slice(0, -1);
|
|
168
|
+
}
|
|
169
|
+
if (word.endsWith("ll") && measure(word) > 1) word = word.slice(0, -1);
|
|
170
|
+
|
|
171
|
+
return word;
|
|
172
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The tokenizer both sides of a match share — analyzer v2.
|
|
2
|
+
*
|
|
3
|
+
* Twin of `src/fux/query/tokenize.py`. The pipeline lives in `analyzer.mjs`;
|
|
4
|
+
* this is the stable entry point, kept so every caller gets *the same*
|
|
5
|
+
* analysis by construction rather than by review.
|
|
6
|
+
*/
|
|
7
|
+
import { analyze, analyzePairs, STOPWORDS } from "./analyzer.mjs";
|
|
8
|
+
|
|
9
|
+
export function tokenize(text) { return analyze(text); }
|
|
10
|
+
export function tokenizePairs(text) { return analyzePairs(text); }
|
|
11
|
+
export { STOPWORDS };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Fill a byte budget with the highest-value passages that fit.
|
|
2
|
+
* Twin of `src/fux/refer/_assemble.py`.
|
|
3
|
+
*
|
|
4
|
+
* Greedy by **score per byte** — the question is not "what scores highest" but
|
|
5
|
+
* "what is the most answer per byte of the caller's window".
|
|
6
|
+
*/
|
|
7
|
+
import { cmpCodePoints } from "../compat/pyfloat.mjs";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_BUDGET = 8000;
|
|
10
|
+
export const PER_DOC_FRACTION = 0.5;
|
|
11
|
+
/** Charged per citation, so the budget bounds the RENDERED answer and not
|
|
12
|
+
* merely its payload. */
|
|
13
|
+
export const CITATION_OVERHEAD = 80;
|
|
14
|
+
|
|
15
|
+
const enc = new TextEncoder();
|
|
16
|
+
|
|
17
|
+
export function assemble(scored, {
|
|
18
|
+
budget = DEFAULT_BUDGET, k = null, source = "fetched",
|
|
19
|
+
overhead = 0, perDocFraction = PER_DOC_FRACTION,
|
|
20
|
+
} = {}) {
|
|
21
|
+
if (budget <= 0) throw new RangeError("budget must be positive");
|
|
22
|
+
|
|
23
|
+
const candidates = scored.filter((s) => s.score > 0);
|
|
24
|
+
if (!candidates.length) return { citations: [], budget, used: overhead, dropped: 0 };
|
|
25
|
+
|
|
26
|
+
const cmp = (a, b) => {
|
|
27
|
+
if (a.score !== b.score) return a.score > b.score ? -1 : 1;
|
|
28
|
+
const s = cmpCodePoints(a.sha, b.sha);
|
|
29
|
+
return s !== 0 ? s : cmpCodePoints(a.locator, b.locator);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// The floor: the best answer by ABSOLUTE score is seated first, so greedy
|
|
33
|
+
// score-per-byte cannot crowd it out with cheaper fragments.
|
|
34
|
+
let best = candidates[0];
|
|
35
|
+
for (const s of candidates) if (cmp(s, best) < 0) best = s;
|
|
36
|
+
|
|
37
|
+
const rest = candidates.filter((s) => s !== best);
|
|
38
|
+
rest.sort((a, b) => {
|
|
39
|
+
const ra = -(a.score / Math.max(a.nbytes, 1));
|
|
40
|
+
const rb = -(b.score / Math.max(b.nbytes, 1));
|
|
41
|
+
if (ra !== rb) return ra < rb ? -1 : 1;
|
|
42
|
+
const s = cmpCodePoints(a.sha, b.sha);
|
|
43
|
+
return s !== 0 ? s : cmpCodePoints(a.locator, b.locator);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// **The cap protects against competition, so with no competition there is
|
|
47
|
+
// nothing to protect.** Keyed on the candidates' own documents, not on `k`
|
|
48
|
+
// or the caller's intent.
|
|
49
|
+
const single = new Set(candidates.map((s) => s.doc_id)).size <= 1;
|
|
50
|
+
const perDocCap = single ? budget : Math.floor(budget * perDocFraction);
|
|
51
|
+
|
|
52
|
+
let used = overhead, dropped = 0;
|
|
53
|
+
const perDoc = new Map();
|
|
54
|
+
const chosen = [];
|
|
55
|
+
|
|
56
|
+
for (const s of [best, ...rest]) {
|
|
57
|
+
if (k !== null && chosen.length >= k) { dropped++; continue; }
|
|
58
|
+
const text = s.passage.text;
|
|
59
|
+
const citation = {
|
|
60
|
+
doc_id: s.doc_id, locator: s.locator, sha: s.sha,
|
|
61
|
+
heading: s.passage.heading, text, score: s.score, source,
|
|
62
|
+
// ⚠ The overhead is part of a citation's OWN size, not a separate line
|
|
63
|
+
// item: the locator line and separator will be rendered around every
|
|
64
|
+
// passage, so the budget bounds the rendered answer rather than the
|
|
65
|
+
// payload. Charging it here is what makes `used` mean what it says.
|
|
66
|
+
nbytes: enc.encode(text).length + CITATION_OVERHEAD,
|
|
67
|
+
};
|
|
68
|
+
const spent = perDoc.get(s.doc_id) ?? 0;
|
|
69
|
+
// A document's FIRST citation is exempt: the cap exists to stop a document
|
|
70
|
+
// DOMINATING, not to stop it appearing.
|
|
71
|
+
const capped = perDoc.has(s.doc_id) && spent + citation.nbytes > perDocCap;
|
|
72
|
+
if (used + citation.nbytes > budget || capped) { dropped++; continue; }
|
|
73
|
+
chosen.push(citation);
|
|
74
|
+
used += citation.nbytes;
|
|
75
|
+
perDoc.set(s.doc_id, spent + citation.nbytes);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Presented in SCORE order — the caller reads the best answer first, even
|
|
79
|
+
// though selection ran on score-per-byte.
|
|
80
|
+
chosen.sort(cmp);
|
|
81
|
+
return { citations: chosen, budget, used, dropped };
|
|
82
|
+
}
|