@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,342 @@
1
+ // taste.md — the human half of AGENTS.md.
2
+ //
3
+ // A taste is 108 bytes of int8 in a space nobody can read. That is a fine thing
4
+ // to compute with and a terrible thing to own: you cannot check it, correct it,
5
+ // argue with it, or decide you have changed. `exportTaste` already carries the
6
+ // TITLES it was built from for exactly that reason, which helps and is not
7
+ // enough — a list of twelve films you already know you watched tells you nothing
8
+ // you did not know.
9
+ //
10
+ // So this file writes the vector down in words, against the vocabulary the
11
+ // publishers themselves declared, and the result is a document with the same
12
+ // job as AGENTS.md: something a person reads to see what is being assumed about
13
+ // them, edits to say otherwise, and hands to an agent as the brief.
14
+ //
15
+ // WHY THE PUBLISHERS' OWN VOCABULARY
16
+ // ----------------------------------
17
+ // The alternative is to embed a fixed list of adjectives and score those, which
18
+ // would describe the reader in the model's terms and be wrong in the specific
19
+ // way that matters: it would say the same things about a person on a catalogue
20
+ // of silent film and a catalogue of restaurants. Here a term exists only because
21
+ // some publisher declared it as a `tags` role and at least MIN_SUPPORT of their
22
+ // rows carry it, so the words available to describe you are the words the
23
+ // catalogue is actually organised by.
24
+ //
25
+ // WHY EVERY TERM IS CENTRED
26
+ // -------------------------
27
+ // A term's meaning is the mean of the rows carrying it, and the mean of 5,000
28
+ // rows tagged `drama` is very close to the mean of the whole catalogue — so raw
29
+ // cosine ranks the biggest tags first for everyone, and every reader is told
30
+ // they like drama. Subtracting the corpus mean from both sides asks the only
31
+ // question worth asking: of the ways this catalogue varies, which ones are you?
32
+ import { values } from "./roles.js";
33
+
34
+ /** Below this a term's "meaning" is a handful of rows, and on a small enough
35
+ * handful it is one row wearing a word — which is both noise and, on a corpus
36
+ * of reactions, a disclosure. Same floor as `reactions.js`. */
37
+ export const MIN_SUPPORT = 8;
38
+
39
+ const unit = (d) => {
40
+ let n = 0;
41
+ for (const x of d) n += x * x;
42
+ n = Math.sqrt(n);
43
+ if (n < 1e-9) return null;
44
+ for (let i = 0; i < d.length; i++) d[i] /= n;
45
+ return d;
46
+ };
47
+ const dot = (a, b) => { let s = 0; for (let i = 0; i < a.length && i < b.length; i++) s += a[i] * b[i]; return s; };
48
+
49
+ /**
50
+ * What this catalogue can say about a reader: every declared tag value with
51
+ * enough rows behind it, as a direction away from the catalogue's own centre.
52
+ */
53
+ export function vocabulary(rows = [], roles = {}, { minSupport = MIN_SUPPORT } = {}) {
54
+ const dim = rows.find((r) => r.vector)?.vector.length ?? 0;
55
+ if (!dim) return { mean: null, dim: 0, terms: [] };
56
+
57
+ const mean = new Float64Array(dim);
58
+ let n = 0;
59
+ for (const r of rows) {
60
+ if (!r.vector) continue;
61
+ n++;
62
+ for (let i = 0; i < dim; i++) mean[i] += r.vector[i];
63
+ }
64
+ for (let i = 0; i < dim; i++) mean[i] /= n;
65
+
66
+ const acc = new Map();
67
+ for (const r of rows) {
68
+ if (!r.vector) continue;
69
+ for (const f of roles.tags ?? []) {
70
+ for (const v of values(r[f])) {
71
+ const k = `${f} ${v}`;
72
+ let a = acc.get(k);
73
+ if (!a) acc.set(k, (a = { field: f, value: v, n: 0, sum: new Float64Array(dim) }));
74
+ a.n++;
75
+ for (let i = 0; i < dim; i++) a.sum[i] += r.vector[i];
76
+ }
77
+ }
78
+ }
79
+
80
+ const terms = [];
81
+ for (const a of acc.values()) {
82
+ if (a.n < minSupport) continue;
83
+ const d = new Float64Array(dim);
84
+ for (let i = 0; i < dim; i++) d[i] = a.sum[i] / a.n - mean[i];
85
+ if (unit(d)) terms.push({ field: a.field, value: a.value, n: a.n, vec: d });
86
+ }
87
+ return { mean, dim, terms };
88
+ }
89
+
90
+ /**
91
+ * The terms a direction is most like.
92
+ *
93
+ * `center` is false for a vector that is ALREADY a difference — the kernel's `v`
94
+ * is (recent picks minus older picks), a heading rather than a place, and taking
95
+ * the catalogue's centre off a heading is meaningless.
96
+ */
97
+ /** Two tags this close are one fact wearing two words. Not MMR: MMR reorders and
98
+ * still fills the list, so with nothing but aliases left to pick it prints all
99
+ * of them. An alias has to be DROPPED. 0.92 is well above anything two genuinely
100
+ * different tags reach and well below the ~0.99 of two names for one show. */
101
+ export const REDUNDANT = 0.92;
102
+
103
+ export function affinities(vocab, vec, { limit = 6, center = true, floor = 0.05, redundant = REDUNDANT } = {}) {
104
+ if (!vocab?.terms?.length || !vec) return [];
105
+ const d = new Float64Array(vocab.dim);
106
+ for (let i = 0; i < vocab.dim; i++) d[i] = vec[i] - (center ? vocab.mean[i] : 0);
107
+ if (!unit(d)) return [];
108
+ const ranked = vocab.terms
109
+ .map((t) => ({ t, score: dot(t.vec, d) }))
110
+ .filter((x) => x.score > floor)
111
+ .sort((a, b) => b.score - a.score);
112
+
113
+ // A cartoon called Eek carries `eek`, `eek the cat`, `fox` and its voice
114
+ // actor across one set of 59 rows, so the raw ranking opened with four
115
+ // bullets saying "you watched Eek the Cat". A description that repeats
116
+ // itself is not shorter than the vector it describes, and being shorter than
117
+ // the vector is the only reason this document exists.
118
+ const out = [];
119
+ for (const { t, score } of ranked) {
120
+ if (out.length >= limit) break;
121
+ if (out.some((k) => dot(k.vec, t.vec) > redundant)) continue;
122
+ out.push({ vec: t.vec, field: t.field, value: t.value, n: t.n, score });
123
+ }
124
+ return out.map(({ vec: _, ...rest }) => rest);
125
+ }
126
+
127
+ const pct = (x) => `${(x * 100).toFixed(0)}`;
128
+ const list = (xs) => xs.map((x) => `\`${x}\``).join(", ");
129
+
130
+ /**
131
+ * The document.
132
+ *
133
+ * `catalogues` are `[{ name, rows, roles }]` — whatever this reader happens to
134
+ * have open. The document is therefore about a reader AS SEEN BY the catalogues
135
+ * they have actually touched, which is the honest scope: nothing here claims to
136
+ * describe a person, only how their choices land against these shelves.
137
+ *
138
+ * `instructions` is the half a person WRITES. It is round-tripped untouched and
139
+ * placed above everything derived, because a document whose authored section is
140
+ * a footnote to the machine's section is not a brief, it is a report.
141
+ */
142
+ export function tasteDoc({ t = null, catalogues = [], instructions = "", now = new Date() } = {}) {
143
+ const day = now.toISOString().slice(0, 10);
144
+ const head = [
145
+ "# taste.md",
146
+ "",
147
+ "> Your preferences, as your own agent has them. Derived on this machine from the",
148
+ "> things you named; never sent anywhere. Everything below the line is rewritten",
149
+ "> each time you react — the *Standing instructions* are yours.",
150
+ "",
151
+ ];
152
+
153
+ const authored = [
154
+ "## Standing instructions",
155
+ "",
156
+ instructions.trim()
157
+ || "_Nothing yet. Write what an agent should do for you — everything below is an\nobservation, and this is the part where you get to answer back._",
158
+ "",
159
+ ];
160
+
161
+ if (!t) {
162
+ return [...head, ...authored,
163
+ "---", "",
164
+ "## Observed", "",
165
+ "Nothing observed yet: no likes or rejections have been recorded in this browser.",
166
+ "React to a few things and this document fills in.", ""].join("\n");
167
+ }
168
+
169
+ const vocabs = catalogues
170
+ .map((c) => ({ name: c.name, v: vocabulary(c.rows ?? [], c.roles ?? {}) }))
171
+ .filter((c) => c.v.terms.length);
172
+
173
+ const across = (vec, { limit = 6, center = true } = {}) => vocabs
174
+ .flatMap(({ name, v }) => affinities(v, vec, { center, limit: 4 }).map((a) => ({ ...a, corpus: name })))
175
+ .sort((a, b) => b.score - a.score)
176
+ .slice(0, limit);
177
+
178
+ const draw = across(t.q, { limit: 7 });
179
+ const pass = t.no ? across(t.no, { limit: 5 }) : [];
180
+ const toward = t.v ? across(t.v, { limit: 4, center: false }) : [];
181
+ const away = t.v ? across(Float64Array.from(t.v, (x) => -x), { limit: 4, center: false }) : [];
182
+
183
+ const bullets = (xs) => (xs.length
184
+ ? xs.map((a) => `- **${a.value}** — ${a.corpus} · ${a.field} · ${a.n.toLocaleString()} rows · ${pct(a.score)}`).join("\n")
185
+ : "_Nothing separated itself from the catalogue's average. Usually this means too few picks._");
186
+
187
+ const body = [
188
+ "---",
189
+ "",
190
+ "## What I am drawn to",
191
+ "",
192
+ bullets(draw),
193
+ "",
194
+ "> Read as: of the directions these catalogues vary in, these are the ones you are.",
195
+ "> The number is correlation against the catalogue's own centre, not a share of anything.",
196
+ "",
197
+ ];
198
+
199
+ if (pass.length) body.push("## What I pass on", "", bullets(pass), "");
200
+
201
+ if (t.v) {
202
+ body.push("## Where I am heading", "",
203
+ `Your last ${Math.ceil(t.n / 2)} picks differ from the ones before them, so the agent ranks`,
204
+ `${(t.drift * 100).toFixed(0)}% past where you are, in this direction:`, "",
205
+ toward.length ? `**toward** ${list(toward.map((a) => a.value))}` : "_no clear heading_",
206
+ ...(away.length ? ["", `**away from** ${list(away.map((a) => a.value))}`] : []), "");
207
+ } else {
208
+ // Deliberately not "N named, four needed": with the heading now carried
209
+ // across sessions, `n` and the absence of a direction stopped agreeing,
210
+ // and the document printed "7 named, four needed" at a reader who had
211
+ // named seven things. The honest statement is about the kernel, not the
212
+ // count.
213
+ body.push("## Where I am heading", "",
214
+ "No direction yet. The kernel infers one only once four picks give it an older half",
215
+ "and a newer half to compare, and it will not invent one from fewer — so",
216
+ "recommendations mirror what you named rather than leaning past it.", "");
217
+ }
218
+
219
+ body.push("## What I named", "",
220
+ `**liked** (${t.n}) — ${t.from.length ? list(t.from) : "_none_"}`, "",
221
+ `**passed** — ${t.rejected.length ? list(t.rejected) : "_none_"}`, "",
222
+ "Delete a line here and the sentence above it stops being true. That is what writing",
223
+ "it down is for: a taste you cannot read is a taste you cannot correct.", "",
224
+ "## For an agent reading this", "",
225
+ "- Rank candidates by cosine to `q`, minus 0.6 times the positive part of cosine to `no`.",
226
+ "- Every corpus on this network embeds with `nomic-embed-text-v1.5` truncated to 256",
227
+ " dimensions, so `q` is comparable against a catalogue this reader has never opened.",
228
+ "- Diversify the result (MMR, lambda 0.7). Six episodes of one series is not a recommendation.",
229
+ "- Do not re-recommend anything under *What I named*.",
230
+ "- Read the *Standing instructions* above as overriding anything derived below them.",
231
+ "",
232
+ `_Generated ${day} · ${t.n} likes, ${t.rejected.length} rejections · ${vocabs.length} catalogue`
233
+ + `${vocabs.length === 1 ? "" : "s"} consulted · nothing left this machine._`, "");
234
+
235
+ return [...head, ...authored, ...body].join("\n");
236
+ }
237
+
238
+ // ── self-check: `node consume/taste-doc.js` ─────────────────────────────────
239
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
240
+ const { taste } = await import("./taste.js");
241
+
242
+ // A catalogue with a KNOWN shape: two axes, and a tag at each end of each.
243
+ // Anything the document says had better follow from that and not from how
244
+ // many rows a tag happens to have.
245
+ const D = 8;
246
+ const vec = (a, b, jitter) => {
247
+ const v = new Float32Array(D);
248
+ v[0] = a; v[1] = b;
249
+ for (let i = 2; i < D; i++) v[i] = ((jitter * (i + 1) * 37) % 13) / 100;
250
+ return v;
251
+ };
252
+ const rows = [];
253
+ let id = 0;
254
+ const make = (tag, a, b, n) => { for (let i = 0; i < n; i++) rows.push({ id: `r${id++}`, genre: [tag], vector: vec(a, b, i) }); };
255
+ make("silent", 1, 0, 40);
256
+ make("newsreel", -1, 0, 40);
257
+ make("comedy", 0, 1, 40);
258
+ make("horror", 0, -1, 40);
259
+ make("obscure", 1, 1, 3); // …and one the floor must swallow whole
260
+
261
+ const roles = { tags: ["genre"] };
262
+ const vocab = vocabulary(rows, roles);
263
+ const names = vocab.terms.map((t) => t.value).sort();
264
+ if (names.join(",") !== "comedy,horror,newsreel,silent") throw new Error(`the support floor must drop a 3-row tag: ${names}`);
265
+
266
+ // A reader who likes silents. The document must say silent, must NOT say
267
+ // newsreel, and must not be dragged around by tag size — every surviving tag
268
+ // has exactly 40 rows, so any ranking that appears is about direction alone.
269
+ const likes = rows.filter((r) => r.genre[0] === "silent").slice(0, 6).map((r) => ({ id: r.id, title: r.id, vector: r.vector }));
270
+ const dislikes = rows.filter((r) => r.genre[0] === "horror").slice(0, 3).map((r) => ({ id: r.id, title: r.id, vector: r.vector }));
271
+ const t = taste(likes, dislikes);
272
+
273
+ const drawn = affinities(vocab, t.q);
274
+ if (drawn[0]?.value !== "silent") throw new Error(`the top affinity must be what was liked: ${JSON.stringify(drawn)}`);
275
+ if (drawn.some((a) => a.value === "newsreel")) throw new Error("the opposite of what you liked must not appear as an affinity");
276
+ const passed = affinities(vocab, t.no);
277
+ if (passed[0]?.value !== "horror") throw new Error(`rejections must be described from 'no', not 'q': ${JSON.stringify(passed)}`);
278
+
279
+ // Centring is the whole reason this is not a popularity list.
280
+ {
281
+ // A tag on EVERY row has the catalogue mean for a centroid, so its
282
+ // direction is the zero vector and it is not a term at all — which is
283
+ // the right answer: `film` on a corpus of films says nothing about
284
+ // anyone, and the raw-cosine version of this ranked it first for
285
+ // everybody.
286
+ const all = rows.map((r) => ({ ...r, genre: [...r.genre, "film"] }));
287
+ if (vocabulary(all, roles).terms.some((x) => x.value === "film")) {
288
+ throw new Error("a tag carried by the whole catalogue has no direction and must not survive as a term");
289
+ }
290
+ // And a tag on most of it survives, but must not outrank the one that
291
+ // actually points where the reader is.
292
+ const most = rows.map((r) => (r.genre[0] === "horror" ? r : { ...r, genre: [...r.genre, "feature"] }));
293
+ const v2 = vocabulary(most, roles);
294
+ const a2 = affinities(v2, t.q, { limit: 10 });
295
+ if (!v2.terms.some((x) => x.value === "feature")) throw new Error("a tag on three quarters of the rows is still a term");
296
+ if (a2[0]?.value !== "silent") throw new Error(`bulk must not outrank direction: ${JSON.stringify(a2.slice(0, 3))}`);
297
+ }
298
+
299
+ // Co-occurring tags are one fact wearing several words. On the real archive
300
+ // bundle the top four affinities were `fox`, `eek the cat`, `eek` and the
301
+ // voice actor — 59 identical rows, four bullets, one cartoon.
302
+ {
303
+ const dupes = rows.map((r) => (r.genre[0] === "silent" ? { ...r, genre: [...r.genre, "mute", "no-sound", "pre-talkie"] } : r));
304
+ const vd = vocabulary(dupes, roles);
305
+ if (vd.terms.length !== 7) throw new Error(`the aliases must all be terms before ranking: ${vd.terms.length}`);
306
+ const a = affinities(vd, t.q, { limit: 4 });
307
+ const said = a.filter((x) => ["silent", "mute", "no-sound", "pre-talkie"].includes(x.value)).length;
308
+ if (said > 1) throw new Error(`one fact must be said once: ${JSON.stringify(a.map((x) => x.value))}`);
309
+ if (!a.length) throw new Error("…and it must still be said");
310
+ }
311
+
312
+ const md = tasteDoc({ t, catalogues: [{ name: "films", rows, roles }], instructions: " No trailers. Ever. " });
313
+ if (!md.startsWith("# taste.md")) throw new Error("it is a document, and it says so first");
314
+ if (md.indexOf("No trailers. Ever.") > md.indexOf("What I am drawn to")) throw new Error("what the person wrote comes before what the machine inferred");
315
+ if (!md.includes("**silent**") || md.includes("**newsreel**")) throw new Error("the prose must follow the vectors");
316
+ if (!md.includes("nomic-embed-text-v1.5")) throw new Error("an agent reading this must be told what space q lives in");
317
+ for (const h of ["## Standing instructions", "## What I am drawn to", "## What I pass on", "## Where I am heading", "## What I named", "## For an agent reading this"]) {
318
+ if (!md.includes(h)) throw new Error(`missing section: ${h}`);
319
+ }
320
+
321
+ // No taste, and no catalogue: still a document, never a crash, never a
322
+ // fabricated observation.
323
+ const empty = tasteDoc({ t: null, instructions: "" });
324
+ if (!empty.includes("Nothing observed yet")) throw new Error("an empty taste must say so rather than inventing one");
325
+ if (empty.includes("drawn to")) throw new Error("…and must claim nothing");
326
+ if (!tasteDoc({ t, catalogues: [] }).includes("## What I am drawn to")) throw new Error("a taste with no catalogue open still renders its sections");
327
+
328
+ // Four picks is where the kernel starts inferring a heading; three is not.
329
+ const few = taste(likes.slice(0, 3), []);
330
+ {
331
+ const doc = tasteDoc({ t: few, catalogues: [{ name: "films", rows, roles }] });
332
+ if (!doc.includes("once four picks")) throw new Error("too few picks for a direction must be stated, not silently omitted");
333
+ // The document must never print a count next to a claim the count
334
+ // contradicts — a carried heading and a carried `n` are independent now.
335
+ if (/\d+ named, four needed/.test(doc)) throw new Error("a reader who named seven things must not be told four are needed");
336
+ }
337
+
338
+ console.log("taste-doc.js self-check ok — terms are the publishers' own declared tags above a support floor, "
339
+ + "centred so the catalogue's bulk describes nobody, affinities follow q and rejections follow no, co-occurring tags are said once, "
340
+ + "a heading is read off v and withheld when the kernel has none, the authored section outranks the derived one, "
341
+ + "and an empty taste writes a document rather than an invention");
342
+ }