@fangorn-network/westmarch 0.0.1-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,251 @@
1
+ // In-browser query embedder. Lifted from embeddings/examples/src/lib/embed.ts —
2
+ // the query vector MUST come out of the same model and post-processing as the
3
+ // document vectors in the shard, or cosine is meaningless.
4
+ //
5
+ // Document side (quickbeam/embeddings.py): fastembed nomic-embed-text-v1.5 on
6
+ // "search_document: " + text, then matryoshka(vec, 256).
7
+ // Query side (here): same model on "search_query: " + text (nomic is asymmetric),
8
+ // then the identical matryoshka.
9
+ //
10
+ // If the embedding pipeline switches model or dim, change MODEL/DIM here to match.
11
+ // transformers.js is dynamically imported so its ~MB runtime stays out of the main
12
+ // bundle and a load failure degrades to lexical search instead of breaking the app.
13
+
14
+ const MODEL = "nomic-ai/nomic-embed-text-v1.5";
15
+ const DIM = 256;
16
+
17
+ // Stamped onto every vector a publisher commits. Vectors from different models
18
+ // (or dims) are not comparable — cosine between them is noise, and silently so.
19
+ // The shard bake drops any vector whose stamp doesn't match rather than blending
20
+ // corpora, which is the one thing that can quietly ruin a shared index.
21
+ export const EMBED_MODEL = MODEL;
22
+ export const EMBED_DIM = DIM;
23
+
24
+ let _extractor = null;
25
+ const extractor = () => (_extractor ??= (async () => {
26
+ const { pipeline } = await import("@huggingface/transformers");
27
+ return pipeline("feature-extraction", MODEL, { dtype: "q8" });
28
+ })());
29
+
30
+ /** Standardize over the full vector, slice to `dim`, L2-normalize the slice. */
31
+ function matryoshka(vec, dim = DIM) {
32
+ const n = vec.length;
33
+ let mean = 0;
34
+ for (let i = 0; i < n; i++) mean += vec[i];
35
+ mean /= n;
36
+ let varsum = 0;
37
+ for (let i = 0; i < n; i++) varsum += (vec[i] - mean) ** 2;
38
+ const std = Math.sqrt(varsum / n + 1e-5);
39
+
40
+ const out = new Array(Math.min(dim, n));
41
+ for (let i = 0; i < out.length; i++) out[i] = (vec[i] - mean) / std;
42
+
43
+ let norm = 0;
44
+ for (const x of out) norm += x * x;
45
+ norm = Math.sqrt(norm);
46
+ if (norm) for (let i = 0; i < out.length; i++) out[i] /= norm;
47
+ return out;
48
+ }
49
+
50
+ /** Free text → 256-d L2-normalized vector. Throws if the model can't load —
51
+ * callers fall back to lexical. First call downloads the model (slow). */
52
+ export async function embedQueryDirect(text) {
53
+ const ex = await extractor();
54
+ const output = await ex(`search_query: ${text}`, { pooling: "mean", normalize: false });
55
+ return matryoshka(output.data);
56
+ }
57
+
58
+ /** One document, in-process. The batch loop stays with the caller — see embedDocuments. */
59
+ export async function embedDocumentDirect(text) {
60
+ const ex = await extractor();
61
+ const output = await ex(`search_document: ${text}`, { pooling: "mean", normalize: false });
62
+ return matryoshka(output.data);
63
+ }
64
+
65
+ /** Load the model in-process, and settle when it is actually ready. */
66
+ export const warmDirect = () => extractor().then(() => {});
67
+
68
+ // ── off the main thread ──────────────────────────────────────────────────────
69
+ //
70
+ // The public API below is unchanged, so no caller knows where the work happens.
71
+ // In a browser it happens in embed.worker.js; in Node (the publish script, the
72
+ // self-checks) and inside the worker itself there is no Worker to spawn, so it
73
+ // runs in-process. `window`, not `Worker`: a worker context HAS Worker and would
74
+ // otherwise spawn a worker per query, forever.
75
+ const offThread = () => typeof window !== "undefined" && typeof Worker !== "undefined";
76
+
77
+ let _w = null, _seq = 0;
78
+ const _pending = new Map();
79
+
80
+ function hub() {
81
+ if (_w) return _w;
82
+ _w = new Worker(new URL("./embed.worker.js", import.meta.url), { type: "module" });
83
+ _w.onmessage = ({ data }) => {
84
+ const job = _pending.get(data.id);
85
+ if (!job) return;
86
+ _pending.delete(data.id);
87
+ data.error ? job.reject(new Error(data.error)) : job.resolve(data.vec);
88
+ };
89
+ // A worker that fails to BOOT never answers a message, so every caller would
90
+ // hang. Fail them all and drop the worker, so the next call retries.
91
+ _w.onerror = (e) => {
92
+ for (const job of _pending.values()) job.reject(new Error(`embed worker: ${e?.message ?? "failed to start"}`));
93
+ _pending.clear();
94
+ _w = null;
95
+ };
96
+ return _w;
97
+ }
98
+
99
+ const ask = (kind, text) => new Promise((resolve, reject) => {
100
+ const id = ++_seq;
101
+ _pending.set(id, { resolve, reject });
102
+ hub().postMessage({ id, kind, text });
103
+ });
104
+
105
+ /** Free text → 256-d L2-normalized vector. Throws if the model can't load —
106
+ * callers fall back to lexical. First call downloads the model (slow). */
107
+ export const embedQuery = (text) => (offThread() ? ask("query", text) : embedQueryDirect(text));
108
+
109
+ /**
110
+ * Document side. Same model, the OTHER nomic prefix — it's an asymmetric model,
111
+ * so documents embedded with "search_query: " would rank against real queries
112
+ * about as well as random.
113
+ *
114
+ * Runs one at a time on purpose: this executes in a publisher's browser during
115
+ * publish, and a batch large enough to matter is a batch large enough to lock
116
+ * the tab. `onProgress(done, total)` drives the publish progress bar.
117
+ */
118
+ export async function embedDocuments(texts, onProgress) {
119
+ const one = offThread() ? (t) => ask("document", t) : embedDocumentDirect;
120
+ const out = [];
121
+ for (const text of texts) {
122
+ out.push(await one(text));
123
+ onProgress?.(out.length, texts.length);
124
+ }
125
+ return out;
126
+ }
127
+
128
+ // ── wire format ───────────────────────────────────────────────────────────────
129
+ // Vectors are L2-normalized, so every component is in [-1, 1] and int8 costs one
130
+ // byte instead of ~9 for a JSON float. 256 floats: ~2.3 KB as JSON, 344 chars as
131
+ // base64 int8. That matters twice over — the vectors ride in the on-chain graph
132
+ // payload (which /api/catalog walks on every cold hit) AND in the shard every
133
+ // buyer downloads.
134
+ //
135
+ // ponytail: flat int8, no per-vector scale. Components of a unit vector in 256
136
+ // dims are ~0.06 RMS, so 1/127 steps cost far less recall than the retrieval
137
+ // noise floor. Add a scale factor only if a measured recall drop says to.
138
+
139
+ /** Float vector → base64 int8. */
140
+ export function packVec(vec) {
141
+ const q = new Uint8Array(vec.length);
142
+ for (let i = 0; i < vec.length; i++) {
143
+ const v = Math.round(Math.max(-1, Math.min(1, vec[i])) * 127);
144
+ q[i] = v < 0 ? v + 256 : v; // two's complement in a byte
145
+ }
146
+ let s = "";
147
+ for (const b of q) s += String.fromCharCode(b);
148
+ return btoa(s);
149
+ }
150
+
151
+ /** base64 int8 → Float32Array. Returns null on anything malformed — a bad vector
152
+ * must degrade that row to lexical, never throw mid-search. */
153
+ export function unpackVec(b64) {
154
+ try {
155
+ const s = atob(b64);
156
+ const out = new Float32Array(s.length);
157
+ for (let i = 0; i < s.length; i++) {
158
+ const b = s.charCodeAt(i);
159
+ out[i] = (b > 127 ? b - 256 : b) / 127;
160
+ }
161
+ return out.length ? out : null;
162
+ } catch { return null; }
163
+ }
164
+
165
+ /**
166
+ * A unit vector as SIGN BITS — one bit per dimension, eight to a byte, base64.
167
+ *
168
+ * For LINKS, not for ranking storage: 256 dims land in 32 bytes (43 chars)
169
+ * instead of int8's 256 bytes (344 chars), which is the difference between a
170
+ * channel link you can paste in a message and one that wraps four times.
171
+ *
172
+ * The sign vector is a worse query than the float one, but not by much, and the
173
+ * cost is measurable rather than a guess: over 7k real catalog vectors, the
174
+ * top-80 pool it selects sits at mean cosine 0.808 to the true query where the
175
+ * float query's own pool sits at 0.824 — a ~2% dilution of the neighbourhood for
176
+ * an 8x shorter link. Both sides of a shared channel walk the SAME sign vector
177
+ * (see tuned()), so there is no drift between them either way.
178
+ *
179
+ * ponytail: 1 bit. If the neighbourhood ever reads as visibly loose, 4 bits with
180
+ * a per-vector scale measured 0.822 — indistinguishable from float — at 172 chars.
181
+ */
182
+ export function packBits(vec) {
183
+ const q = new Uint8Array(Math.ceil(vec.length / 8));
184
+ for (let i = 0; i < vec.length; i++) if (vec[i] >= 0) q[i >> 3] |= 128 >> (i & 7);
185
+ let s = "";
186
+ for (const b of q) s += String.fromCharCode(b);
187
+ return btoa(s);
188
+ }
189
+
190
+ /** base64 sign bits → Float32Array of ±1. Null on anything malformed, same
191
+ * contract as unpackVec: a pasted link is untrusted input. */
192
+ export function unpackBits(b64) {
193
+ try {
194
+ // atob is strict about length: a stripped "=" is normal in a URL.
195
+ const s = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "="));
196
+ const out = new Float32Array(s.length * 8);
197
+ for (let i = 0; i < out.length; i++) out[i] = (s.charCodeAt(i >> 3) >> (7 - (i & 7))) & 1 ? 1 : -1;
198
+ return out.length ? out : null;
199
+ } catch { return null; }
200
+ }
201
+
202
+ /**
203
+ * Start loading the model. Never rejects, and safe to call on every keystroke or
204
+ * focus — the pipeline is a memoized singleton on whichever thread owns it.
205
+ *
206
+ * Call this on INTENT (the search box taking focus), never on page load: it is a
207
+ * 131MB download, and until someone types there is nothing for it to embed. The
208
+ * whole first-paint path — browse, shelves, channels, the field, the compass —
209
+ * runs on the document vectors already in the shard.
210
+ */
211
+ export const warmEmbedder = () => {
212
+ if (offThread()) ask("warm").catch(() => {});
213
+ else warmDirect().catch(() => {});
214
+ };
215
+
216
+ // ── self-check: `node src/llm/embed.js` — quantization only, no model download ────
217
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
218
+ const v = matryoshka(Array.from({ length: 768 }, (_, i) => Math.sin(i)));
219
+ let norm = 0;
220
+ for (const x of v) norm += x * x;
221
+ if (Math.abs(Math.sqrt(norm) - 1) > 1e-6) throw new Error("matryoshka must L2-normalize");
222
+ if (v.length !== DIM) throw new Error(`matryoshka must truncate to ${DIM}`);
223
+
224
+ const round = unpackVec(packVec(v));
225
+ if (round.length !== v.length) throw new Error("pack/unpack changed the dimension");
226
+ // Round-trip must stay near-parallel, or cosine ranking shifts under quantization.
227
+ let dot = 0, n2 = 0;
228
+ for (let i = 0; i < v.length; i++) { dot += v[i] * round[i]; n2 += round[i] * round[i]; }
229
+ const cos = dot / Math.sqrt(n2);
230
+ if (cos < 0.999) throw new Error(`int8 round-trip cosine ${cos} — too lossy to rank with`);
231
+
232
+ // Negatives are the easy thing to get wrong in a byte round-trip.
233
+ const neg = unpackVec(packVec([-1, -0.5, 0, 0.5, 1]));
234
+ if (neg[0] > -0.99 || neg[1] > -0.49 || neg[1] < -0.51) throw new Error(`negative components corrupted: ${[...neg]}`);
235
+
236
+ if (unpackVec("!!!not base64!!!") !== null) throw new Error("malformed vector must return null, not throw");
237
+
238
+ // Sign bits: the link encoding. Idempotent (packing a sign vector again
239
+ // changes nothing), which is what lets tuned() walk the shared vector.
240
+ const bits = packBits(v);
241
+ if (bits.length > 48) throw new Error(`sign bits must stay link-sized, got ${bits.length} chars`);
242
+ const back = unpackBits(bits);
243
+ if (back.length !== v.length) throw new Error("sign round-trip changed the dimension");
244
+ for (let i = 0; i < v.length; i++) {
245
+ if (Math.sign(back[i]) !== (v[i] < 0 ? -1 : 1)) throw new Error(`sign flipped at ${i}`);
246
+ }
247
+ if (packBits(back) !== bits) throw new Error("sign packing must be idempotent");
248
+ if (unpackBits("!!!not base64!!!") !== null) throw new Error("malformed bits must return null, not throw");
249
+
250
+ console.log("embed.js self-check ok — matryoshka norm, int8 round-trip, negatives, malformed input, sign bits");
251
+ }
@@ -0,0 +1,26 @@
1
+ // The embedder, off the main thread.
2
+ //
3
+ // nomic-embed-text-v1.5 q8 is a 131MB ONNX download, and ONNX Runtime's WASM init
4
+ // and every inference after it are synchronous CPU. On the main thread that is a
5
+ // frozen tab for as long as it takes — which on a first visit is a minute or two
6
+ // of an app that looks broken rather than busy.
7
+ //
8
+ // Nothing here decides anything. It owns the pipeline and answers three messages,
9
+ // so that the one copy of MODEL, DIM and matryoshka stays in embed.js and the two
10
+ // sides cannot drift into embedding queries and documents differently — which is
11
+ // the failure that makes cosine meaningless while every test still passes.
12
+
13
+ import { embedQueryDirect, embedDocumentDirect, warmDirect } from "./embed.js";
14
+
15
+ const jobs = { query: embedQueryDirect, document: embedDocumentDirect };
16
+
17
+ self.onmessage = async ({ data: { id, kind, text } }) => {
18
+ try {
19
+ if (kind === "warm") { await warmDirect(); return self.postMessage({ id, vec: null }); }
20
+ self.postMessage({ id, vec: await jobs[kind](text) });
21
+ } catch (err) {
22
+ // The model failing to load must reject the caller, not kill the worker
23
+ // silently and leave a promise pending forever.
24
+ self.postMessage({ id, error: err?.message ?? String(err) });
25
+ }
26
+ };