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.
- package/README.md +74 -12
- package/fux.mjs +0 -0
- package/package.json +3 -3
- package/src/compat/pyfloat.mjs +0 -95
- package/src/config/root.mjs +0 -16
- package/src/decode/markdown.mjs +0 -52
- package/src/graph/community.mjs +0 -59
- package/src/graph/model.mjs +0 -67
- package/src/graph/plane.mjs +0 -51
- package/src/hash/blake2b.mjs +0 -208
- package/src/index.mjs +0 -38
- package/src/ingest/priors.mjs +0 -35
- package/src/query/analyzer.mjs +0 -81
- package/src/query/bm25f.mjs +0 -89
- package/src/query/confidence.mjs +0 -163
- package/src/query/expand.mjs +0 -55
- package/src/query/fuse.mjs +0 -49
- package/src/query/headings.mjs +0 -30
- package/src/query/rank.mjs +0 -158
- package/src/query/run.mjs +0 -74
- package/src/query/scan.mjs +0 -106
- package/src/query/stem.mjs +0 -172
- package/src/query/tokenize.mjs +0 -11
- package/src/refer/assemble.mjs +0 -82
- package/src/refer/chunk.mjs +0 -263
- package/src/refer/freshness.mjs +0 -51
- package/src/refer/rescore.mjs +0 -106
- package/src/refer/source.mjs +0 -45
- package/src/store/format.mjs +0 -37
- package/src/store/reader.mjs +0 -87
- package/src/verbs/answer.mjs +0 -143
- package/src/verbs/ask.mjs +0 -48
- package/src/verbs/find.mjs +0 -24
- package/src/verbs/graph.mjs +0 -110
- package/src/verbs/mcp.mjs +0 -145
package/src/hash/blake2b.mjs
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
/** BLAKE2b (RFC 7693), stdlib-free, at arbitrary digest sizes.
|
|
2
|
-
*
|
|
3
|
-
* ⚠ **`node:crypto` cannot do this job.** It exposes `blake2b512` and nothing
|
|
4
|
-
* else, and **truncating a 512-bit digest is a different function**: BLAKE2b's
|
|
5
|
-
* parameter block folds the digest length into `h[0]`, so `blake2b(x, 8)` and
|
|
6
|
-
* `blake2b(x, 64)[:8]` disagree on every input. Fux keys its postings on
|
|
7
|
-
* `blake2b(term, digest_size=8)`, so a truncation here would produce an index
|
|
8
|
-
* that reads as valid and matches nothing.
|
|
9
|
-
*
|
|
10
|
-
* **64-bit words as 32-bit halves, never BigInt** (W-107 §6.3). Each 64-bit
|
|
11
|
-
* word is two `Uint32Array` slots — lo at `2i`, hi at `2i+1`. BigInt would be
|
|
12
|
-
* correct and roughly an order of magnitude slower on the hot path, and the
|
|
13
|
-
* hot path is every term of every query.
|
|
14
|
-
*
|
|
15
|
-
* Transcribed from RFC 7693 §3.1–§3.3. Pinned in `test/blake2b.test.mjs`
|
|
16
|
-
* against RFC 7693 Appendix A and against Python `hashlib` at digest sizes
|
|
17
|
-
* 1, 8 and 20 — the three fux uses (shard bucket, term key, content sha).
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
// IV — RFC 7693 §2.6, the SHA-512 IV. lo, hi per word.
|
|
21
|
-
const IV32 = new Uint32Array([
|
|
22
|
-
0xf3bcc908, 0x6a09e667, 0x84caa73b, 0xbb67ae85,
|
|
23
|
-
0xfe94f82b, 0x3c6ef372, 0x5f1d36f1, 0xa54ff53a,
|
|
24
|
-
0xade682d1, 0x510e527f, 0x2b3e6c1f, 0x9b05688c,
|
|
25
|
-
0xfb41bd6b, 0x1f83d9ab, 0x137e2179, 0x5be0cd19,
|
|
26
|
-
]);
|
|
27
|
-
|
|
28
|
-
// SIGMA — RFC 7693 §2.7, ten permutations, rounds 11/12 reuse 0/1.
|
|
29
|
-
// Pre-doubled: these index the 32-bit halves directly.
|
|
30
|
-
const SIGMA82 = new Uint8Array([
|
|
31
|
-
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
|
|
32
|
-
14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3,
|
|
33
|
-
11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4,
|
|
34
|
-
7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8,
|
|
35
|
-
9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13,
|
|
36
|
-
2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9,
|
|
37
|
-
12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11,
|
|
38
|
-
13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10,
|
|
39
|
-
6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5,
|
|
40
|
-
10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0,
|
|
41
|
-
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
|
|
42
|
-
14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3,
|
|
43
|
-
].map((x) => x * 2));
|
|
44
|
-
|
|
45
|
-
const v = new Uint32Array(32);
|
|
46
|
-
const m = new Uint32Array(32);
|
|
47
|
-
|
|
48
|
-
/** v[a] += v[b], 64-bit, carrying lo into hi. */
|
|
49
|
-
function add64AA(dst, a, b) {
|
|
50
|
-
const o0 = dst[a] + dst[b];
|
|
51
|
-
let o1 = dst[a + 1] + dst[b + 1];
|
|
52
|
-
if (o0 >= 0x100000000) o1++;
|
|
53
|
-
dst[a] = o0;
|
|
54
|
-
dst[a + 1] = o1;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** v[a] += (b1 << 32) | b0. `b0 < 0` is a sign-extended 32-bit value. */
|
|
58
|
-
function add64AC(dst, a, b0, b1) {
|
|
59
|
-
let o0 = dst[a] + b0;
|
|
60
|
-
if (b0 < 0) o0 += 0x100000000;
|
|
61
|
-
let o1 = dst[a + 1] + b1;
|
|
62
|
-
if (o0 >= 0x100000000) o1++;
|
|
63
|
-
dst[a] = o0;
|
|
64
|
-
dst[a + 1] = o1;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function get32(arr, i) {
|
|
68
|
-
return (arr[i] ^ (arr[i + 1] << 8) ^ (arr[i + 2] << 16) ^ (arr[i + 3] << 24));
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** The G mixing function — RFC 7693 §3.1. Rotations 32, 24, 16, 63. */
|
|
72
|
-
function g(a, b, c, d, ix, iy) {
|
|
73
|
-
const x0 = m[ix], x1 = m[ix + 1];
|
|
74
|
-
const y0 = m[iy], y1 = m[iy + 1];
|
|
75
|
-
|
|
76
|
-
add64AA(v, a, b);
|
|
77
|
-
add64AC(v, a, x0, x1);
|
|
78
|
-
|
|
79
|
-
// rotr 32 — a whole-word swap, so it is free
|
|
80
|
-
let xor0 = v[d] ^ v[a];
|
|
81
|
-
let xor1 = v[d + 1] ^ v[a + 1];
|
|
82
|
-
v[d] = xor1;
|
|
83
|
-
v[d + 1] = xor0;
|
|
84
|
-
|
|
85
|
-
add64AA(v, c, d);
|
|
86
|
-
|
|
87
|
-
// rotr 24
|
|
88
|
-
xor0 = v[b] ^ v[c];
|
|
89
|
-
xor1 = v[b + 1] ^ v[c + 1];
|
|
90
|
-
v[b] = (xor0 >>> 24) ^ (xor1 << 8);
|
|
91
|
-
v[b + 1] = (xor1 >>> 24) ^ (xor0 << 8);
|
|
92
|
-
|
|
93
|
-
add64AA(v, a, b);
|
|
94
|
-
add64AC(v, a, y0, y1);
|
|
95
|
-
|
|
96
|
-
// rotr 16
|
|
97
|
-
xor0 = v[d] ^ v[a];
|
|
98
|
-
xor1 = v[d + 1] ^ v[a + 1];
|
|
99
|
-
v[d] = (xor0 >>> 16) ^ (xor1 << 16);
|
|
100
|
-
v[d + 1] = (xor1 >>> 16) ^ (xor0 << 16);
|
|
101
|
-
|
|
102
|
-
add64AA(v, c, d);
|
|
103
|
-
|
|
104
|
-
// rotr 63
|
|
105
|
-
xor0 = v[b] ^ v[c];
|
|
106
|
-
xor1 = v[b + 1] ^ v[c + 1];
|
|
107
|
-
v[b] = (xor1 >>> 31) ^ (xor0 << 1);
|
|
108
|
-
v[b + 1] = (xor0 >>> 31) ^ (xor1 << 1);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** The compression function F — RFC 7693 §3.2. */
|
|
112
|
-
function compress(ctx, last) {
|
|
113
|
-
let i = 0;
|
|
114
|
-
for (i = 0; i < 16; i++) {
|
|
115
|
-
v[i] = ctx.h[i];
|
|
116
|
-
v[i + 16] = IV32[i];
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// v[12,13] ^= t (the byte counter); v[14,15] ^= 0xff.. on the final block
|
|
120
|
-
v[24] = v[24] ^ ctx.t;
|
|
121
|
-
v[25] = v[25] ^ (ctx.t / 0x100000000);
|
|
122
|
-
if (last) {
|
|
123
|
-
v[28] = ~v[28];
|
|
124
|
-
v[29] = ~v[29];
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
for (i = 0; i < 32; i++) m[i] = get32(ctx.b, 4 * i);
|
|
128
|
-
|
|
129
|
-
for (i = 0; i < 12; i++) {
|
|
130
|
-
const s = SIGMA82.subarray(i * 16, i * 16 + 16);
|
|
131
|
-
g(0, 8, 16, 24, s[0], s[1]);
|
|
132
|
-
g(2, 10, 18, 26, s[2], s[3]);
|
|
133
|
-
g(4, 12, 20, 28, s[4], s[5]);
|
|
134
|
-
g(6, 14, 22, 30, s[6], s[7]);
|
|
135
|
-
g(0, 10, 20, 30, s[8], s[9]);
|
|
136
|
-
g(2, 12, 22, 24, s[10], s[11]);
|
|
137
|
-
g(4, 14, 16, 26, s[12], s[13]);
|
|
138
|
-
g(6, 8, 18, 28, s[14], s[15]);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
for (i = 0; i < 16; i++) ctx.h[i] = ctx.h[i] ^ v[i] ^ v[i + 16];
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Init — RFC 7693 §3.3. The parameter block is why truncation is wrong. */
|
|
145
|
-
function init(outlen, key) {
|
|
146
|
-
if (!Number.isInteger(outlen) || outlen < 1 || outlen > 64) {
|
|
147
|
-
throw new RangeError(`blake2b digest size must be 1..64, got ${outlen}`);
|
|
148
|
-
}
|
|
149
|
-
const keylen = key ? key.length : 0;
|
|
150
|
-
if (keylen > 64) throw new RangeError("blake2b key must be <= 64 bytes");
|
|
151
|
-
|
|
152
|
-
const ctx = {
|
|
153
|
-
b: new Uint8Array(128),
|
|
154
|
-
h: new Uint32Array(16),
|
|
155
|
-
t: 0, // bytes compressed
|
|
156
|
-
c: 0, // bytes in the buffer
|
|
157
|
-
outlen,
|
|
158
|
-
};
|
|
159
|
-
for (let i = 0; i < 16; i++) ctx.h[i] = IV32[i];
|
|
160
|
-
// h[0] ^= 0x01010000 ^ (keylen << 8) ^ outlen
|
|
161
|
-
ctx.h[0] ^= 0x01010000 ^ (keylen << 8) ^ outlen;
|
|
162
|
-
|
|
163
|
-
if (keylen > 0) {
|
|
164
|
-
update(ctx, key);
|
|
165
|
-
ctx.c = 128;
|
|
166
|
-
}
|
|
167
|
-
return ctx;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function update(ctx, input) {
|
|
171
|
-
for (let i = 0; i < input.length; i++) {
|
|
172
|
-
if (ctx.c === 128) {
|
|
173
|
-
ctx.t += ctx.c;
|
|
174
|
-
compress(ctx, false);
|
|
175
|
-
ctx.c = 0;
|
|
176
|
-
}
|
|
177
|
-
ctx.b[ctx.c++] = input[i];
|
|
178
|
-
}
|
|
179
|
-
return ctx;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function final(ctx) {
|
|
183
|
-
ctx.t += ctx.c;
|
|
184
|
-
while (ctx.c < 128) ctx.b[ctx.c++] = 0;
|
|
185
|
-
compress(ctx, true);
|
|
186
|
-
|
|
187
|
-
const out = new Uint8Array(ctx.outlen);
|
|
188
|
-
for (let i = 0; i < ctx.outlen; i++) {
|
|
189
|
-
out[i] = (ctx.h[i >> 2] >> (8 * (i & 3))) & 0xff;
|
|
190
|
-
}
|
|
191
|
-
return out;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const HEX = [];
|
|
195
|
-
for (let i = 0; i < 256; i++) HEX.push(i.toString(16).padStart(2, "0"));
|
|
196
|
-
|
|
197
|
-
/** `blake2b(bytes, digestSize)` -> Uint8Array. */
|
|
198
|
-
export function blake2b(input, digestSize = 64, key = null) {
|
|
199
|
-
return final(update(init(digestSize, key), input));
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** `blake2b(bytes, digestSize)` -> lowercase hex, like Python's `.hexdigest()`. */
|
|
203
|
-
export function blake2bHex(input, digestSize = 64, key = null) {
|
|
204
|
-
const out = blake2b(input, digestSize, key);
|
|
205
|
-
let s = "";
|
|
206
|
-
for (let i = 0; i < out.length; i++) s += HEX[out[i]];
|
|
207
|
-
return s;
|
|
208
|
-
}
|
package/src/index.mjs
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
/** The library surface — the half of `fux-engine` that is not a CLI.
|
|
2
|
-
*
|
|
3
|
-
* Mirrors `fux.api` in Python method for method, argument for argument, and
|
|
4
|
-
* returns the same shapes the `--json` payload carries (W-107 R3). A frontend
|
|
5
|
-
* project wants this; shelling out to a binary is the fallback, not the point.
|
|
6
|
-
*
|
|
7
|
-
* import { open } from 'fux-engine'
|
|
8
|
-
* const ix = await open('.')
|
|
9
|
-
* await ix.find('rollback', { top: 5 })
|
|
10
|
-
*/
|
|
11
|
-
import { findRoot } from "./config/root.mjs";
|
|
12
|
-
import { ask as scanAsk } from "./query/scan.mjs";
|
|
13
|
-
|
|
14
|
-
class Index {
|
|
15
|
-
constructor(root) { this.root = root; }
|
|
16
|
-
|
|
17
|
-
/** Ranked document locations. */
|
|
18
|
-
async find(query, { top = 5 } = {}) {
|
|
19
|
-
return scanAsk(this.root, query, top);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async ask() { throw new Error("ask lands in W-107 Phase 2"); }
|
|
23
|
-
async answer() { throw new Error("answer lands in W-107 Phase 2"); }
|
|
24
|
-
async explain() { throw new Error("explain lands in W-107 Phase 3"); }
|
|
25
|
-
async graph() { throw new Error("graph lands in W-107 Phase 3"); }
|
|
26
|
-
async path() { throw new Error("path lands in W-107 Phase 3"); }
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Open the index at `root`, or at the first fux root above it. */
|
|
30
|
-
export async function open(root = process.cwd()) {
|
|
31
|
-
const resolved = findRoot(root);
|
|
32
|
-
if (resolved === null) {
|
|
33
|
-
throw new Error(`no fux root at or above ${root} — no fux.toml and no .git`);
|
|
34
|
-
}
|
|
35
|
-
return new Index(resolved);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export { Index };
|
package/src/ingest/priors.mjs
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/** The ranking priors' query-time half. Twin of `src/fux/ingest/priors.py`.
|
|
2
|
-
*
|
|
3
|
-
* Only `recencyMultiplier` and `supersededIds` have a Node twin: the git half
|
|
4
|
-
* (`git_commit_times`) is an INGEST concern, and Node does not ingest. `mtime`
|
|
5
|
-
* and `superseded` arrive as committed facts on the record, which is the whole
|
|
6
|
-
* reason they are committed — a derivation from local filesystem state would
|
|
7
|
-
* differ per machine and break L3.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/** A gentle exponential decay, normalised so the newest document is `1.0`.
|
|
11
|
-
*
|
|
12
|
-
* **Bounded to `(0, 1]` by design**: recency can demote an old document and
|
|
13
|
-
* can never promote a new one past a better match. That bound is load-bearing
|
|
14
|
-
* for the accelerator's block bound, not just good manners. */
|
|
15
|
-
export function recencyMultiplier(mtime, newest, halfLifeDays) {
|
|
16
|
-
if (halfLifeDays <= 0 || mtime === null || mtime === undefined || newest <= 0) return 1.0;
|
|
17
|
-
const ageDays = Math.max(0.0, (newest - mtime) / 86400.0);
|
|
18
|
-
return Math.pow(0.5, ageDays / halfLifeDays);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Doc ids that some other document DECLARES it supersedes.
|
|
22
|
-
* Declared, never inferred — nothing guesses from titles, numbering or dates. */
|
|
23
|
-
export function supersededIds(records) {
|
|
24
|
-
const out = new Set();
|
|
25
|
-
const known = new Set(records.map((r) => r.id));
|
|
26
|
-
for (const record of records) {
|
|
27
|
-
for (const edge of record.edges || []) {
|
|
28
|
-
if (edge.kind === "supersedes") {
|
|
29
|
-
const dst = edge.dst;
|
|
30
|
-
if (known.has(dst) && dst !== record.id) out.add(dst);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return out;
|
|
35
|
-
}
|
package/src/query/analyzer.mjs
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
/** Analyzer v2 — the pipeline both ingest and query run, in this order:
|
|
2
|
-
*
|
|
3
|
-
* split identifiers -> lower -> stopwords -> stem -> (caller hashes)
|
|
4
|
-
*
|
|
5
|
-
* Twin of `src/fux/query/analyzer.py`. The order is the whole design; two
|
|
6
|
-
* steps are load-bearing in a way that is easy to get backwards:
|
|
7
|
-
*
|
|
8
|
-
* 1. **Splitting happens BEFORE lowercasing** — case is the only signal that a
|
|
9
|
-
* boundary was there, so lowercasing first destroys `camelCase`.
|
|
10
|
-
* 2. **Stemming happens BEFORE hashing**, and the hash is of the final
|
|
11
|
-
* analyzed token. A one-step divergence between the two sides produces a
|
|
12
|
-
* silent no-match: the query hashes a string the index never wrote, and
|
|
13
|
-
* there is no error to see.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { stem } from "./stem.mjs";
|
|
17
|
-
|
|
18
|
-
/** Matched against the ORIGINAL text, not a lowercased copy. Hyphen is absent
|
|
19
|
-
* from the class, so `kebab-case` splits here for free. */
|
|
20
|
-
const WORD_RE = /[A-Za-z0-9_]+/g;
|
|
21
|
-
|
|
22
|
-
/** The three places a real identifier boundary can sit:
|
|
23
|
-
* `_` snake_case
|
|
24
|
-
* lower/digit -> upper getUser, bm25F
|
|
25
|
-
* upper -> upper+lower HTTPServer
|
|
26
|
-
* A token with none of these is left whole — `sha256`, `utf8`, `k1`. */
|
|
27
|
-
const BOUNDARY_RE = /_+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/;
|
|
28
|
-
|
|
29
|
-
const STOPWORDS = new Set(
|
|
30
|
-
`a an and are as at be but by for from has have how i if in into is it its
|
|
31
|
-
of on or that the their then there these this to was were what when where
|
|
32
|
-
which who why will with you your we our not can may`.split(/\s+/),
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
/** The parts of one raw token, or `[]` when there is no boundary in it. */
|
|
36
|
-
export function splitIdentifier(raw) {
|
|
37
|
-
const parts = raw.split(BOUNDARY_RE).filter((p) => p);
|
|
38
|
-
if (parts.length < 2) return [];
|
|
39
|
-
return parts.filter((p) => p.length > 1);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Text to final analyzed terms, in document order, WITH duplicates.
|
|
43
|
-
* Duplicates are the point: the caller counts them into a term frequency. */
|
|
44
|
-
export function analyze(text) {
|
|
45
|
-
const out = [];
|
|
46
|
-
const matches = text.match(WORD_RE);
|
|
47
|
-
if (!matches) return out;
|
|
48
|
-
for (const raw of matches) {
|
|
49
|
-
for (const token of [raw, ...splitIdentifier(raw)]) {
|
|
50
|
-
const lowered = token.toLowerCase();
|
|
51
|
-
if (STOPWORDS.has(lowered)) continue;
|
|
52
|
-
out.push(stem(lowered));
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return out;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** `[surface, analyzed]` for every term `analyze` produces, same order.
|
|
59
|
-
*
|
|
60
|
-
* The surface is the pre-lowercase, pre-stem token — what the user actually
|
|
61
|
-
* typed. `confidence` reports that, not the stem the index is keyed by,
|
|
62
|
-
* because *"`mtl` is not in this corpus"* is worse than saying nothing.
|
|
63
|
-
*
|
|
64
|
-
* ⚠ Duplicates `analyze`'s loop deliberately, exactly as Python does, and for
|
|
65
|
-
* the same reason: `analyze` runs over every token at ingest, and allocating
|
|
66
|
-
* a pair per token to serve a per-query diagnostic is the wrong trade. */
|
|
67
|
-
export function analyzePairs(text) {
|
|
68
|
-
const out = [];
|
|
69
|
-
const matches = text.match(WORD_RE);
|
|
70
|
-
if (!matches) return out;
|
|
71
|
-
for (const raw of matches) {
|
|
72
|
-
for (const token of [raw, ...splitIdentifier(raw)]) {
|
|
73
|
-
const lowered = token.toLowerCase();
|
|
74
|
-
if (STOPWORDS.has(lowered)) continue;
|
|
75
|
-
out.push([token, stem(lowered)]);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return out;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export { STOPWORDS };
|
package/src/query/bm25f.mjs
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
/** BM25F — weight THEN saturate, once. Twin of `src/fux/query/bm25f.py`.
|
|
2
|
-
*
|
|
3
|
-
* Every number here appears on both sides of the same fraction, which is why
|
|
4
|
-
* `Scoring` is ONE object and not three parameters: a caller that passes the
|
|
5
|
-
* weights and forgets `k1` reweights half a formula.
|
|
6
|
-
*/
|
|
7
|
-
import { TF_FIELDS } from "../store/format.mjs";
|
|
8
|
-
|
|
9
|
-
export const FIELD_WEIGHTS = [1.0, 3.0, 2.0, 1.5, 1.0];
|
|
10
|
-
if (FIELD_WEIGHTS.length !== TF_FIELDS.length) {
|
|
11
|
-
throw new Error("field weights must align with TF_FIELDS");
|
|
12
|
-
}
|
|
13
|
-
export const K1 = 1.2;
|
|
14
|
-
export const B = 0.75;
|
|
15
|
-
|
|
16
|
-
export class Scoring {
|
|
17
|
-
constructor(k1 = K1, b = B, weights = FIELD_WEIGHTS) {
|
|
18
|
-
this.k1 = k1; this.b = b; this.weights = weights;
|
|
19
|
-
Object.freeze(this);
|
|
20
|
-
}
|
|
21
|
-
/** True when this is the engine default, so callers can skip work. */
|
|
22
|
-
get trivial() {
|
|
23
|
-
return this.k1 === K1 && this.b === B
|
|
24
|
-
&& this.weights.length === FIELD_WEIGHTS.length
|
|
25
|
-
&& this.weights.every((w, i) => w === FIELD_WEIGHTS[i]);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const DEFAULT_SCORING = new Scoring();
|
|
30
|
-
|
|
31
|
-
/** ⚠ `Math.log` and Python's `math.log` disagree in the last ulp on ~0.7 % of
|
|
32
|
-
* inputs (Phase 0, measured on darwin and glibc). **Every difference is one
|
|
33
|
-
* ulp and none survives `round(9)`**, which is the sort key's own resolution
|
|
34
|
-
* — ADR-RANKING decision 8a. This is the tolerance Arpit ruled, option (b). */
|
|
35
|
-
export function idf(df, n) { return Math.log((n - df + 0.5) / (df + 0.5) + 1); }
|
|
36
|
-
|
|
37
|
-
/** The BM25F numerator for one term in one document.
|
|
38
|
-
* `tf` may be shorter than `weights` — trailing zeros are omitted on the wire,
|
|
39
|
-
* so iterating `tf` rather than `weights` makes the short form free. */
|
|
40
|
-
export function weightedTf(tf, scoring = DEFAULT_SCORING) {
|
|
41
|
-
let total = 0.0;
|
|
42
|
-
const weights = scoring.weights;
|
|
43
|
-
for (let i = 0; i < tf.length; i++) {
|
|
44
|
-
const count = tf[i];
|
|
45
|
-
if (count) total += weights[i] * count;
|
|
46
|
-
}
|
|
47
|
-
return total;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** The length normaliser, from committed per-field counts and live weights.
|
|
51
|
-
* **The one place this arithmetic exists** — four callers need it, and four
|
|
52
|
-
* copies is how they drift. */
|
|
53
|
-
export function deriveWlen(flen, scoring = DEFAULT_SCORING) {
|
|
54
|
-
let total = 0.0;
|
|
55
|
-
const weights = scoring.weights;
|
|
56
|
-
for (let i = 0; i < flen.length; i++) {
|
|
57
|
-
const count = flen[i];
|
|
58
|
-
if (count) total += weights[i] * count;
|
|
59
|
-
}
|
|
60
|
-
return total;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Sum of each matched query term's weight-then-saturate contribution.
|
|
64
|
-
*
|
|
65
|
-
* ⚠ `termWeights === null` performs **no multiply at all** — not a multiply by
|
|
66
|
-
* 1.0. An unexpanded query must do exactly the float arithmetic it did before
|
|
67
|
-
* `--expand` existed, or the differential law picks up a last-bit difference
|
|
68
|
-
* from the feature merely being present.
|
|
69
|
-
*/
|
|
70
|
-
export function scoreRecord(
|
|
71
|
-
terms, flen, queryHashes, df, n, avgWlen,
|
|
72
|
-
scoring = DEFAULT_SCORING, termWeights = null,
|
|
73
|
-
) {
|
|
74
|
-
if (n <= 0 || avgWlen <= 0) return 0.0;
|
|
75
|
-
const wlen = typeof flen === "number" ? flen : deriveWlen(flen, scoring);
|
|
76
|
-
const k1 = scoring.k1, b = scoring.b;
|
|
77
|
-
let total = 0.0;
|
|
78
|
-
for (const h of queryHashes) {
|
|
79
|
-
const tf = terms[h];
|
|
80
|
-
if (tf === undefined) continue;
|
|
81
|
-
const wtf = weightedTf(tf, scoring);
|
|
82
|
-
if (wtf === 0) continue;
|
|
83
|
-
const denom = wtf + k1 * (1 - b + b * wlen / avgWlen);
|
|
84
|
-
let contribution = idf(df[h] ?? 0, n) * wtf * (k1 + 1) / denom;
|
|
85
|
-
if (termWeights !== null) contribution *= (termWeights[h] ?? 1.0);
|
|
86
|
-
total += contribution;
|
|
87
|
-
}
|
|
88
|
-
return total;
|
|
89
|
-
}
|
package/src/query/confidence.mjs
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
/** The confidence band — what fux says out loud about its own answer.
|
|
2
|
-
* Twin of `confidence.py`.
|
|
3
|
-
*
|
|
4
|
-
* Four signals and two floors. **The floors arrive from the CALLER**, never
|
|
5
|
-
* from this module, so a single query cannot be judged by one floor and
|
|
6
|
-
* reported with another — and both are published on the block, because a
|
|
7
|
-
* `grounded` judged at 0.02 is a different claim from one judged at 0.10 and
|
|
8
|
-
* absent publication the difference would be invisible.
|
|
9
|
-
*/
|
|
10
|
-
import { idf } from "./bm25f.mjs";
|
|
11
|
-
import { termHash } from "../store/format.mjs";
|
|
12
|
-
import { tokenizePairs } from "./tokenize.mjs";
|
|
13
|
-
|
|
14
|
-
export const GROUNDED = "grounded";
|
|
15
|
-
export const WEAK = "weak";
|
|
16
|
-
export const PARTIAL = "partial";
|
|
17
|
-
export const NONE = "none";
|
|
18
|
-
|
|
19
|
-
export const SEPARATION_FLOOR = 0.10;
|
|
20
|
-
/** `0.0` means the clause is OFF — a measured ruling, not an oversight. */
|
|
21
|
-
export const DOC_COVERAGE_FLOOR = 0.0;
|
|
22
|
-
|
|
23
|
-
function round4(x) { return Math.round(x * 1e4) / 1e4; }
|
|
24
|
-
function clamp01(x) { return Math.max(0.0, Math.min(1.0, x)); }
|
|
25
|
-
|
|
26
|
-
export class Confidence {
|
|
27
|
-
constructor({
|
|
28
|
-
coverage = 0.0, separation = 0.0, support = 0, verified = "unverified",
|
|
29
|
-
missing = [], docCoverage = 1.0,
|
|
30
|
-
separationFloor = SEPARATION_FLOOR, docCoverageFloor = DOC_COVERAGE_FLOOR,
|
|
31
|
-
} = {}) {
|
|
32
|
-
this.coverage = coverage;
|
|
33
|
-
this.separation = separation;
|
|
34
|
-
this.support = support;
|
|
35
|
-
this.verified = verified;
|
|
36
|
-
this.missing = missing;
|
|
37
|
-
this.docCoverage = docCoverage;
|
|
38
|
-
this.separationFloor = separationFloor;
|
|
39
|
-
this.docCoverageFloor = docCoverageFloor;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** `grounded` · `partial` · `weak` · `none` — **first true clause wins.**
|
|
43
|
-
*
|
|
44
|
-
* `stale` demotes to `partial` rather than `weak` because stale bytes are a
|
|
45
|
-
* KNOWABLE defect a consumer can name, which is what `partial` means; `weak`
|
|
46
|
-
* is where nothing is identifiably wrong and the ranking cannot choose. */
|
|
47
|
-
get band() {
|
|
48
|
-
if (this.support === 0) return NONE;
|
|
49
|
-
if (this.missing.length || this.verified === "stale") return PARTIAL;
|
|
50
|
-
// The clause the decoy control bought: corpus-wide coverage cannot see
|
|
51
|
-
// that a question's terms are scattered across four documents while no
|
|
52
|
-
// single one discusses it, and separation cannot either — a corpus of
|
|
53
|
-
// near-misses is decisive about its best near-miss.
|
|
54
|
-
if (this.docCoverage < this.docCoverageFloor) return PARTIAL;
|
|
55
|
-
if (this.separation < this.separationFloor) return WEAK;
|
|
56
|
-
return GROUNDED;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** **A refusal, not a low number.** An agent handed `0.3` will use it anyway
|
|
60
|
-
* and hedge in prose; one handed `answerable: false` has nothing to hedge. */
|
|
61
|
-
get answerable() { return this.band !== NONE; }
|
|
62
|
-
|
|
63
|
-
/** The same signals with the refer plane's verdict filled in. `answer` ranks
|
|
64
|
-
* before it fetches, so the verdict does not exist when the rest are
|
|
65
|
-
* computed. A new object — `band` re-derives and cannot go stale. */
|
|
66
|
-
withVerified(verdict) {
|
|
67
|
-
return new Confidence({
|
|
68
|
-
coverage: this.coverage, separation: this.separation, support: this.support,
|
|
69
|
-
verified: verdict, missing: this.missing, docCoverage: this.docCoverage,
|
|
70
|
-
separationFloor: this.separationFloor, docCoverageFloor: this.docCoverageFloor,
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** The `--json` shape, declared in `output.schema.json#confidence`.
|
|
75
|
-
* `band` and `answerable` are written out rather than left derivable — a
|
|
76
|
-
* consumer re-implementing the band rules would be a second copy of this
|
|
77
|
-
* policy, in another language, drifting from the day it was written. */
|
|
78
|
-
asDict() {
|
|
79
|
-
return {
|
|
80
|
-
band: this.band,
|
|
81
|
-
answerable: this.answerable,
|
|
82
|
-
coverage: this.coverage,
|
|
83
|
-
separation: this.separation,
|
|
84
|
-
separation_floor: this.separationFloor,
|
|
85
|
-
doc_coverage: this.docCoverage,
|
|
86
|
-
doc_coverage_floor: this.docCoverageFloor,
|
|
87
|
-
support: this.support,
|
|
88
|
-
verified: this.verified,
|
|
89
|
-
missing: [...this.missing],
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Compute the block from what `rank()` already had in hand.
|
|
95
|
-
*
|
|
96
|
-
* `scores` is every score above zero, already sorted descending.
|
|
97
|
-
* **`missing` reports the SURFACE form, never the analyzed one** — *"`mtl` is
|
|
98
|
-
* not in this corpus"* is worse than silence; *"`mTLS` is"* is actionable.
|
|
99
|
-
*
|
|
100
|
-
* `support` is bounded by `--top` deliberately: a corpus-wide count would
|
|
101
|
-
* differ between `--fast` and `--scan`, which is the differential-law break
|
|
102
|
-
* the accelerator record exists to forbid. The law is worth more than the
|
|
103
|
-
* better number. */
|
|
104
|
-
export function signals(
|
|
105
|
-
pairs, queryHashes, df, n, scores,
|
|
106
|
-
{
|
|
107
|
-
verified = "unverified", topDocHashes = null,
|
|
108
|
-
separationFloor = SEPARATION_FLOOR, docCoverageFloor = DOC_COVERAGE_FLOOR,
|
|
109
|
-
} = {},
|
|
110
|
-
) {
|
|
111
|
-
if (!queryHashes.length || n <= 0) {
|
|
112
|
-
return new Confidence({ verified, separationFloor, docCoverageFloor });
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// First token per hash, mirroring `queryTermHashes`' de-duplication so the
|
|
116
|
-
// two lists cannot fall out of step on a repeated word.
|
|
117
|
-
const first = new Map();
|
|
118
|
-
for (const [surface, analyzed] of pairs) {
|
|
119
|
-
const h = termHash(analyzed);
|
|
120
|
-
if (!first.has(h)) first.set(h, surface);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
let total = 0.0, matched = 0.0;
|
|
124
|
-
const missing = [];
|
|
125
|
-
for (const h of queryHashes) {
|
|
126
|
-
const weight = idf(df[h] ?? 0, n);
|
|
127
|
-
total += weight;
|
|
128
|
-
if ((df[h] ?? 0) > 0) matched += weight;
|
|
129
|
-
else missing.push(first.get(h) ?? h);
|
|
130
|
-
}
|
|
131
|
-
const coverage = total > 0 ? matched / total : 0.0;
|
|
132
|
-
|
|
133
|
-
// The same idf weighting, over the TOP document's own terms rather than the
|
|
134
|
-
// corpus's. Weighted, not counted: missing `the` is nothing, missing `mTLS`
|
|
135
|
-
// is the question.
|
|
136
|
-
let docCoverage;
|
|
137
|
-
if (topDocHashes === null) docCoverage = 1.0; // not supplied — never demote on absence
|
|
138
|
-
else if (total > 0) {
|
|
139
|
-
const present = new Set(topDocHashes);
|
|
140
|
-
let s = 0.0;
|
|
141
|
-
for (const h of queryHashes) if (present.has(h)) s += idf(df[h] ?? 0, n);
|
|
142
|
-
docCoverage = s / total;
|
|
143
|
-
} else docCoverage = 1.0;
|
|
144
|
-
|
|
145
|
-
// One result separates perfectly: there is no runner-up to confuse it with.
|
|
146
|
-
let separation;
|
|
147
|
-
if (scores.length >= 2 && scores[0] > 0) separation = (scores[0] - scores[1]) / scores[0];
|
|
148
|
-
else if (scores.length === 1) separation = 1.0;
|
|
149
|
-
else separation = 0.0;
|
|
150
|
-
|
|
151
|
-
return new Confidence({
|
|
152
|
-
coverage: round4(clamp01(coverage)),
|
|
153
|
-
docCoverage: round4(clamp01(docCoverage)),
|
|
154
|
-
separation: round4(clamp01(separation)),
|
|
155
|
-
support: scores.length,
|
|
156
|
-
verified,
|
|
157
|
-
missing,
|
|
158
|
-
separationFloor,
|
|
159
|
-
docCoverageFloor,
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export { tokenizePairs };
|
package/src/query/expand.mjs
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
/** `--expand` — extra vocabulary the CALLER supplies. Twin of `expand.py`.
|
|
2
|
-
*
|
|
3
|
-
* Fux never invents terms: no model sits anywhere on the query path, so an
|
|
4
|
-
* expansion arrives from whoever asked. What this module owns is the rule that
|
|
5
|
-
* an expansion is a *supplement* — it may lift a document, never define one.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/** Every hash to score (originals first), what the user actually asked for,
|
|
9
|
-
* and at what weight. */
|
|
10
|
-
export class Expansion {
|
|
11
|
-
constructor(hashes, required, weights = {}) {
|
|
12
|
-
this.hashes = hashes;
|
|
13
|
-
this.required = required; // Set — the ORIGINAL query's hashes
|
|
14
|
-
this.weights = weights; // hash -> multiplier, expansion-only
|
|
15
|
-
Object.freeze(this);
|
|
16
|
-
}
|
|
17
|
-
/** True when this changes nothing — the `Scoring.trivial` precedent. */
|
|
18
|
-
get trivial() { return Object.keys(this.weights).length === 0; }
|
|
19
|
-
|
|
20
|
-
/** The identity: every hash required, nothing weighted. */
|
|
21
|
-
static none(queryHashes) {
|
|
22
|
-
const hashes = [...queryHashes];
|
|
23
|
-
return new Expansion(hashes, new Set(hashes), {});
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** 🔴 The hallucinated-citation guard. A record matching ONLY expansion
|
|
27
|
-
* terms is not a weak answer to rank low — it is an answer to a question
|
|
28
|
-
* nobody asked, and `rank()` drops it outright. */
|
|
29
|
-
matches(terms) {
|
|
30
|
-
for (const h of this.required) if (h in terms) return true;
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
weightOf(h) { return this.weights[h] ?? 1.0; }
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Combine the query's hashes with an expansion's, at `weight`.
|
|
38
|
-
*
|
|
39
|
-
* **A hash the original query already carries stays at 1.0**, even when the
|
|
40
|
-
* expansion repeats it — otherwise a caller could quietly demote their own
|
|
41
|
-
* query by mentioning one of its own words. `weight <= 0` is off, and off must
|
|
42
|
-
* be reachable by configuration as well as by omission. */
|
|
43
|
-
export function build(queryHashes, expansionHashes, weight) {
|
|
44
|
-
if (weight <= 0 || !expansionHashes || !expansionHashes.length) {
|
|
45
|
-
return Expansion.none(queryHashes);
|
|
46
|
-
}
|
|
47
|
-
const original = [...new Set(queryHashes)];
|
|
48
|
-
const required = new Set(original);
|
|
49
|
-
const extra = [...new Set(expansionHashes)].filter((h) => !required.has(h));
|
|
50
|
-
if (!extra.length) return Expansion.none(original);
|
|
51
|
-
|
|
52
|
-
const weights = {};
|
|
53
|
-
for (const h of extra) weights[h] = weight;
|
|
54
|
-
return new Expansion([...original, ...extra], required, weights);
|
|
55
|
-
}
|