fux-engine 2.0.0-alpha.7 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 };
@@ -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
- }
@@ -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 };
@@ -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
- }
@@ -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 };
@@ -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
- }