@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fangorn-network/westmarch",
3
- "version": "0.0.11-dev",
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). Still q8 at batch 1, so still the same encoder: its
29
- // vectors agree with CPU q8 to ~0.99, closer than the browser's WASM q8 queries (~0.98).
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: "q8", ...(device && { device }) });
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. The batch loop stays with the caller — see embedDocuments. */
63
- export async function embedDocumentDirect(text) {
64
- const ex = await extractor();
65
- const output = await ex(`search_document: ${text}`, { pooling: "mean", normalize: false });
66
- return matryoshka(output.data);
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. */
@@ -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
- // Two passes: verify every card (small), then read catalogs (Quorum's is
136
- // 1.7 MB, Kingsfoil has 23). Only a card failure drops an app; an owner or
137
- // catalog that fails leaves it listed with less to say about it.
138
- const dropped = [];
139
- const verified = (await Promise.all([...latest].map(async ([appId, { uri, block }]) => {
140
- if (!uri) return null; // unbound on purpose
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
- const card = await json(uri);
151
+ card = await json(uri);
144
152
  const why = verifyCard(card, appId, chain);
145
153
  if (why) throw new Error(why);
146
- return { appId, uri, block, card };
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
- return {
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
 
@@ -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)
@@ -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: "q8" };
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
- for (const id of fresh) {
267
- const fields = records.get(id);
268
- const v = await embed(composeText(fields, rolesOf.get(fields.entityType)));
269
- lines.push(JSON.stringify({ track_id: id, fields, v: packVec(v) }));
270
- if (++n % 100 === 0) log(`${domain}: embedded ${n}/${fresh.length}`);
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