@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,350 @@
1
+ // Taste as a portable object.
2
+ //
3
+ // Every corpus on this network is embedded by the same model into the same
4
+ // 256-d space. That is usually described as a compatibility requirement — the
5
+ // directory refuses to rank a corpus baked with a different model, because
6
+ // cosine between two models' vectors is noise. But it buys something nobody
7
+ // asked for and nothing else offers:
8
+ //
9
+ // a taste learned in ONE publisher's corpus applies directly to ANOTHER'S.
10
+ //
11
+ // Not by matching ids — there are none in common. Not by a shared schema —
12
+ // there isn't one. A film and a video game have no field in common, no
13
+ // publisher in common, and no agreement of any kind beyond the embedding. Point
14
+ // a vector built from four films you liked at a corpus of games, and it ranks
15
+ // them, because both live in the same space.
16
+ //
17
+ // Every recommender that exists keeps its embedding private, which is precisely
18
+ // what makes taste non-portable: your taste is an asset of whoever holds the
19
+ // model. Here the model is public and the vectors are in files you already
20
+ // downloaded, so the taste is a ~350-byte object you own and can carry.
21
+ //
22
+ // The state is the session kernel from sond3r's src/geometry/kernel.js, cut to
23
+ // what is portable:
24
+ //
25
+ // mu — recency-weighted mean of what you liked (where you are)
26
+ // v — recent mean minus older mean (where you're heading)
27
+ // q — mu nudged along v (the lookahead you rank with)
28
+ // no — mean of what you rejected (where you are NOT going)
29
+
30
+ import { packVec, unpackVec } from "./embed.js";
31
+ import { cosine, diversify, norm } from "./rank.js";
32
+
33
+ const dim = (vs) => vs.reduce((n, v) => Math.max(n, v.length), 0);
34
+
35
+ function weightedMean(vs, weights) {
36
+ const d = dim(vs);
37
+ if (!d) return null;
38
+ const out = new Float32Array(d);
39
+ let wsum = 0;
40
+ vs.forEach((v, i) => {
41
+ const w = weights[i];
42
+ wsum += w;
43
+ for (let j = 0; j < v.length; j++) out[j] += v[j] * w;
44
+ });
45
+ if (!wsum) return null;
46
+ for (let j = 0; j < d; j++) out[j] /= wsum;
47
+ return out;
48
+ }
49
+
50
+ const unit = (v) => { if (!v) return null; const n = norm(v); const o = new Float32Array(v.length); for (let i = 0; i < v.length; i++) o[i] = v[i] / n; return o; };
51
+
52
+ /** How fast older picks stop counting. 8 means the 8th-most-recent like carries
53
+ * half the weight of the newest — taste that never forgets is a taste you
54
+ * cannot steer, and the whole point of `v` is that it can move. */
55
+ export const HALF_LIFE = 8;
56
+
57
+ /**
58
+ * Build a taste from what someone liked and rejected, newest LAST.
59
+ *
60
+ * `likes` and `dislikes` are `{ id, title, vector }`. Vectors come straight off
61
+ * the rows — no re-embedding, so this costs nothing and works offline.
62
+ *
63
+ * `drift` is how far `q` leans past where you are toward where you are heading.
64
+ * 0 ranks what you already like (a mirror); high overshoots into things you
65
+ * have shown no sign of wanting. The default leans, and it is the knob the
66
+ * product means when it says you own the algorithm.
67
+ */
68
+ export function taste(likes = [], dislikes = [], { drift = 0.35 } = {}) {
69
+ const seen = likes.filter((l) => l?.vector?.length);
70
+ if (!seen.length) return null;
71
+ const w = seen.map((_, i) => Math.pow(0.5, (seen.length - 1 - i) / HALF_LIFE));
72
+ const mu = unit(weightedMean(seen.map((l) => l.vector), w));
73
+
74
+ // Where it is heading: the recent half minus the older half. With too few
75
+ // picks there is no "older half" and a direction would be invented from
76
+ // nothing, so it stays null and `q` is just `mu`.
77
+ let v = null;
78
+ if (seen.length >= 4) {
79
+ const cut = Math.floor(seen.length / 2);
80
+ const recent = weightedMean(seen.slice(cut).map((l) => l.vector), w.slice(cut));
81
+ const older = weightedMean(seen.slice(0, cut).map((l) => l.vector), w.slice(0, cut));
82
+ if (recent && older) {
83
+ const d = new Float32Array(recent.length);
84
+ for (let i = 0; i < d.length; i++) d[i] = recent[i] - older[i];
85
+ if (norm(d) > 1e-6) v = unit(d);
86
+ }
87
+ }
88
+
89
+ const q = new Float32Array(mu.length);
90
+ for (let i = 0; i < q.length; i++) q[i] = mu[i] + (v ? drift * v[i] : 0);
91
+
92
+ const neg = dislikes.filter((d) => d?.vector?.length);
93
+ const no = neg.length ? unit(weightedMean(neg.map((d) => d.vector), neg.map(() => 1))) : null;
94
+
95
+ return {
96
+ mu, v, q: unit(q), no, drift,
97
+ from: seen.map((l) => l.title ?? l.id).slice(-12),
98
+ rejected: neg.map((d) => d.title ?? d.id).slice(-12),
99
+ n: seen.length,
100
+ };
101
+ }
102
+
103
+ /** How hard a rejection pushes its neighbours away. 0.6, taken from sond3r's
104
+ * session kernel rather than picked here: a dislike is the only thing a person
105
+ * ever says explicitly, and it has to visibly beat a direction the kernel
106
+ * merely inferred, or pressing the button feels like it did nothing. Two
107
+ * different values across two surfaces would make the same rejection mean two
108
+ * different things. */
109
+ export const GAMMA = 0.6;
110
+
111
+ /**
112
+ * Score a row against a taste. Attraction to `q`, minus repulsion from what was
113
+ * rejected.
114
+ *
115
+ * The subtraction matters more than it looks: without it, "not that" is
116
+ * unexpressible and a taste can only ever be refined toward things it already
117
+ * matches. Clamped at zero, so a row on the far side of the space from a
118
+ * rejection gets no bonus for it — being unlike something you disliked is not
119
+ * evidence of anything.
120
+ */
121
+ export const score = (row, t) => {
122
+ if (!row?.vector || !t) return null;
123
+ const r = { vector: row.vector, norm: row.norm ?? norm(row.vector) };
124
+ const pos = cosine(r, t.q, norm(t.q));
125
+ if (!t.no) return pos;
126
+ return pos - GAMMA * Math.max(0, cosine(r, t.no, norm(t.no)));
127
+ };
128
+
129
+ /** The best `limit` rows for a taste. `exclude` drops rows the taste was built
130
+ * from — recommending someone the thing they just told you they liked is the
131
+ * oldest failure in the genre. */
132
+ export function recommend(rows, t, { limit = 10, exclude = new Set(), lambda = 0.7 } = {}) {
133
+ if (!t) return [];
134
+ const ranked = rows
135
+ .filter((r) => r.vector && !exclude.has(r.id))
136
+ .map((r) => ({ row: r, s: score(r, t) }))
137
+ .sort((a, b) => b.s - a.s);
138
+ // Not `.slice(limit)`. Taken by score alone this returned five episodes of
139
+ // one series out of six — near-duplicates are adjacent in the space by
140
+ // construction, so the top of any list is the same thing said repeatedly.
141
+ // `lambda: 1` restores the pure ranking for a caller that wants it.
142
+ return diversify(ranked, { limit, lambda })
143
+ .map(({ row, s }) => ({ row, score: Number(s.toFixed(4)) }));
144
+ }
145
+
146
+ /**
147
+ * A taste, small enough to paste.
148
+ *
149
+ * Vectors as base64 int8 — the same encoding the shards use — so the whole
150
+ * object is a few hundred bytes and survives a chat message, a file, a URL. It
151
+ * carries the TITLES it was built from as well, because a taste you cannot read
152
+ * is a taste you cannot correct, and this is a thing a person is meant to own
153
+ * rather than a profile held about them.
154
+ */
155
+ export const exportTaste = (t) => t && ({
156
+ v: 1, model: "nomic-256", drift: t.drift, n: t.n,
157
+ q: packVec(Array.from(t.q)),
158
+ mu: packVec(Array.from(t.mu)),
159
+ // The heading, not just the position. `q` is `mu + drift·v`, so an export
160
+ // that dropped `v` produced an object whose ranking still leaned on a
161
+ // direction it then denied having — the next session was told "not enough
162
+ // picks to infer a direction" while ranking by one. It is one more packed
163
+ // vector; a taste that cannot say which way it is going is not worth the
164
+ // saving.
165
+ ...(t.v ? { d: packVec(Array.from(t.v)) } : {}),
166
+ ...(t.no ? { no: packVec(Array.from(t.no)) } : {}),
167
+ from: t.from, rejected: t.rejected,
168
+ });
169
+
170
+ export function importTaste(obj) {
171
+ if (!obj?.q) return null;
172
+ const q = unpackVec(obj.q), mu = unpackVec(obj.mu ?? obj.q);
173
+ if (!q) return null;
174
+ return { q, mu: mu ?? q, v: obj.d ? unpackVec(obj.d) : null, no: obj.no ? unpackVec(obj.no) : null,
175
+ drift: obj.drift ?? 0.35, from: obj.from ?? [], rejected: obj.rejected ?? [], n: obj.n ?? 0 };
176
+ }
177
+
178
+ /** The id a restored session is folded into. A previous visit comes back as ONE
179
+ * like carrying the old `q`, which is what lets the half-life work without
180
+ * anyone deciding when to forget — but a collapse is not a deletion, and the
181
+ * titles are the only part of a taste a person can actually check. */
182
+ export const REMEMBERED = "(remembered";
183
+
184
+ /**
185
+ * A taste that still knows what it was built from, across that collapse.
186
+ *
187
+ * Without this, `taste(likes)` recomputes `from` off the surviving array, so a
188
+ * restored session reports `n: 1` and one synthetic title — and re-exporting
189
+ * that writes the loss to disk. Every reload then forgets a little more, which
190
+ * is the exact failure a document about your own preferences cannot have.
191
+ *
192
+ * `carried` is what the last export said about itself; `likes`/`dislikes` are
193
+ * the entries named in THIS session. The vector is untouched — this restores
194
+ * only the provenance the collapse dropped.
195
+ */
196
+ export function withProvenance(t, { carried = null, likes = [], dislikes = [] } = {}) {
197
+ if (!t) return t;
198
+ const c = carried ?? { from: [], rejected: [], n: 0 };
199
+ const own = (xs) => xs.filter((x) => !String(x?.id ?? "").startsWith(REMEMBERED)).map((x) => x.title ?? x.id);
200
+ const mine = own(likes);
201
+ return {
202
+ ...t,
203
+ // The restored session is one entry, so the kernel cannot compute a
204
+ // direction from it and returns null. The direction it HAD is on the
205
+ // carried object and is still the one baked into the carried `q`, so it
206
+ // stands until this session has enough picks to compute its own.
207
+ v: t.v ?? c.v ?? null,
208
+ from: [...(c.from ?? []), ...mine].slice(-12),
209
+ rejected: [...(c.rejected ?? []), ...own(dislikes)].slice(-12),
210
+ n: (c.n ?? 0) + mine.length,
211
+ };
212
+ }
213
+
214
+ // ── self-check: `node consume/taste.js` ─────────────────────────────────────
215
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
216
+
217
+ // recommend() returns {row, score} — NOT {row, s}. kingsfoil's agent read
218
+ // h.s and reported every recommendation with score: undefined.
219
+ {
220
+ const rows = [{ id: "a", vector: [1, 0], norm: 1 }, { id: "b", vector: [0, 1], norm: 1 }];
221
+ const out = recommend(rows, { q: [1, 0] });
222
+ if (!out.length) throw new Error("recommend must rank rows with vectors");
223
+ if (out[0].row === undefined) throw new Error("recommend rows carry .row");
224
+ if (typeof out[0].score !== "number") throw new Error("recommend rows carry .score (not .s)");
225
+ }
226
+ const R = (id, vector, title = id) => ({ id, title, vector: Float32Array.from(vector), norm: norm(vector) });
227
+ // A toy 3-d space: x = "bleak", y = "comic", z = "documentary".
228
+ const bleak = [1, 0, 0], comic = [0, 1, 0], doc = [0, 0, 1];
229
+ const mix = (a, b, f) => a.map((x, i) => x * (1 - f) + b[i] * f);
230
+
231
+ // Taste from three bleak things.
232
+ let t = taste([R("a", bleak), R("b", mix(bleak, doc, 0.2)), R("c", mix(bleak, doc, 0.1))]);
233
+ if (!t) throw new Error("three likes must make a taste");
234
+ if (t.v !== null) throw new Error("under four picks there is no older half — direction must not be invented");
235
+ if (cosine({ vector: t.q, norm: norm(t.q) }, Float32Array.from(bleak), 1) < 0.9) throw new Error("q must sit near what was liked");
236
+
237
+ // Ranking a DIFFERENT pool with it — the whole point.
238
+ const pool = [R("x", comic, "a comedy"), R("y", mix(bleak, doc, 0.15), "a bleak documentary"), R("z", doc, "a documentary")];
239
+ if (recommend(pool, t)[0].row.title !== "a bleak documentary") throw new Error("taste must rank an unseen pool");
240
+
241
+ // Drift: four picks moving from bleak toward comic must lean the lookahead
242
+ // PAST where they are, or `v` is decoration.
243
+ const moving = [R("1", bleak), R("2", mix(bleak, comic, 0.2)), R("3", mix(bleak, comic, 0.4)), R("4", mix(bleak, comic, 0.6))];
244
+ t = taste(moving);
245
+ if (t.v === null) throw new Error("four picks must yield a direction");
246
+ const cq = cosine({ vector: t.q, norm: norm(t.q) }, Float32Array.from(comic), 1);
247
+ const cm = cosine({ vector: t.mu, norm: norm(t.mu) }, Float32Array.from(comic), 1);
248
+ if (!(cq > cm)) throw new Error(`q must lean further toward where it is heading than mu: ${cq} vs ${cm}`);
249
+ // drift 0 is a mirror, not a lookahead.
250
+ const still = taste(moving, [], { drift: 0 });
251
+ if (cosine({ vector: still.q, norm: norm(still.q) }, still.mu, norm(still.mu)) < 0.999) throw new Error("drift 0 must be mu");
252
+
253
+ // Rejection has to FLIP the answer, or "not that" is unexpressible. The
254
+ // taste leans comic, so comic wins until it is rejected. (A taste sitting
255
+ // exactly between the two options ties, and a tie proves nothing — which is
256
+ // what the first version of this test accidentally measured.)
257
+ const liked = [R("p", mix(bleak, comic, 0.7))];
258
+ const opts = [R("m", bleak, "bleak"), R("n", comic, "comic")];
259
+ if (recommend(opts, taste(liked))[0].row.title !== "comic") throw new Error("sanity: the taste leans comic");
260
+ if (recommend(opts, taste(liked, [R("q", comic)]))[0].row.title !== "bleak") throw new Error("rejecting comic must flip the ranking to bleak");
261
+
262
+ // Never recommend back what it was built from.
263
+ const seenRow = R("a", bleak, "the one they liked");
264
+ const rec = recommend([seenRow, R("other", mix(bleak, doc, 0.1), "similar")], taste([seenRow]), { exclude: new Set(["a"]) });
265
+ if (rec.some((r) => r.row.id === "a")) throw new Error("must not recommend what the taste was built from");
266
+
267
+ // Portability: export → import → same ranking. This is the object a person
268
+ // carries between two publishers who share nothing but a model.
269
+ t = taste(moving);
270
+ const wire = exportTaste(t);
271
+ const size = JSON.stringify(wire).length;
272
+ const back = importTaste(wire);
273
+ if (!back) throw new Error("a taste must survive the round trip");
274
+ const A = recommend(pool, t).map((r) => r.row.id).join();
275
+ const B = recommend(pool, back).map((r) => r.row.id).join();
276
+ if (A !== B) throw new Error(`ranking changed across the wire: ${A} vs ${B}`);
277
+ if (!wire.from.length) throw new Error("a taste must say what it was built from, or it cannot be corrected");
278
+ if (!t.v) throw new Error("this fixture is meant to have a heading");
279
+ if (!back.v) throw new Error("a heading must survive the wire — q leans on it, so an export without it lies");
280
+ if (cosine({ vector: back.v, norm: norm(back.v) }, t.v, norm(t.v)) < 0.98) throw new Error("…and must survive it intact");
281
+ if (importTaste({}) !== null || importTaste(null) !== null) throw new Error("a malformed taste must be null, not a crash");
282
+
283
+ if (taste([]) !== null) throw new Error("no likes is no taste, not an empty one");
284
+ if (recommend(pool, null).length) throw new Error("no taste recommends nothing");
285
+ if (score({ vector: null }, t) !== null) throw new Error("a vectorless row scores null, not NaN");
286
+
287
+ // A pool that is mostly one thing must not come back as one thing.
288
+ //
289
+ // Four near-identical rows clustered at 0°, one different row at 60°, and a
290
+ // taste pointing at 30° so the cluster and the outlier score the SAME. By
291
+ // relevance alone the cluster wins all three slots, which is the North of 60
292
+ // failure exactly; MMR has to spend one of them on the outlier.
293
+ {
294
+ const at = (id, deg) => {
295
+ const a = (deg * Math.PI) / 180;
296
+ return { id, vector: [Math.cos(a), Math.sin(a)], norm: 1 };
297
+ };
298
+ const pool = [at("run1", 0), at("run2", 0.8), at("run3", 1.6), at("run4", 2.4), at("other", 60)];
299
+ const tt = taste([{ id: "seed", ...at("seed", 30) }]);
300
+ const pure = recommend(pool, tt, { limit: 3, lambda: 1 }).map((h) => h.row.id);
301
+ if (pure.some((i) => i === "other")) throw new Error(`relevance alone must fill up with the run: ${pure}`);
302
+ const mixed = recommend(pool, tt, { limit: 3 }).map((h) => h.row.id);
303
+ if (!mixed.includes("other")) throw new Error(`MMR must spend a slot on something else: ${mixed}`);
304
+ if (mixed[0] !== pure[0]) throw new Error(`the best match must still come first: ${mixed[0]} vs ${pure[0]}`);
305
+ }
306
+
307
+ // Provenance across the restore collapse. A session that comes back as one
308
+ // synthetic like must not report itself as one pick, and re-exporting must
309
+ // not write that loss to disk — a taste that forgets a little on every
310
+ // reload is one nobody can check.
311
+ {
312
+ const first = taste([R("a", bleak, "The Golem"), R("b", comic, "Nosferatu"), R("c", doc, "Metropolis")], [R("z", doc, "Wrestling")]);
313
+ const saved = exportTaste(first);
314
+ if (saved.n !== 3 || saved.from.length !== 3) throw new Error("a fresh export carries what it was built from");
315
+
316
+ // …the next visit, exactly as the page restores it.
317
+ const back = importTaste(saved);
318
+ const likes = [{ id: "(remembered)", title: `what you liked before · ${back.n} picks`, vector: back.q }];
319
+ const dislikes = back.no ? [{ id: "(remembered-no)", title: "what you passed on before", vector: back.no }] : [];
320
+ if (exportTaste(taste(likes, dislikes)).n !== 1) throw new Error("this is the loss the next assertion is about");
321
+
322
+ const kept = exportTaste(withProvenance(taste(likes, dislikes), { carried: back, likes, dislikes }));
323
+ if (kept.n !== 3) throw new Error(`a restored taste must not report itself as one pick: ${kept.n}`);
324
+ if (!kept.from.includes("Nosferatu")) throw new Error("…and must still name what it was built from");
325
+ if (kept.from.some((f) => f.startsWith("(remembered"))) throw new Error("the synthetic placeholder is not a title");
326
+ if (!kept.rejected.includes("Wrestling")) throw new Error("rejections are provenance too");
327
+
328
+ // …and a NEW pick this session lands beside the old ones, once.
329
+ const more = [...likes, R("d", comic, "Sunrise")];
330
+ const grown = exportTaste(withProvenance(taste(more, dislikes), { carried: back, likes: more, dislikes }));
331
+ if (grown.n !== 4) throw new Error(`a new pick must count once: ${grown.n}`);
332
+ if (grown.from.filter((f) => f === "Nosferatu").length !== 1) throw new Error("…and must not duplicate the carried ones");
333
+
334
+ // A heading outlives the collapse too, and gives way to a fresh one.
335
+ {
336
+ const m4 = taste([R("p", bleak, "1"), R("q", mix(bleak, comic, .3), "2"), R("r", mix(bleak, comic, .6), "3"), R("s", comic, "4")]);
337
+ const carried4 = importTaste(exportTaste(m4));
338
+ const one = [{ id: "(remembered)", title: "before", vector: carried4.q }];
339
+ const held = withProvenance(taste(one), { carried: carried4, likes: one });
340
+ if (!held.v) throw new Error("a restored session must keep the heading its own q was built with");
341
+ if (taste(one).v) throw new Error("…which the kernel alone cannot supply from one entry");
342
+ }
343
+
344
+ // Idempotent: reload twice, nothing drifts.
345
+ const twice = exportTaste(withProvenance(taste(likes, dislikes), { carried: importTaste(kept), likes, dislikes }));
346
+ if (twice.n !== 3 || twice.from.join() !== kept.from.join()) throw new Error("a second reload must change nothing");
347
+ }
348
+
349
+ console.log(`taste.js self-check ok — mu/v/q, drift leans forward, rejection steers, portable in ${size}B, ranks a pool it never saw, MMR keeps a run of near-duplicates from filling the list, provenance and heading survive the restore collapse and do not drift on reload`);
350
+ }