@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.
- package/LICENSE.md +20 -0
- package/README.md +280 -0
- package/consume/apps.js +111 -0
- package/consume/corpora.js +297 -0
- package/consume/directory.js +262 -0
- package/consume/embed.js +251 -0
- package/consume/embed.worker.js +26 -0
- package/consume/lint.js +500 -0
- package/consume/mcp.js +381 -0
- package/consume/rank.js +218 -0
- package/consume/roles.js +556 -0
- package/consume/shard.js +726 -0
- package/consume/steam.js +341 -0
- package/consume/taste-doc.js +342 -0
- package/consume/taste.js +350 -0
- package/consume/tools.js +358 -0
- package/consume/ui.js +724 -0
- package/docs/app-to-agent.md +351 -0
- package/package.json +70 -0
- package/publish/agent-card.js +134 -0
- package/publish/cli.js +336 -0
- package/publish/cohort.js +349 -0
- package/publish/demand.js +537 -0
- package/publish/enrich.js +211 -0
- package/publish/envelope.js +185 -0
- package/publish/graph.js +307 -0
- package/publish/manifest.js +142 -0
- package/publish/reactions.js +343 -0
- package/publish/settle.js +165 -0
- package/publish/terms.js +200 -0
- package/publish/vault.js +162 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// Several corpora, open at once.
|
|
2
|
+
//
|
|
3
|
+
// Everything else in consume/ answers a question about ONE bundle. This holds
|
|
4
|
+
// the set, and it exists because the interesting questions are the ones a single
|
|
5
|
+
// publisher cannot answer: rank these games by a taste built from those films,
|
|
6
|
+
// find this place near that event. Two publishers who have never heard of each
|
|
7
|
+
// other, joined by nothing but the embedding model they both used.
|
|
8
|
+
//
|
|
9
|
+
// shard.js already caches rows and manifests per view, so nothing here fetches.
|
|
10
|
+
// What was missing was somewhere for a consumer to keep the PARSED result of
|
|
11
|
+
// more than one view — rows plus the roles they were parsed with, which differ
|
|
12
|
+
// per publisher and must not be shared.
|
|
13
|
+
//
|
|
14
|
+
// ponytail: a Map and three lookups, no eviction. Corpora are tens of megabytes
|
|
15
|
+
// each and a person opens a handful; when a session holds enough of them to
|
|
16
|
+
// matter, drop the least recently focused — the map is already keyed for it.
|
|
17
|
+
|
|
18
|
+
import { trimView } from "./shard.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A set of open corpora, and the vocabulary for naming one.
|
|
22
|
+
*
|
|
23
|
+
* `blankRoles` is injected rather than imported so this file does not depend on
|
|
24
|
+
* roles.js — a consumer that parses rows some other way still gets the session.
|
|
25
|
+
*/
|
|
26
|
+
export function session({ blankRoles = () => ({}) } = {}) {
|
|
27
|
+
const map = new Map(); // view → { view, name, rows, roles, stats }
|
|
28
|
+
let focus = null;
|
|
29
|
+
|
|
30
|
+
/** The record for a view, created empty on first mention. Called from the
|
|
31
|
+
* manifest hook, which fires before any row exists. */
|
|
32
|
+
const slot = (viewIn) => {
|
|
33
|
+
const view = trimView(viewIn);
|
|
34
|
+
let c = map.get(view);
|
|
35
|
+
if (!c) map.set(view, c = { view, name: null, rows: [], roles: blankRoles(), stats: { shards: 0, ms: 0, started: 0 } });
|
|
36
|
+
return c;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Corpora that can actually answer something. A slot exists from the moment
|
|
40
|
+
* its manifests land, which is well before its rows have arrived. */
|
|
41
|
+
const loaded = () => [...map.values()].filter((c) => c.rows.length);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The corpus a caller named — by domain name, view URL, or a fragment of
|
|
45
|
+
* either. Unnamed (or "*") means whichever is focused.
|
|
46
|
+
*
|
|
47
|
+
* A miss LISTS what is open instead of saying "not found". An agent that
|
|
48
|
+
* asked for `films` when the corpus is called `archive-films` needs the real
|
|
49
|
+
* names back; told only that it failed, it tries the same thing again.
|
|
50
|
+
*/
|
|
51
|
+
function at(ref) {
|
|
52
|
+
const cs = loaded();
|
|
53
|
+
const names = cs.map((c) => c.name ?? c.view).join(", ") || "none";
|
|
54
|
+
if (!ref || ref === "*") {
|
|
55
|
+
const c = focus && map.get(focus);
|
|
56
|
+
if (!c?.rows.length) throw new Error(`no corpus open (loaded: ${names})`);
|
|
57
|
+
return c;
|
|
58
|
+
}
|
|
59
|
+
const want = String(ref).toLowerCase();
|
|
60
|
+
const eq = (x) => x?.toLowerCase() === want;
|
|
61
|
+
const has = (x) => x?.toLowerCase().includes(want);
|
|
62
|
+
const hit = cs.find((c) => eq(c.name))
|
|
63
|
+
?? cs.find((c) => eq(c.view) || eq(trimView(c.view)))
|
|
64
|
+
?? cs.find((c) => has(c.name) || has(c.view));
|
|
65
|
+
if (!hit) throw new Error(`no open corpus matches "${ref}" — open: ${names}`);
|
|
66
|
+
return hit;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The corpora one call spans: all of them for "*", else the one named.
|
|
70
|
+
* "*" with nothing open still throws through `at`, rather than returning an
|
|
71
|
+
* empty list that would read as "searched everywhere, found nothing". */
|
|
72
|
+
const span = (ref) => (ref === "*" ? (loaded().length ? loaded() : [at()]) : [at(ref)]);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
map, slot, loaded, at, span,
|
|
76
|
+
get focus() { return focus; },
|
|
77
|
+
set focus(v) { focus = v == null ? null : trimView(v); },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Merge per-corpus hit lists into one ranking, tagging each hit with its source.
|
|
83
|
+
*
|
|
84
|
+
* A sort, not a normalisation, and that is a property of the data rather than a
|
|
85
|
+
* shortcut: every corpus scored the SAME query vector with raw cosine, so the
|
|
86
|
+
* numbers are already on one scale. Each corpus applies its own z-floor first,
|
|
87
|
+
* which is what keeps a corpus that knows nothing about the question from
|
|
88
|
+
* contributing its least-bad rows to a list it has no business being in.
|
|
89
|
+
*
|
|
90
|
+
* The tag is omitted when only one corpus was searched — a `corpus` key on every
|
|
91
|
+
* hit of a single-corpus search is noise an agent has to learn to ignore.
|
|
92
|
+
*/
|
|
93
|
+
export function merge(perCorpus, { limit = 10, key = "score" } = {}) {
|
|
94
|
+
const multi = perCorpus.length > 1;
|
|
95
|
+
const all = perCorpus
|
|
96
|
+
.flatMap(({ name, hits }) => hits.map((h) => (multi ? { corpus: name, ...h } : h)))
|
|
97
|
+
.sort((a, b) => b[key] - a[key]);
|
|
98
|
+
// One row per title per corpus, best score kept. A corpus of dialogue has a
|
|
99
|
+
// row per spoken line and titles them all by the film, so an unfiltered
|
|
100
|
+
// ranking returns "Beast_of_Yucca_Flats.mp4" four times at 0.671, 0.670,
|
|
101
|
+
// 0.670, 0.669 — four slots spent saying the same thing. Keyed by corpus,
|
|
102
|
+
// not title alone: the SAME title in two publishers is the interesting case,
|
|
103
|
+
// not a duplicate.
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const h of all) {
|
|
107
|
+
const k = `${h.corpus ?? ""}\n${h.title ?? h.id ?? out.length}`;
|
|
108
|
+
if (seen.has(k)) continue;
|
|
109
|
+
seen.add(k);
|
|
110
|
+
out.push(h);
|
|
111
|
+
if (out.length >= limit) break;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Follow a corpus's declared reference into the corpus it points at.
|
|
118
|
+
*
|
|
119
|
+
* The archive bundle bakes two domains out of one graph: 20,986 films, and
|
|
120
|
+
* 21,131 subtitle passages each carrying the `path` of the film it was
|
|
121
|
+
* transcribed from. Every one of them resolves. The whole reason the graph holds
|
|
122
|
+
* them together is that you search what was SAID and arrive at what was FILMED —
|
|
123
|
+
* and a consumer that ranks them as two unrelated shelves throws that away,
|
|
124
|
+
* handing a person a matched line of dialogue with nothing to play.
|
|
125
|
+
*
|
|
126
|
+
* So a pick from a referring corpus is REPLACED by its target, keeping what
|
|
127
|
+
* matched as `via`: the row that actually scored, the corpus it came from, and
|
|
128
|
+
* the timestamp if its publisher gave one away. A film reached by three
|
|
129
|
+
* different lines appears once, by the best of them, with the rest counted —
|
|
130
|
+
* otherwise one talkative film fills the queue.
|
|
131
|
+
*
|
|
132
|
+
* A reference that cannot be followed — the target corpus is not open, or the
|
|
133
|
+
* key is missing from the row — leaves the pick exactly as it was. Dropping a
|
|
134
|
+
* hit because its film has not finished downloading would be worse than showing
|
|
135
|
+
* the hit.
|
|
136
|
+
*/
|
|
137
|
+
export function follow(picks = [], corpora = [], { at = null } = {}) {
|
|
138
|
+
const byName = new Map(corpora.map((c) => [c.name, c]));
|
|
139
|
+
const index = new Map(); // "corpus field" → (key value → row)
|
|
140
|
+
const keyed = (name, field) => {
|
|
141
|
+
const k = `${name} ${field}`;
|
|
142
|
+
let m = index.get(k);
|
|
143
|
+
if (!m) {
|
|
144
|
+
m = new Map();
|
|
145
|
+
for (const r of byName.get(name)?.rows ?? []) {
|
|
146
|
+
const v = r?.[field];
|
|
147
|
+
if (v != null && v !== "" && !m.has(v)) m.set(v, r);
|
|
148
|
+
}
|
|
149
|
+
index.set(k, m);
|
|
150
|
+
}
|
|
151
|
+
return m;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const out = [];
|
|
155
|
+
const seen = new Map(); // target row id → the entry already emitted for it
|
|
156
|
+
for (const p of picks) {
|
|
157
|
+
const ref = p.roles?.refers;
|
|
158
|
+
const target = ref && byName.get(ref.corpus);
|
|
159
|
+
const row = target && keyed(ref.corpus, ref.to).get(p.row?.[ref.field]);
|
|
160
|
+
if (!row) { out.push(p); continue; }
|
|
161
|
+
|
|
162
|
+
const prev = seen.get(row.id);
|
|
163
|
+
if (prev) { prev.via.also = (prev.via.also ?? 0) + 1; continue; }
|
|
164
|
+
const entry = {
|
|
165
|
+
...p,
|
|
166
|
+
row,
|
|
167
|
+
roles: target.roles,
|
|
168
|
+
corpus: target.name,
|
|
169
|
+
via: {
|
|
170
|
+
row: p.row,
|
|
171
|
+
corpus: p.corpus,
|
|
172
|
+
// The roles that row was published under, because after the swap
|
|
173
|
+
// `roles` belongs to the TARGET and reading the matched line with
|
|
174
|
+
// the film publisher's declaration would be reading it with the
|
|
175
|
+
// wrong text role — which, on a corpus that sells its text, is the
|
|
176
|
+
// difference between an empty string and someone else's field.
|
|
177
|
+
roles: p.roles,
|
|
178
|
+
// Only when the publisher actually gives it away. The archive
|
|
179
|
+
// bundle sells `start` today, so most passages resolve to a film
|
|
180
|
+
// and not to a moment in one — a fact about that bundle's
|
|
181
|
+
// paywall, and not something to paper over with a zero.
|
|
182
|
+
at: at ? at(p.row) : null,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
seen.set(row.id, entry);
|
|
186
|
+
out.push(entry);
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── self-check: `node consume/corpora.js` ───────────────────────────────────
|
|
192
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
193
|
+
const s = session();
|
|
194
|
+
const add = (view, name, n) => {
|
|
195
|
+
const c = s.slot(view);
|
|
196
|
+
c.name = name;
|
|
197
|
+
c.rows = Array.from({ length: n }, (_, i) => ({ id: `${name}-${i}` }));
|
|
198
|
+
return c;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// A slot exists before its rows do, and must not answer while empty.
|
|
202
|
+
s.slot("https://a.test/q/v1/cdn");
|
|
203
|
+
if (s.loaded().length) throw new Error("an empty slot must not count as loaded");
|
|
204
|
+
if (s.map.has("https://a.test/q/v1/cdn")) throw new Error("the key must be the trimmed view base");
|
|
205
|
+
|
|
206
|
+
add("https://a.test/q/v1", "archive-films", 3);
|
|
207
|
+
add("https://b.test/q/v2/stream", "games", 5);
|
|
208
|
+
s.focus = "https://a.test/q/v1";
|
|
209
|
+
|
|
210
|
+
// Opening the second did not close the first. This is the whole feature.
|
|
211
|
+
if (s.loaded().length !== 2) throw new Error("corpora must accumulate, not replace");
|
|
212
|
+
if (s.at().name !== "archive-films") throw new Error("unnamed must mean focused");
|
|
213
|
+
if (s.at("games").rows.length !== 5) throw new Error("by name");
|
|
214
|
+
if (s.at("GAMES").name !== "games") throw new Error("names are case-insensitive — an agent reads them off a listing");
|
|
215
|
+
if (s.at("https://b.test/q/v2").name !== "games") throw new Error("by view url, trimmed either way");
|
|
216
|
+
if (s.at("films").name !== "archive-films") throw new Error("a fragment must resolve — `films` for `archive-films` is the common agent guess");
|
|
217
|
+
if (s.span("*").length !== 2) throw new Error('"*" must span everything open');
|
|
218
|
+
if (s.span("games").length !== 1) throw new Error("a named span is one corpus");
|
|
219
|
+
|
|
220
|
+
// A miss names what IS open. "not found" alone makes an agent retry the same call.
|
|
221
|
+
try { s.at("netflix"); throw new Error("a miss must throw"); }
|
|
222
|
+
catch (e) { if (!e.message.includes("archive-films") || !e.message.includes("games")) throw new Error(`a miss must list what is open: ${e.message}`); }
|
|
223
|
+
|
|
224
|
+
// "*" against an empty session is an error, not an empty result — the
|
|
225
|
+
// difference between "nothing is open" and "I searched and found nothing".
|
|
226
|
+
const empty = session();
|
|
227
|
+
try { empty.span("*"); throw new Error('"*" with nothing open must throw'); }
|
|
228
|
+
catch (e) { if (!e.message.includes("no corpus open")) throw e; }
|
|
229
|
+
|
|
230
|
+
// Merging is a sort across corpora, tagged, and untagged when it is one.
|
|
231
|
+
const m = merge([
|
|
232
|
+
{ name: "films", hits: [{ t: "a", score: 0.9 }, { t: "b", score: 0.4 }] },
|
|
233
|
+
{ name: "games", hits: [{ t: "c", score: 0.7 }] },
|
|
234
|
+
], { limit: 2 });
|
|
235
|
+
if (m.map((h) => h.t).join("") !== "ac") throw new Error(`cross-corpus merge must rank by score: ${JSON.stringify(m)}`);
|
|
236
|
+
if (m[0].corpus !== "films" || m[1].corpus !== "games") throw new Error("a merged hit must say which corpus it came from");
|
|
237
|
+
const one = merge([{ name: "films", hits: [{ t: "a", score: 0.9 }] }]);
|
|
238
|
+
if ("corpus" in one[0]) throw new Error("a single-corpus result must not carry a corpus tag");
|
|
239
|
+
|
|
240
|
+
// A dialogue corpus repeats one title per line; a ranked list must not.
|
|
241
|
+
{
|
|
242
|
+
const m = merge([
|
|
243
|
+
{ name: "films", hits: [{ title: "Beast", score: 0.7 }, { title: "Beast", score: 0.69 }, { title: "Other", score: 0.6 }] },
|
|
244
|
+
{ name: "games", hits: [{ title: "Beast", score: 0.65 }] },
|
|
245
|
+
], { limit: 5 });
|
|
246
|
+
if (m.length !== 3) throw new Error(`repeated titles must collapse within a corpus: ${m.length}`);
|
|
247
|
+
if (m[0].score !== 0.7) throw new Error("the best-scoring copy must be the one kept");
|
|
248
|
+
if (!m.some((h) => h.corpus === "games" && h.title === "Beast")) throw new Error("the same title in another publisher is a different row");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── follow: two shelves that are one thing ──────────────────────────────
|
|
252
|
+
{
|
|
253
|
+
const films = {
|
|
254
|
+
name: "archive-films",
|
|
255
|
+
roles: { title: ["name"], media: ["url"] },
|
|
256
|
+
rows: [
|
|
257
|
+
{ id: "f1", name: "King Kelly.mp4", path: "Comedy/King Kelly.mp4", url: "https://a.test/kk.mp4", mime: "video/mp4" },
|
|
258
|
+
{ id: "f2", name: "Nosferatu.mp4", path: "Horror/Nosferatu.mp4", url: "https://a.test/nos.mp4", mime: "video/mp4" },
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
const subs = {
|
|
262
|
+
name: "archive-dialogue",
|
|
263
|
+
roles: { title: ["name"], text: ["text"], refers: { field: "videoPath", corpus: "archive-films", to: "path" } },
|
|
264
|
+
rows: [],
|
|
265
|
+
};
|
|
266
|
+
const line = (id, path, start) => ({ row: { id, videoPath: path, name: "x.mp4", start }, roles: subs.roles, corpus: subs.name });
|
|
267
|
+
|
|
268
|
+
const got = follow(
|
|
269
|
+
[{ ...line("s1", "Comedy/King Kelly.mp4", 41.5), score: 0.7 },
|
|
270
|
+
{ ...line("s2", "Comedy/King Kelly.mp4", 900), score: 0.6 },
|
|
271
|
+
{ ...line("s3", "Horror/Nosferatu.mp4"), score: 0.5 },
|
|
272
|
+
{ ...line("s4", "Missing/Gone.mp4"), score: 0.4 }],
|
|
273
|
+
[films, subs], { at: (r) => r.start ?? null });
|
|
274
|
+
|
|
275
|
+
if (got.length !== 3) throw new Error(`one film reached by two lines is one entry: ${got.length}`);
|
|
276
|
+
if (got[0].row.id !== "f1" || got[0].corpus !== "archive-films") throw new Error("a matched line must become the film it came from");
|
|
277
|
+
if (got[0].roles !== films.roles) throw new Error("…rendered by the FILM publisher's declaration, not the transcript's");
|
|
278
|
+
if (got[0].via.row.id !== "s1" || got[0].via.corpus !== "archive-dialogue") throw new Error("what actually matched must survive as provenance");
|
|
279
|
+
if (got[0].via.at !== 41.5) throw new Error("a timestamp the publisher gave away must come with it");
|
|
280
|
+
if (got[0].via.roles !== subs.roles) throw new Error("the matched row's own roles must survive, or its text is read with the wrong declaration");
|
|
281
|
+
if (got[0].via.also !== 1) throw new Error("the other lines into the same film must be counted, not silently dropped");
|
|
282
|
+
if (got[0].score !== 0.7) throw new Error("the best line's score is the film's score");
|
|
283
|
+
if (got[1].via.at !== null) throw new Error("a passage whose publisher withheld the timecode resolves to the film, not to a fake moment");
|
|
284
|
+
|
|
285
|
+
// An unresolvable reference is kept, not lost: the target corpus may
|
|
286
|
+
// simply not have downloaded yet.
|
|
287
|
+
if (got[2].row.id !== "s4" || got[2].via) throw new Error("a reference that cannot be followed must leave the pick alone");
|
|
288
|
+
const closed = follow([{ ...line("s1", "Comedy/King Kelly.mp4", 1), score: 1 }], [subs], { at: (r) => r.start });
|
|
289
|
+
if (closed[0].row.id !== "s1") throw new Error("with the target corpus unopened, the hit still stands");
|
|
290
|
+
|
|
291
|
+
// A corpus that declares nothing is untouched, and that is most of them.
|
|
292
|
+
const plain = [{ row: { id: "g1" }, roles: { title: ["title"] }, corpus: "games", score: 1 }];
|
|
293
|
+
if (follow(plain, [films])[0] !== plain[0]) throw new Error("a corpus with no declared reference must pass through");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
console.log("corpora.js self-check ok — corpora accumulate, resolve by name/url/fragment, a miss lists what is open, merge ranks across publishers on one scale, a declared reference turns a matched subtitle into the film it came from");
|
|
297
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Finding the corpus, before you can search inside it.
|
|
2
|
+
//
|
|
3
|
+
// Every other file here answers a question about rows you already have. This one
|
|
4
|
+
// answers the question that comes first, and that an agent facing a network of
|
|
5
|
+
// independent publishers cannot otherwise answer at all: WHICH corpus.
|
|
6
|
+
//
|
|
7
|
+
// It works because the bake already writes the answer. Each domain's
|
|
8
|
+
// catalog.json carries `coverage` — spherical k-means centroids over a sample of
|
|
9
|
+
// its own vectors, 8 of them at 128 dims, about 4 KB. That is a fingerprint of
|
|
10
|
+
// what a corpus is ABOUT, published by whoever baked it, costing nothing to
|
|
11
|
+
// fetch. Rank a query vector against every registered publisher's centroids and
|
|
12
|
+
// you have ranked the network's corpora without downloading a single shard.
|
|
13
|
+
//
|
|
14
|
+
// Which is the property that matters. A directory that had to index the rows
|
|
15
|
+
// would have to hold everyone's rows — it would be a search engine, with a
|
|
16
|
+
// crawler, and every publisher's data on its disks. This holds 4 KB per corpus
|
|
17
|
+
// and never sees a row. The query is embedded in the client too, so the
|
|
18
|
+
// directory does not learn what anyone is looking for either.
|
|
19
|
+
//
|
|
20
|
+
// ponytail: one flat pass over every registered domain, no index. A registry
|
|
21
|
+
// with 10k domains is 40MB of centroids and a linear scan of 80k × 128 floats —
|
|
22
|
+
// still well under a second. Past that, the directory needs its own coverage
|
|
23
|
+
// index, which is the same trick one level up.
|
|
24
|
+
|
|
25
|
+
import { trimView } from "./shard.js";
|
|
26
|
+
import { rankDomains, zFloor } from "./rank.js";
|
|
27
|
+
import { listApps } from "./apps.js";
|
|
28
|
+
|
|
29
|
+
/** Catalog URL for a view base, tolerating either form the registry prints. */
|
|
30
|
+
const catalogUrl = (view) => `${trimView(view)}/cdn/catalog`;
|
|
31
|
+
|
|
32
|
+
async function readJson(url, timeoutMs) {
|
|
33
|
+
const ctl = new AbortController();
|
|
34
|
+
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(url, { signal: ctl.signal });
|
|
37
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
38
|
+
return await res.json();
|
|
39
|
+
} finally { clearTimeout(t); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every corpus a set of publishers offers, with what it is about and what it
|
|
44
|
+
* costs — but not its contents.
|
|
45
|
+
*
|
|
46
|
+
* `sources` are view bases. The on-chain app list (apps.js) resolves to a list
|
|
47
|
+
* of them; this takes the list, so the directory works with a registry, a
|
|
48
|
+
* hand-written array, or one URL a user pasted.
|
|
49
|
+
*
|
|
50
|
+
* A publisher that is down, slow, or serving something that isn't a catalog is
|
|
51
|
+
* REPORTED, not thrown. A directory that fails because one of forty publishers
|
|
52
|
+
* is offline is a directory nobody can rely on, and silently dropping them is
|
|
53
|
+
* worse — an agent would conclude the corpus does not exist.
|
|
54
|
+
*/
|
|
55
|
+
export async function survey(sources = [], { timeoutMs = 8000 } = {}) {
|
|
56
|
+
const corpora = [];
|
|
57
|
+
const unreachable = [];
|
|
58
|
+
await Promise.all(sources.map(async (src) => {
|
|
59
|
+
const view = trimView(typeof src === "string" ? src : src.view);
|
|
60
|
+
try {
|
|
61
|
+
const cat = await readJson(catalogUrl(view), timeoutMs);
|
|
62
|
+
for (const d of cat.domains ?? []) {
|
|
63
|
+
corpora.push({
|
|
64
|
+
view,
|
|
65
|
+
publisher: typeof src === "string" ? null : (src.name ?? src.appId ?? null),
|
|
66
|
+
owner: typeof src === "string" ? null : (src.owner ?? null),
|
|
67
|
+
domain: d.name,
|
|
68
|
+
description: d.description || "",
|
|
69
|
+
rows: d.count ?? 0,
|
|
70
|
+
bytes: d.bytes ?? 0,
|
|
71
|
+
dim: d.dim ?? null,
|
|
72
|
+
entityTypes: d.entity_types ?? [],
|
|
73
|
+
coverage: d.coverage ?? null,
|
|
74
|
+
// Quoted from the catalog. The field NAMES are one fetch
|
|
75
|
+
// deeper, in the manifest — a listing should not carry every
|
|
76
|
+
// corpus's schema.
|
|
77
|
+
paywall: d.paywall ?? null,
|
|
78
|
+
model: cat.embedding_model ?? null,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
} catch (e) {
|
|
82
|
+
unreachable.push({ view, error: e?.message ?? String(e) });
|
|
83
|
+
}
|
|
84
|
+
}));
|
|
85
|
+
return { corpora, unreachable };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Rank a survey against a query vector. The whole directory, in one line of
|
|
90
|
+
* arithmetic, because `rankDomains` already scores coverage centroids.
|
|
91
|
+
*
|
|
92
|
+
* `affinity` is null — never 0 — for a corpus baked before coverage existed.
|
|
93
|
+
* Sorting those last is right; scoring them 0 would claim they were measured and
|
|
94
|
+
* found irrelevant, and an agent would stop looking at a corpus nobody has
|
|
95
|
+
* described rather than at one that does not match.
|
|
96
|
+
*/
|
|
97
|
+
export function rankCorpora(corpora, qv) {
|
|
98
|
+
return rankDomains(corpora, qv);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The model a corpus was embedded with must match the one that made `qv`, or
|
|
102
|
+
* cosine between them is noise. Publishers are independent, so this WILL happen
|
|
103
|
+
* the day someone bakes with a different model — and it is invisible without a
|
|
104
|
+
* check, because the numbers still come out looking like scores. */
|
|
105
|
+
export const comparable = (corpus, model) => !corpus.model || !model || corpus.model === model;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* One call: what is out there, for this question.
|
|
109
|
+
*
|
|
110
|
+
* Returns matches with their affinity, price and size, plus whatever could not
|
|
111
|
+
* be reached and whatever could not be compared. `embed` is injected so the
|
|
112
|
+
* query vector is made in the caller's own process — the directory never sees
|
|
113
|
+
* the question, which is the same property the shards give search.
|
|
114
|
+
*/
|
|
115
|
+
export async function findCorpora(query, { sources = [], embed, model, limit = 10, timeoutMs } = {}) {
|
|
116
|
+
const { corpora, unreachable } = await survey(sources, { timeoutMs });
|
|
117
|
+
const usable = corpora.filter((c) => comparable(c, model));
|
|
118
|
+
const mismatched = corpora.filter((c) => !comparable(c, model))
|
|
119
|
+
.map((c) => ({ domain: c.domain, model: c.model }));
|
|
120
|
+
|
|
121
|
+
let ranked = usable, qv = null;
|
|
122
|
+
if (query?.trim() && embed) {
|
|
123
|
+
try {
|
|
124
|
+
qv = await embed(query);
|
|
125
|
+
ranked = rankCorpora(usable, qv);
|
|
126
|
+
// Cosine to a coverage centroid is offset per query — a raw 0.52
|
|
127
|
+
// means nothing on its own, and "quantum chromodynamics" scores
|
|
128
|
+
// higher against a film archive than "sci-fi monster movies" does.
|
|
129
|
+
// So the answer to "which of these is actually about this" is a
|
|
130
|
+
// floor computed FROM THIS QUERY's own spread, exactly as rank.js
|
|
131
|
+
// does at row level. Marked, not cut: a person who typed something
|
|
132
|
+
// vague should still see everything that exists.
|
|
133
|
+
//
|
|
134
|
+
// This is the part that has to hold at a thousand publishers, where
|
|
135
|
+
// a ranked list nobody trimmed is a thousand rows of noise.
|
|
136
|
+
const scored = ranked.map((c) => c.affinity).filter((a) => a != null);
|
|
137
|
+
// `Math.min(floor, top)`: with a handful of corpora that ALL match,
|
|
138
|
+
// mean+1σ sits above every one of them and would report nothing
|
|
139
|
+
// relevant. The best match is relevant by definition — the floor's
|
|
140
|
+
// job is to cut the tail, never to empty the list.
|
|
141
|
+
const floor = Math.min(zFloor(scored), Math.max(...scored, -Infinity));
|
|
142
|
+
ranked = ranked.map((c) => ({ ...c, relevant: c.affinity != null && c.affinity >= floor }));
|
|
143
|
+
} catch (e) {
|
|
144
|
+
// No embedder: still a useful directory, just unranked. Say so rather
|
|
145
|
+
// than returning an arbitrary order that looks like a ranking.
|
|
146
|
+
return { query, ranked: false, why: e?.message ?? String(e), corpora: usable, unreachable, mismatched };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
query: query ?? null,
|
|
151
|
+
ranked: !!qv,
|
|
152
|
+
corpora: ranked.slice(0, limit),
|
|
153
|
+
searched: usable.length,
|
|
154
|
+
// How many cleared the floor. An agent should open these and stop —
|
|
155
|
+
// the tail is sorted, not relevant.
|
|
156
|
+
relevant: qv ? ranked.filter((c) => c.relevant).length : null,
|
|
157
|
+
unreachable,
|
|
158
|
+
mismatched,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Every app bound on chain → the view bases their cards list, as survey()
|
|
163
|
+
* sources. A thin pass-through of apps.js so a caller does not need both
|
|
164
|
+
* modules to run a survey. Rejected cards come back beside the sources. */
|
|
165
|
+
export async function sourcesFromChain(fangorn, opts) {
|
|
166
|
+
const { apps, rejected } = await listApps(fangorn, opts);
|
|
167
|
+
return { sources: apps.flatMap((a) => a.views.map((view) => ({ view, name: a.name, appId: a.appId }))), rejected };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── self-check: `node consume/directory.js` ─────────────────────────────────
|
|
171
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
172
|
+
const warn = console.warn; console.warn = () => {};
|
|
173
|
+
const cov = (...vectors) => ({ dim: 2, sampled: 100, vectors, counts: vectors.map(() => 50) });
|
|
174
|
+
|
|
175
|
+
const CATALOGS = {
|
|
176
|
+
"https://films.test/q/v1": {
|
|
177
|
+
embedding_model: "nomic", domains: [{
|
|
178
|
+
name: "films", count: 42215, bytes: 15400000, dim: 256,
|
|
179
|
+
description: "Public-domain film", entity_types: ["video", "subtitles"],
|
|
180
|
+
coverage: cov([1, 0]),
|
|
181
|
+
paywall: { price: "50000", asset: "USDC", locked: 22, free: 14, buyable: true },
|
|
182
|
+
}],
|
|
183
|
+
},
|
|
184
|
+
"https://places.test/q/v2": {
|
|
185
|
+
embedding_model: "nomic", domains: [{
|
|
186
|
+
name: "places", count: 917, bytes: 5600000, dim: 256,
|
|
187
|
+
description: "Local businesses and events", entity_types: ["Business", "Event"],
|
|
188
|
+
coverage: cov([0, 1]),
|
|
189
|
+
}, { name: "menus", count: 30, dim: 256, coverage: cov([0, 1]) },
|
|
190
|
+
{ name: "listings", count: 30, dim: 256, coverage: cov([0, 1]) }],
|
|
191
|
+
},
|
|
192
|
+
"https://old.test/q/v3": {
|
|
193
|
+
embedding_model: "nomic",
|
|
194
|
+
domains: [{ name: "legacy", count: 10, dim: 256 }], // baked before coverage
|
|
195
|
+
},
|
|
196
|
+
"https://other.test/q/v4": {
|
|
197
|
+
embedding_model: "some-other-model",
|
|
198
|
+
domains: [{ name: "foreign", count: 5, dim: 384, coverage: cov([1, 0]) }],
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
globalThis.fetch = async (url) => {
|
|
202
|
+
const base = String(url).replace("/cdn/catalog", "");
|
|
203
|
+
if (base === "https://down.test/q/v9") throw new Error("connect ECONNREFUSED");
|
|
204
|
+
const body = CATALOGS[base];
|
|
205
|
+
return body ? { ok: true, json: async () => body } : { ok: false, status: 404 };
|
|
206
|
+
};
|
|
207
|
+
const SOURCES = [...Object.keys(CATALOGS), "https://down.test/q/v9"];
|
|
208
|
+
const embed = async (q) => (/film|movie|cinema/.test(q) ? [1, 0] : [0, 1]);
|
|
209
|
+
|
|
210
|
+
// The directory ranks corpora it has not downloaded.
|
|
211
|
+
let r = await findCorpora("old movies about war", { sources: SOURCES, embed, model: "nomic" });
|
|
212
|
+
if (!r.ranked) throw new Error("a query with an embedder must rank");
|
|
213
|
+
if (r.corpora[0].domain !== "films") throw new Error(`wrong corpus first: ${r.corpora[0].domain}`);
|
|
214
|
+
if (!(r.corpora[0].affinity > 0.99)) throw new Error(`affinity should be ~1: ${r.corpora[0].affinity}`);
|
|
215
|
+
// …and the price is quoted from the catalog, with no manifest fetched.
|
|
216
|
+
if (r.corpora[0].paywall.price !== "50000") throw new Error("a listing must quote a price");
|
|
217
|
+
|
|
218
|
+
// The floor. Absolute affinity is meaningless across queries, so "which of
|
|
219
|
+
// these is about this" is answered from THIS query's own spread — the thing
|
|
220
|
+
// that has to hold when the directory is a thousand publishers deep.
|
|
221
|
+
if (r.relevant !== 1) throw new Error(`only the film archive is about war films, got ${r.relevant}`);
|
|
222
|
+
if (r.corpora.find((c) => c.domain === "places").relevant) throw new Error("an unrelated corpus must not clear the floor");
|
|
223
|
+
|
|
224
|
+
// The same directory, a different question. Nothing was downloaded in between.
|
|
225
|
+
r = await findCorpora("somewhere to eat tonight", { sources: SOURCES, embed, model: "nomic" });
|
|
226
|
+
if (r.corpora[0].domain !== "places") throw new Error("the question must decide the corpus");
|
|
227
|
+
|
|
228
|
+
// …and when everything matches, the floor must not empty the list.
|
|
229
|
+
if (r.relevant !== 3) throw new Error(`three place corpora all match "eat tonight", got ${r.relevant}`);
|
|
230
|
+
|
|
231
|
+
// Undescribed corpora sort last and stay null — unrankable is not irrelevant.
|
|
232
|
+
const legacy = r.corpora.find((c) => c.domain === "legacy");
|
|
233
|
+
if (legacy.affinity !== null) throw new Error("a corpus with no coverage must rank null, not 0");
|
|
234
|
+
if (r.corpora.at(-1).domain !== "legacy") throw new Error("unrankable sorts last");
|
|
235
|
+
|
|
236
|
+
// A corpus embedded with another model is EXCLUDED and named. Cosine between
|
|
237
|
+
// two models' vectors is noise that still looks like a score.
|
|
238
|
+
if (r.corpora.some((c) => c.domain === "foreign")) throw new Error("a foreign model must not be ranked against ours");
|
|
239
|
+
if (r.mismatched[0]?.domain !== "foreign") throw new Error("an excluded corpus must be reported, not dropped");
|
|
240
|
+
|
|
241
|
+
// One publisher being down must not take the directory down, and must not
|
|
242
|
+
// look like that corpus does not exist.
|
|
243
|
+
if (r.unreachable[0]?.view !== "https://down.test/q/v9") throw new Error("an unreachable publisher must be reported");
|
|
244
|
+
if (!r.corpora.length) throw new Error("one bad publisher must not empty the directory");
|
|
245
|
+
|
|
246
|
+
// No embedder: a listing, honestly labelled, rather than a fake ranking.
|
|
247
|
+
r = await findCorpora("anything", { sources: SOURCES, embed: async () => { throw new Error("model offline"); }, model: "nomic" });
|
|
248
|
+
if (r.ranked !== false || !r.why.includes("model offline")) throw new Error("a failed embed must say so");
|
|
249
|
+
if (!r.corpora.length) throw new Error("an unranked directory is still a directory");
|
|
250
|
+
|
|
251
|
+
// No query at all is a valid call — "what is out there".
|
|
252
|
+
r = await findCorpora("", { sources: SOURCES, embed, model: "nomic" });
|
|
253
|
+
if (r.ranked || r.corpora.length !== 5) throw new Error(`a bare survey must list everything comparable: ${r.corpora.length}`);
|
|
254
|
+
|
|
255
|
+
// Publisher identity rides through when the source came from a registry.
|
|
256
|
+
r = await findCorpora("film", { sources: [{ view: "https://films.test/q/v1", name: "Archive", owner: "0xa" }], embed, model: "nomic" });
|
|
257
|
+
if (r.corpora[0].publisher !== "Archive" || r.corpora[0].owner !== "0xa") throw new Error("publisher identity lost");
|
|
258
|
+
|
|
259
|
+
console.warn = warn;
|
|
260
|
+
console.log("directory.js self-check ok — ranks undownloaded corpora by coverage, quotes price from the catalog, "
|
|
261
|
+
+ "excludes foreign models, survives dead publishers, unranked is labelled not faked");
|
|
262
|
+
}
|