@fangorn-network/westmarch 0.0.11-dev → 0.0.12-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/package.json +1 -1
- package/src/core/embed.js +33 -8
- package/src/discover/explore.js +20 -15
- package/src/publish/ship.js +4 -4
- package/src/publish/view.js +9 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fangorn-network/westmarch",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12-dev",
|
|
4
4
|
"description": "Publisher and consumer surfaces for a Fangorn app: shape a graph, publish it, read it back as a searchable, agent-drivable catalog",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/core/embed.js
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
|
|
14
14
|
const MODEL = "nomic-ai/nomic-embed-text-v1.5";
|
|
15
15
|
const DIM = 256;
|
|
16
|
+
// The document side's precision. q8 everywhere by default (the browser's queries are q8).
|
|
17
|
+
// WESTMARCH_EMBED_DTYPE=fp16 is a different encoder (~0.96 cosine to q8): a view records it
|
|
18
|
+
// and refuses to mix the two (view.js), so switching an app is one --rebake.
|
|
19
|
+
const DTYPE = globalThis.process?.env?.WESTMARCH_EMBED_DTYPE ?? "q8";
|
|
20
|
+
export const EMBED_DTYPE = DTYPE;
|
|
16
21
|
|
|
17
22
|
// Stamped onto every vector a publisher commits. Vectors from different models
|
|
18
23
|
// (or dims) are not comparable — cosine between them is noise, and silently so.
|
|
@@ -25,10 +30,11 @@ let _extractor = null;
|
|
|
25
30
|
const extractor = () => (_extractor ??= (async () => {
|
|
26
31
|
const { pipeline } = await import("@huggingface/transformers");
|
|
27
32
|
// WESTMARCH_EMBED_DEVICE=cuda puts the publisher's document side on a GPU (Node only; needs
|
|
28
|
-
// onnxruntime-node's CUDA binaries
|
|
29
|
-
//
|
|
33
|
+
// onnxruntime-node's CUDA binaries and the matching CUDA runtime, cuBLAS, cuRAND and cuDNN on
|
|
34
|
+
// LD_LIBRARY_PATH). q8's integer ops do not run on CUDA, so q8 there is slower than on CPU:
|
|
35
|
+
// pair it with WESTMARCH_EMBED_DTYPE=fp16, which is ~4x faster than CPU q8, batched.
|
|
30
36
|
const device = globalThis.process?.env?.WESTMARCH_EMBED_DEVICE;
|
|
31
|
-
return pipeline("feature-extraction", MODEL, { dtype:
|
|
37
|
+
return pipeline("feature-extraction", MODEL, { dtype: DTYPE, ...(device && { device }) });
|
|
32
38
|
})());
|
|
33
39
|
|
|
34
40
|
/** Standardize over the full vector, slice to `dim`, L2-normalize the slice. */
|
|
@@ -59,11 +65,30 @@ export async function embedQueryDirect(text) {
|
|
|
59
65
|
return matryoshka(output.data);
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
/** One document, in-process.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
/** One document, in-process. Calls made together are embedded as one batch (a GPU's
|
|
69
|
+
* speed); q8 stays at batch 1, since batching moves its activation quantization. */
|
|
70
|
+
export function embedDocumentDirect(text) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
pending.push({ text, resolve, reject });
|
|
73
|
+
if (pending.length === 1) setTimeout(flushDocuments, 0);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const BATCH = DTYPE === "q8" ? 1 : 32;
|
|
77
|
+
let pending = [];
|
|
78
|
+
async function flushDocuments() {
|
|
79
|
+
const all = pending;
|
|
80
|
+
pending = [];
|
|
81
|
+
try {
|
|
82
|
+
const ex = await extractor();
|
|
83
|
+
for (let i = 0; i < all.length; i += BATCH) {
|
|
84
|
+
const part = all.slice(i, i + BATCH);
|
|
85
|
+
const output = await ex(part.map((p) => `search_document: ${p.text}`), { pooling: "mean", normalize: false });
|
|
86
|
+
const d = output.dims.at(-1);
|
|
87
|
+
part.forEach((p, j) => p.resolve(matryoshka(output.data.slice(j * d, (j + 1) * d))));
|
|
88
|
+
}
|
|
89
|
+
} catch (e) {
|
|
90
|
+
for (const p of all) p.reject(e);
|
|
91
|
+
}
|
|
67
92
|
}
|
|
68
93
|
|
|
69
94
|
/** Load the model in-process, and settle when it is actually ready. */
|
package/src/discover/explore.js
CHANGED
|
@@ -122,8 +122,12 @@ async function getLogsSince(client, start, filter) {
|
|
|
122
122
|
/**
|
|
123
123
|
* Every verified app, with its 8004 agent, reputation, and catalog.
|
|
124
124
|
* Unverified apps are dropped, not listed; `dropped` says why, for debugging.
|
|
125
|
+
*
|
|
126
|
+
* `onUpdate({ apps, pending })`, if given, is called once the bindings are read
|
|
127
|
+
* and again as each app lands or drops, so a page can show apps as they arrive.
|
|
128
|
+
* `pending` counts the bound apps not yet settled.
|
|
125
129
|
*/
|
|
126
|
-
export async function readDirectory(client, { chain = CHAIN } = {}) {
|
|
130
|
+
export async function readDirectory(client, { chain = CHAIN, onUpdate = () => {} } = {}) {
|
|
127
131
|
const [bindLogs, regLogs] = await Promise.all([
|
|
128
132
|
getLogsSince(client, chain.appRegistryFrom ?? 0n, { address: chain.appRegistry, event: APP_AGENT_CHANGED }),
|
|
129
133
|
getLogsSince(client, chain.identityFrom ?? 0n, { address: chain.identity, event: REGISTERED }),
|
|
@@ -131,23 +135,24 @@ export async function readDirectory(client, { chain = CHAIN } = {}) {
|
|
|
131
135
|
const registered = regLogs.map((l) => ({ agentId: l.args.agentId, owner: l.args.owner, block: l.blockNumber }));
|
|
132
136
|
const latest = new Map(); // logs come oldest first: the last write wins
|
|
133
137
|
for (const l of bindLogs) latest.set(l.args.app_id, { uri: l.args.agent_uri, block: l.blockNumber });
|
|
138
|
+
const bound = [...latest].filter(([, { uri }]) => uri); // an empty uri is unbound on purpose
|
|
134
139
|
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
const dropped = [];
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
// One pipeline per app, so the quick ones need not wait on Quorum's 1.7 MB
|
|
141
|
+
// catalog. Only a card failure drops an app; an owner or catalog that fails
|
|
142
|
+
// leaves it listed with less to say about it.
|
|
143
|
+
const apps = [], dropped = [];
|
|
144
|
+
let pending = bound.length;
|
|
145
|
+
const settle = () => { pending--; apps.sort((a, b) => a.name.localeCompare(b.name)); onUpdate({ apps: [...apps], pending }); };
|
|
146
|
+
onUpdate({ apps: [], pending });
|
|
147
|
+
await Promise.all(bound.map(async ([appId, { uri, block }]) => {
|
|
148
|
+
let card;
|
|
141
149
|
try {
|
|
142
150
|
if (!/^https:\/\//.test(uri)) throw new Error("card is not https");
|
|
143
|
-
|
|
151
|
+
card = await json(uri);
|
|
144
152
|
const why = verifyCard(card, appId, chain);
|
|
145
153
|
if (why) throw new Error(why);
|
|
146
|
-
|
|
147
|
-
} catch (e) { dropped.push({ card: uri, why: e?.message ?? String(e) }); return null; }
|
|
148
|
-
}))).filter(Boolean);
|
|
154
|
+
} catch (e) { dropped.push({ card: uri, why: e?.message ?? String(e) }); return settle(); }
|
|
149
155
|
|
|
150
|
-
const apps = await Promise.all(verified.map(async ({ appId, uri, block, card }) => {
|
|
151
156
|
const p = card.capabilities.extensions.find((e) => e.uri === APP_EXTENSION).params;
|
|
152
157
|
const [[owner, agentId, rep], { corpora, unreachable }] = await Promise.all([
|
|
153
158
|
client.readContract({ address: chain.appRegistry, abi: APPS, functionName: "getAppOwner", args: [appId] }).catch(() => null)
|
|
@@ -158,7 +163,7 @@ export async function readDirectory(client, { chain = CHAIN } = {}) {
|
|
|
158
163
|
survey((p.views ?? []).filter((v) => /^https:\/\//.test(v))),
|
|
159
164
|
]);
|
|
160
165
|
const name = typeof card.name === "string" && card.name.trim() ? card.name.trim() : `App ${appId.slice(0, 10)}`;
|
|
161
|
-
|
|
166
|
+
apps.push({
|
|
162
167
|
app: slug(name), name, description: typeof card.description === "string" ? card.description : "",
|
|
163
168
|
site: https(card.url), card: uri, appId, owner,
|
|
164
169
|
boundAt: Number(block), fromBlock: Number(p.fromBlock),
|
|
@@ -169,9 +174,9 @@ export async function readDirectory(client, { chain = CHAIN } = {}) {
|
|
|
169
174
|
rows: corpora.length ? corpora.reduce((n, c) => n + (c.rows || 0), 0) : null,
|
|
170
175
|
unreachable: unreachable.length,
|
|
171
176
|
domains: corpora.map((c) => ({ name: c.domain, description: c.description, rows: c.rows, coverage: c.coverage, model: c.model })),
|
|
172
|
-
};
|
|
177
|
+
});
|
|
178
|
+
settle();
|
|
173
179
|
}));
|
|
174
|
-
apps.sort((a, b) => a.name.localeCompare(b.name));
|
|
175
180
|
return { apps, dropped };
|
|
176
181
|
}
|
|
177
182
|
|
package/src/publish/ship.js
CHANGED
|
@@ -161,7 +161,7 @@ function assertSameDeployment(cli) {
|
|
|
161
161
|
|
|
162
162
|
// ── the steps ─────────────────────────────────────────────────────────────────
|
|
163
163
|
|
|
164
|
-
export async function ship(configPath, { crawl = true, deploy = true, dry = false, replace = false, only = null, register = false } = {}) {
|
|
164
|
+
export async function ship(configPath, { crawl = true, deploy = true, dry = false, replace = false, only = null, register = false, rebake = false } = {}) {
|
|
165
165
|
const cfg = loadConfig(configPath);
|
|
166
166
|
if (!register && (!Object.keys(cfg.types).length || !cfg.sources.length))
|
|
167
167
|
throw new Error(`${configPath}: no types or sources yet, so nothing to build; --register sets up the app alone`);
|
|
@@ -246,7 +246,7 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
246
246
|
const namespaces = [...new Set(cfg.sources.map((s) => s.namespace))];
|
|
247
247
|
if (register && !dry) view.writeCatalog(join(site, "view")); // empty, or what an earlier build left
|
|
248
248
|
const report = dry || register ? {} : await view.publishView({ fangorn, app: cfg.app, namespaces, out: join(site, "view"),
|
|
249
|
-
fromBlock: BigInt(state.fromBlock), embed: view.cachedEmbed(join(dir, "vectors.ndjson")), log });
|
|
249
|
+
fromBlock: BigInt(state.fromBlock), embed: view.cachedEmbed(join(dir, "vectors.ndjson")), rebake, log });
|
|
250
250
|
|
|
251
251
|
// 6. the page, the card, the headers
|
|
252
252
|
if (!existsSync(join(STOCK, "index.html"))) throw new Error(`no stock page at ${STOCK}; run \`npx vite build\` in westmarch/site`);
|
|
@@ -480,7 +480,7 @@ if (process.argv[1]?.endsWith("ship.js") || process.argv[1]?.endsWith("westmarch
|
|
|
480
480
|
}
|
|
481
481
|
const [path, ...flags] = process.argv.slice(2);
|
|
482
482
|
if (!path || flags.includes("--help")) {
|
|
483
|
-
console.error("usage: westmarch-ship app.json [--no-crawl] [--no-deploy] [--dry-run] [--replace] [--only ns1,ns2]\n" +
|
|
483
|
+
console.error("usage: westmarch-ship app.json [--no-crawl] [--no-deploy] [--dry-run] [--replace] [--rebake] [--only ns1,ns2]\n" +
|
|
484
484
|
" westmarch-ship app.json --register [--dry-run] (claim, join, empty site, card bound: no data)\n" +
|
|
485
485
|
" westmarch-ship app.json --local <out> [--crawl] [--only ns1,ns2] (no chain, no deploy)");
|
|
486
486
|
process.exit(path ? 0 : 2);
|
|
@@ -492,7 +492,7 @@ if (process.argv[1]?.endsWith("ship.js") || process.argv[1]?.endsWith("westmarch
|
|
|
492
492
|
process.exit(0);
|
|
493
493
|
}
|
|
494
494
|
await ship(path, { crawl: !flags.includes("--no-crawl"), deploy: !flags.includes("--no-deploy"), dry: flags.includes("--dry-run"),
|
|
495
|
-
replace: flags.includes("--replace"), register: flags.includes("--register"),
|
|
495
|
+
replace: flags.includes("--replace"), register: flags.includes("--register"), rebake: flags.includes("--rebake"),
|
|
496
496
|
only })
|
|
497
497
|
.catch((e) => { console.error(`[ship] ✗ ${e.message}`); process.exit(1); });
|
|
498
498
|
process.exit(0); // a CUDA embedder keeps the process alive (and can abort in its teardown)
|
package/src/publish/view.js
CHANGED
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
33
33
|
import { createHash } from "node:crypto";
|
|
34
34
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
35
|
-
import { EMBED_DIM, EMBED_MODEL, embedDocumentDirect, packVec, unpackVec } from "../core/embed.js";
|
|
35
|
+
import { EMBED_DIM, EMBED_DTYPE, EMBED_MODEL, embedDocumentDirect, packVec, unpackVec } from "../core/embed.js";
|
|
36
36
|
import { coverage as fitCoverage } from "../market/reactions.js";
|
|
37
37
|
|
|
38
38
|
export const SCHEMA_NAMESPACE = "fangorn.schema";
|
|
39
39
|
export const TYPE_TAG = "fangorn.type.v1";
|
|
40
|
-
export const EMBEDDER = { runtime: "transformers.js", model: EMBED_MODEL, dtype:
|
|
40
|
+
export const EMBEDDER = { runtime: "transformers.js", model: EMBED_MODEL, dtype: EMBED_DTYPE };
|
|
41
41
|
const SINGULAR = ["identity", "title", "subtitle", "temporal", "spatial", "media"];
|
|
42
42
|
const MULTI = ["tags", "measures", "relations", "text"];
|
|
43
43
|
const ZERO = "0x0000000000000000000000000000000000000000";
|
|
@@ -263,11 +263,13 @@ async function publishDomain({ fangorn, schema, publisher, ns, domain, dir, reba
|
|
|
263
263
|
|
|
264
264
|
let n = 0;
|
|
265
265
|
const lines = [];
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
266
|
+
// 32 at a time: the embedder batches calls made together (embed.js), the GPU's speed.
|
|
267
|
+
for (let i = 0; i < fresh.length; i += 32) {
|
|
268
|
+
const ids = fresh.slice(i, i + 32);
|
|
269
|
+
const vs = await Promise.all(ids.map((id) => embed(composeText(records.get(id), rolesOf.get(records.get(id).entityType)))));
|
|
270
|
+
ids.forEach((id, j) => lines.push(JSON.stringify({ track_id: id, fields: records.get(id), v: packVec(vs[j]) })));
|
|
271
|
+
if (Math.floor((n + ids.length) / 1000) > Math.floor(n / 1000)) log(`${domain}: embedded ${n + ids.length}/${fresh.length}`);
|
|
272
|
+
n += ids.length;
|
|
271
273
|
}
|
|
272
274
|
if (!fresh.length && !gone.length && had.manifest && !rebake) {
|
|
273
275
|
applySpec(dir, had.manifest, schema, had.rows, dead); // a schema change alone still lands
|