@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,358 @@
1
+ // Five verbs over a corpus of rows. Nothing here knows what the rows ARE.
2
+ //
3
+ // That is the whole demonstration. sond3r's WebMCP surface has nineteen tools
4
+ // and most of them are television — capture a frame, program a channel, cut a
5
+ // montage. These five are what is left when you take the app out: describe the
6
+ // corpus, search it, pull one record, count a field, find neighbours. Point them
7
+ // at archival film and they answer questions about archival film; point them at
8
+ // an OSM export or a decade of notes and they answer about that, because a row
9
+ // is `{ id, ...fields, text, vector, norm }` and nothing below reads a field by
10
+ // name that the caller did not name.
11
+ //
12
+ // Pure functions, so `node tools.js` checks them with no browser and no network.
13
+
14
+ import { bestPassage, cosine, lexScore, norm, zFloor } from "./rank.js";
15
+ import { briefOf, collections, linkOf, titleOf, typeOf, values } from "./roles.js";
16
+
17
+ /** Fields that are plumbing rather than content — never worth faceting or
18
+ * showing as "what this corpus holds". */
19
+ const PLUMBING = new Set(["text", "vector", "norm", "id", "owner", "embed"]);
20
+
21
+ /** A cheap, stable preview of a row for a tool result. The full record is one
22
+ * `get-row` away, and an agent that gets 50 full rows back has spent its whole
23
+ * context on one call.
24
+ *
25
+ * It takes `roles` — the publisher's own declaration of which field is the
26
+ * title — rather than a list of field names. Hardcoding `name`/`path`/`desc`
27
+ * worked on the archive bundle and returned `{id, undefined, undefined}` on the
28
+ * first other dataset it saw. `fields` adds to that, it does not replace it. */
29
+ export const brief = (r, roles, fields = []) => {
30
+ const out = briefOf(r, roles, fields);
31
+ const link = linkOf(r, roles);
32
+ return link ? { ...out, url: link } : out;
33
+ };
34
+
35
+ /**
36
+ * What is in here at all — the call an agent makes first, before it knows the
37
+ * shape of anything.
38
+ *
39
+ * Field coverage rather than a schema: these rows come off a publisher's own
40
+ * bundle and nobody promised they are uniform. "`series` on 61% of rows" is the
41
+ * fact an agent needs to decide whether faceting on it means anything, and a
42
+ * declared schema would have said 100% and been wrong.
43
+ */
44
+ export function describe(rows) {
45
+ const counts = new Map();
46
+ let vectors = 0, dim = 0;
47
+ for (const r of rows) {
48
+ if (r.vector) { vectors++; dim ||= r.vector.length; }
49
+ for (const k of Object.keys(r)) {
50
+ if (PLUMBING.has(k)) continue;
51
+ if (r[k] == null || r[k] === "") continue;
52
+ counts.set(k, (counts.get(k) ?? 0) + 1);
53
+ }
54
+ }
55
+ return {
56
+ rows: rows.length,
57
+ // Search is only semantic for rows that carry one; the rest fall back to
58
+ // lexical. An agent that gets nonsense from a query deserves to know which
59
+ // half of the corpus it was searching.
60
+ withVectors: vectors, vectorDim: dim,
61
+ owners: [...new Set(rows.map((r) => r.owner).filter(Boolean))],
62
+ fields: [...counts].sort((a, b) => b[1] - a[1])
63
+ .map(([name, n]) => ({ name, on: n, pct: Math.round((100 * n) / (rows.length || 1)) })),
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Rank rows against a query. Semantic where the row has a vector, lexical where
69
+ * it doesn't, and the mode is reported per hit rather than hidden — degrading
70
+ * silently is how "search stopped working" turns into an afternoon.
71
+ *
72
+ * `qv` is passed in, not computed: embedding is async and model-shaped, and
73
+ * keeping it out here is what lets this file be a pure-function self-check.
74
+ */
75
+ export function search(rows, query, roles, { qv = null, limit = 10, fields, where } = {}) {
76
+ const ql = query.trim().toLowerCase();
77
+ if (!ql) return [];
78
+ const qn = qv ? norm(qv) : 1;
79
+ // Filtered BEFORE scoring, so the z-floor is measured over the rows that
80
+ // could actually be returned. "sad songs before 1970" floored against the
81
+ // whole corpus admits whatever the corpus is mostly about; floored against
82
+ // pre-1970 rows it separates the sad ones from the rest of that shelf.
83
+ // This is the difference between a search that composes and one that only
84
+ // ever answers the question the way it was asked.
85
+ if (where) rows = rows.filter((r) => matches(r, where));
86
+ const scored = rows.map((r) => (qv && r.vector
87
+ ? { r, score: cosine(r, qv, qn), mode: "semantic" }
88
+ : { r, score: lexScore(r, ql), mode: "lexical" }));
89
+ // The floor is per QUERY over the whole corpus, so it is measured before
90
+ // anything is dropped. Raw cosine is offset per query, not per corpus: on this
91
+ // catalog every row scores 0.38–0.65 against anything, and `score > 0` admits
92
+ // all 42,215 of them in confident-looking order.
93
+ const sem = scored.filter((x) => x.mode === "semantic").map((x) => x.score);
94
+ // The best semantic row always survives its own floor. On a big corpus the
95
+ // floor never touches it; on a small one it does — three rows where one is an
96
+ // exact match put the mean so high that the floor lands ABOVE the match, and
97
+ // the corpus answers a question it can answer with nothing at all.
98
+ const floor = Math.min(zFloor(sem), sem.length ? Math.max(...sem) : Infinity);
99
+ return scored
100
+ .filter((x) => (x.mode === "semantic" ? x.score >= floor : x.score > 0))
101
+ .sort((a, b) => b.score - a.score)
102
+ .slice(0, limit)
103
+ .map(({ r, score, mode }) => ({ ...brief(r, roles, fields), score: Number(score.toFixed(4)), mode }));
104
+ }
105
+
106
+ /**
107
+ * Does a row match every `{field: value}` in `where`?
108
+ *
109
+ * Case-insensitive on purpose. An agent that reads `top: ["comedy"]` off a facet
110
+ * and passes "Comedy" back gets an empty result and no idea why — and one bundle
111
+ * here mixes cases across fields (`genre` lowercase, `subject` title case).
112
+ * Matching is still exact on the WHOLE value, so "comedy" never matches "dark
113
+ * comedy": near-misses are the trap, not near-matches.
114
+ *
115
+ * Shared by facet and browse deliberately. Two copies of a filter that drifted
116
+ * would make "count the Events" and "list the Events" disagree, which is the
117
+ * kind of bug nobody reports because both answers look plausible.
118
+ */
119
+ export const matches = (row, where) => !where || Object.entries(where).every(([k, v]) =>
120
+ values(row[k] ?? "").some((x) => x.toLowerCase() === String(v).toLowerCase()));
121
+
122
+ /**
123
+ * Count a field's values. The verb that makes a corpus legible without reading
124
+ * it — "how many events per season", "which decades", "who published what".
125
+ *
126
+ * Multi-valued fields are split by `values()` in roles.js, which is also what
127
+ * shapes a preview's tags: one definition, so "count the Summer events" and
128
+ * "list the Summer events" cannot disagree about what a Summer event is.
129
+ */
130
+ export function facet(rows, field, { limit = 20, where } = {}) {
131
+ const counts = new Map();
132
+ let missing = 0;
133
+ for (const r of rows) {
134
+ if (!matches(r, where)) continue;
135
+ const raw = r[field];
136
+ if (raw == null || raw === "") { missing++; continue; }
137
+ for (const v of values(raw)) counts.set(v, (counts.get(v) ?? 0) + 1);
138
+ }
139
+ const sorted = [...counts].sort((a, b) => b[1] - a[1]);
140
+ return {
141
+ field, distinct: counts.size, missing,
142
+ top: sorted.slice(0, limit).map(([value, count]) => ({ value, count })),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * List a collection, with no query at all.
148
+ *
149
+ * The most basic thing anyone does with a storefront — "show me what's here" —
150
+ * and the only one the other four verbs cannot do: `search` needs a query,
151
+ * `similar-rows` needs a seed id you can only get from a search, and `facet`
152
+ * returns counts rather than rows. Without this an agent's first move on an
153
+ * unfamiliar corpus has to be a guess.
154
+ *
155
+ * `sort` names a field; rows that have it come first, ordered numerically where
156
+ * both values parse as numbers and lexically otherwise, because a bundle's
157
+ * `rating` is the string "4.7" and sorting that as text puts "10" below "9".
158
+ */
159
+ export function browse(rows, roles, { type, where, sort, desc = true, limit = 20, offset = 0, fields } = {}) {
160
+ let hits = rows.filter((r) => (!type || typeOf(r)?.toLowerCase() === String(type).toLowerCase()) && matches(r, where));
161
+ const total = hits.length;
162
+ if (sort) {
163
+ const num = (v) => (v == null || v === "" ? null : Number(v));
164
+ hits = [...hits].sort((a, b) => {
165
+ const [x, y] = [a[sort], b[sort]];
166
+ if ((x == null || x === "") !== (y == null || y === "")) return x == null || x === "" ? 1 : -1;
167
+ const [nx, ny] = [num(x), num(y)];
168
+ const c = Number.isFinite(nx) && Number.isFinite(ny) ? nx - ny : String(x ?? "").localeCompare(String(y ?? ""));
169
+ return desc ? -c : c;
170
+ });
171
+ }
172
+ return {
173
+ total, offset, returned: Math.min(limit, Math.max(0, total - offset)),
174
+ collections: type ? undefined : collections(roles, rows).map((c) => ({ type: c.type, plural: c.plural, count: c.count })),
175
+ rows: hits.slice(offset, offset + limit).map((r) => brief(r, roles, fields)),
176
+ };
177
+ }
178
+
179
+ /** Rows nearest a given row, by vector. "More like this", with no query to
180
+ * write — which is how an agent explores a corpus whose vocabulary it doesn't
181
+ * know yet. Returns nothing (not an error) for a row with no vector. */
182
+ export function neighbors(rows, id, roles, { limit = 10, fields } = {}) {
183
+ const seed = rows.find((r) => r.id === id);
184
+ if (!seed?.vector) return { seed: seed ? brief(seed, roles, fields) : null, near: [] };
185
+ const qn = seed.norm;
186
+ return {
187
+ seed: brief(seed, roles, fields),
188
+ near: rows
189
+ .filter((r) => r.id !== id && r.vector)
190
+ .map((r) => ({ r, score: cosine(r, seed.vector, qn) }))
191
+ .sort((a, b) => b.score - a.score)
192
+ .slice(0, limit)
193
+ .map(({ r, score }) => ({ ...brief(r, roles, fields), score: Number(score.toFixed(4)) })),
194
+ };
195
+ }
196
+
197
+ /** One row, whole. Deliberately the only verb that returns everything — the
198
+ * others preview, so an agent chooses when to spend its context. */
199
+ export function getRow(rows, id, roles) {
200
+ // id first, then whatever the publisher calls a title — an agent that read a
201
+ // title off a search result and passed it back must land on the same row.
202
+ if (!id) return null; // else `x.path === undefined` matches the first row lacking one
203
+ const r = rows.find((x) => x.id === id)
204
+ ?? rows.find((x) => x.path === id || x.name === id || titleOf(x, roles) === id);
205
+ if (!r) return null;
206
+ const { vector, norm: _n, ...rest } = r;
207
+ return { ...rest, hasVector: !!vector, vectorDim: vector?.length ?? 0 };
208
+ }
209
+
210
+ // ── self-check: `node tools.js` — no browser, no network ────────────────────
211
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
212
+ const { rolesFrom } = await import("./roles.js");
213
+ const row = (id, fields, vector) => ({
214
+ id, owner: "0xa", ...fields,
215
+ text: [fields.name, fields.desc].filter(Boolean).join(" "),
216
+ vector: vector ?? null, norm: vector ? norm(vector) : 1,
217
+ });
218
+ const rows = [
219
+ row("1", { name: "Duet S01E02", series: "Duet", year: "1987", subject: "['Sitcom', 'Comedy']", desc: "a forgotten fox sitcom" }, [1, 0, 0]),
220
+ row("2", { name: "Duet S01E03", series: "Duet", year: "1987", subject: "['Sitcom']", desc: "more of the same sitcom" }, [0.99, 0.1, 0]),
221
+ row("3", { name: "Atomic Cafe", series: "", year: "1982", subject: "['Documentary', 'Cold War']", desc: "nuclear test footage" }, [0, 1, 0]),
222
+ row("4", { name: "untitled reel", year: "1954" }), // no vector, no desc — the ragged half of a real bundle
223
+ ];
224
+
225
+ // The sniffed roles for this fixture: no manifest, so title falls to `name`
226
+ // and prose to `desc` — the same path the archive bundle takes.
227
+ const roles = rolesFrom([], rows);
228
+ if (roles.title[0] !== "name") throw new Error(`fixture roles wrong: ${roles.title}`);
229
+
230
+ // describe: coverage, not a schema. `series` is empty on two rows and absent
231
+ // on one, and saying 100% would be a lie an agent would act on.
232
+ const d = describe(rows);
233
+ if (d.rows !== 4 || d.withVectors !== 3 || d.vectorDim !== 3) throw new Error("describe lost the corpus shape");
234
+ const series = d.fields.find((f) => f.name === "series");
235
+ if (series.on !== 2 || series.pct !== 50) throw new Error(`empty strings must not count as coverage: ${JSON.stringify(series)}`);
236
+ if (d.fields.some((f) => PLUMBING.has(f.name))) throw new Error("plumbing fields must not be offered as content");
237
+ if (d.owners.join() !== "0xa") throw new Error("owners lost");
238
+
239
+ // search: semantic where there is a vector, and the z-floor keeps the corpus
240
+ // from answering every query with all of itself.
241
+ let hits = search(rows, "sitcom", roles, { qv: [1, 0, 0], limit: 10 });
242
+ if (hits[0].id !== "1" || hits[0].title !== "Duet S01E02") throw new Error(`semantic: wrong top hit ${hits[0]?.id}`);
243
+ if (hits[0].mode !== "semantic") throw new Error("mode must be reported, not inferred");
244
+ if (hits.some((h) => h.id === "3")) throw new Error(`the z-floor must drop the unrelated row: ${JSON.stringify(hits)}`);
245
+ // A small corpus where one row matches exactly: the mean sits so high that a
246
+ // raw z-floor lands above the match and search answers nothing. The best row
247
+ // survives its own floor.
248
+ const tiny = [rows[0], rows[1], rows[2]];
249
+ if (!search(tiny, "sitcom", roles, { qv: [1, 0, 0] }).length) throw new Error("an exact match must never be filtered out by its own floor");
250
+ // …and lexical when no query vector could be made (the embedder failed).
251
+ hits = search(rows, "nuclear", roles, { limit: 10 });
252
+ if (hits[0]?.id !== "3" || hits[0].mode !== "lexical") throw new Error("must degrade to lexical, and say so");
253
+ if (search(rows, " ", roles).length) throw new Error("an empty query must return nothing, not everything");
254
+ // A row with no vector is still findable — it is half a real bundle.
255
+ if (search(rows, "untitled reel", roles)[0]?.id !== "4") throw new Error("a vectorless row must still be searchable lexically");
256
+
257
+ // facet: the verb that makes a corpus legible. Stringified lists are what
258
+ // archive.org's `subject` actually looks like; faceting the whole string
259
+ // answers a question nobody asked.
260
+ const f = facet(rows, "subject");
261
+ if (f.top[0].value !== "Sitcom" || f.top[0].count !== 2) throw new Error(`list-ish values must split: ${JSON.stringify(f.top)}`);
262
+ if (f.missing !== 1) throw new Error("a field absent from a row must be counted missing, not skipped silently");
263
+ if (facet(rows, "year").distinct !== 3) throw new Error("plain values must not be split");
264
+ // The other form the same bundle uses: a bare comma-joined list.
265
+ if (values("Sitcom,Comedy,Fox").length !== 3) throw new Error("a machine-joined list must split");
266
+ // …and prose that merely contains a comma must not. A creator credit split
267
+ // into "Wilson" and "Michael G." is a wrong answer, not a formatting quirk.
268
+ if (values("Wilson, Michael G.").length !== 1) throw new Error("a comma followed by a space is prose, not a list");
269
+ if (values("1987").length !== 1) throw new Error("a plain value must stay whole");
270
+ // where: the drill-down. "Which years, among sitcoms."
271
+ const w = facet(rows, "year", { where: { subject: "Sitcom" } });
272
+ if (w.distinct !== 1 || w.top[0].value !== "1987") throw new Error(`where must narrow the facet: ${JSON.stringify(w.top)}`);
273
+ // Case is the trap: this corpus has `genre` lowercase and `subject` title
274
+ // case, and an agent that echoes a value back with the wrong case gets an
275
+ // empty result and no clue why.
276
+ if (facet(rows, "year", { where: { subject: "sitcom" } }).distinct !== 1) throw new Error("where must match case-insensitively");
277
+ // …but still on the WHOLE value. A substring match would make "comedy" count
278
+ // every "dark comedy" row and quietly inflate every answer.
279
+ if (facet(rows, "year", { where: { subject: "Sit" } }).distinct !== 0) throw new Error("where must match whole values, not substrings");
280
+
281
+ // browse: "show me what's here", the one thing no other verb does.
282
+ const bAll = browse(rows, roles, { limit: 2 });
283
+ if (bAll.total !== 4 || bAll.rows.length !== 2) throw new Error(`browse must page: ${JSON.stringify(bAll)}`);
284
+ // A corpus whose rows declare no type has no collections — an empty list,
285
+ // which a caller can render as "no groupings", not undefined.
286
+ if (bAll.collections.length !== 0) throw new Error("a typeless corpus must report zero collections, not invent one");
287
+ if (browse(rows, roles, { offset: 3 }).rows.length !== 1) throw new Error("offset must page to the tail");
288
+ if (browse(rows, roles, { offset: 99 }).rows.length !== 0) throw new Error("an offset past the end must be empty, not wrapped");
289
+ // Typed browse, case-insensitively — same trap as `where`.
290
+ const typed = rows.map((r) => ({ ...r, entityType: r.id === "3" ? "Film" : "Episode" }));
291
+ const grouped = browse(typed, roles, { limit: 1 }).collections;
292
+ if (grouped.map((c) => `${c.type}:${c.count}`).join() !== "Episode:3,Film:1") throw new Error(`collections must count and sort: ${JSON.stringify(grouped)}`);
293
+ if (browse(typed, roles, { type: "film" }).total !== 1) throw new Error("type must match case-insensitively");
294
+ if (browse(typed, roles, { type: "film" }).collections !== undefined) throw new Error("a typed browse is already inside a collection");
295
+ // where and type compose, through the same matcher facet uses.
296
+ if (browse(typed, roles, { type: "Episode", where: { year: "1987" } }).total !== 2) throw new Error("browse must honour where");
297
+ // Numeric sort on a string field: "10" must not land below "9".
298
+ const nums = [{ id: "a", rating: "9" }, { id: "b", rating: "10" }, { id: "c" }];
299
+ const sorted = browse(nums, roles, { sort: "rating" }).rows.map((r) => r.id);
300
+ if (sorted.join() !== "b,a,c") throw new Error(`numeric sort + missing-last wrong: ${sorted}`);
301
+ if (browse(nums, roles, { sort: "rating", desc: false }).rows[0].id !== "a") throw new Error("ascending sort ignored");
302
+
303
+ // neighbors: explore without knowing the vocabulary.
304
+ const n = neighbors(rows, "1", roles);
305
+ if (n.near[0].id !== "2") throw new Error("nearest neighbour missed the near-identical row");
306
+ if (n.near.some((x) => x.id === "1")) throw new Error("a row must not be its own neighbour");
307
+ if (n.near.some((x) => x.id === "4")) throw new Error("a vectorless row cannot be a neighbour");
308
+ if (neighbors(rows, "4", roles).near.length) throw new Error("a vectorless seed must return nothing, not throw");
309
+ if (neighbors(rows, "nope", roles).seed !== null) throw new Error("an unknown seed must be null, not undefined");
310
+
311
+ // getRow: the only verb that returns everything, and never the raw vector —
312
+ // 256 floats of JSON in an agent's context buys it nothing.
313
+ const g = getRow(rows, "3", roles);
314
+ if (g.name !== "Atomic Cafe" || !g.hasVector || g.vectorDim !== 3) throw new Error("getRow lost the record");
315
+ if ("vector" in g || "norm" in g) throw new Error("the raw vector must never reach a tool result");
316
+ if (getRow(rows, "nope", roles) !== null) throw new Error("an unknown id must be null");
317
+
318
+ // brief truncates, so fifty hits cannot spend a whole context window.
319
+ const long = brief(row("x", { name: "n", categories: "d".repeat(500) }), rolesFrom([{ role_map: { title: "name", tags: ["categories"] } }]));
320
+ if (long.categories.length > 200) throw new Error("brief must truncate a long field");
321
+ if (long.title !== "n") throw new Error("brief must title from the declared field");
322
+
323
+ // Semantic search that composes with a filter. Without this, "like X but only
324
+ // Y" is two calls and a join the caller has to do by hand.
325
+ {
326
+ const pool = [
327
+ { id: "a", name: "cold war thriller", year: 1965, vector: [1, 0], norm: 1 },
328
+ { id: "b", name: "cold war comedy", year: 1999, vector: [1, 0], norm: 1 },
329
+ { id: "c", name: "beach party", year: 1964, vector: [0, 1], norm: 1 },
330
+ { id: "d", name: "surf movie", year: 1966, vector: [0, 1], norm: 1 },
331
+ ];
332
+ const rl = { title: ["name"], subtitle: [], tags: [], text: ["name"], measures: [], fields: ["year"] };
333
+ const all = search(pool, "cold war", rl, { qv: [1, 0], limit: 10 });
334
+ if (!all.some((h) => h.title === "cold war comedy")) throw new Error("unfiltered search must see every era");
335
+ const old60s = search(pool, "cold war", rl, { qv: [1, 0], limit: 10, where: { year: 1965 } });
336
+ if (old60s.length !== 1 || old60s[0].title !== "cold war thriller") throw new Error(`where must narrow a semantic search: ${JSON.stringify(old60s)}`);
337
+ // …and the floor is computed inside the filter, not outside it.
338
+ const surf = search(pool, "cold war", rl, { qv: [0, 1], limit: 10, where: { year: 1964 } });
339
+ if (surf[0]?.title !== "beach party") throw new Error("the floor must be measured over the filtered rows");
340
+ }
341
+
342
+ // The shape callers must code against. kingsfoil's agent surface read
343
+ // `search(...).hits` and returned [] for every query — silently, because
344
+ // undefined ?? [] is a valid empty result. Pin it.
345
+ {
346
+ const pool = [{ id: "a", name: "cold war thriller", vector: [1, 0], norm: 1 }];
347
+ const rl = { title: ["name"], subtitle: [], tags: [], text: ["name"], measures: [], fields: [] };
348
+ const out = search(pool, "cold war", rl, { qv: [1, 0], limit: 5 });
349
+ if (!Array.isArray(out)) throw new Error("search must return a flat array, not {hits}");
350
+ if (out[0].score === undefined || out[0].mode === undefined) throw new Error("search rows carry score+mode");
351
+ // A missing id must not match the first row lacking `path`/`name`.
352
+ for (const bad of [undefined, null, ""]) {
353
+ if (getRow(pool, bad, rl) !== null) throw new Error(`getRow(${JSON.stringify(bad)}) must be null`);
354
+ }
355
+ }
356
+
357
+ console.log("tools.js self-check ok — coverage not schema, semantic/lexical modes + z-floor, list-ish facets + where, neighbours, whole-row fetch, previews truncate");
358
+ }