event-horizon / src /engine /wasm_engine.js
maxdemarzi's picture
Deploy 4ed0390343adb220188e58f95ab1a8e7e1dd995e (manual: Actions blocked on billing) (part 2)
1b9ed71 verified
Raw
History Blame Contribute Delete
21.6 kB
/* WASM backend β€” Swan inside the tab.
*
* Three pieces have to line up, and all three now come from one place:
*
* 1. DuckDB a real 1.5.4, built for Pyodide, from a wheel
* 2. swan.duckdb_extension the wasm_eh_pyodide build, supplying
* `swan_compile_ast`
* 3. pyrel_duckdb the PyRel front-end β€” the DSL, the AST serializer
* and the SQL post-processing are all Python
*
* All of it runs inside Pyodide, and there is exactly one database.
*
* They are not all loaded at once. ready() checks what is staged and
* starts the worker; startPyrel() pulls Pyodide, numpy, pandas and the
* front-end, roughly 100 MB and tens of seconds, so it is opt-in and reports
* progress. Nothing here falls back to the sidecar on the caller's behalf:
* which engine answered is what this backend exists to make visible.
*
* VERIFIED headlessly, against the same wasm binaries a browser runs:
* tools/verify_pyrel_wasm.mjs a PyRel query compiles and returns rows, with
* no CPython involved
* And in real browsers: tools/smoke_browser.mjs and tools/smoke_templates.mjs
* drive Chromium, tools/webkit_smoke.mjs drives WebKit.
*/
const ASSETS = {
manifest: 'web/swan-wasm/manifest.json',
python: 'web/swan-wasm/python/',
};
/* The compiled Swan extension, as the vendor script stages it. Named here
* rather than read from the manifest because the probe below runs before the
* manifest is trusted β€” checking a path the manifest supplied would make a
* corrupt manifest look like a missing file. */
const PY_EXTENSION = 'swan.duckdb_extension.wasm';
/* The DuckDB the extension is built against. The manifest records what was
* actually staged β€” read from the wheel's own filename β€” and probe() compares
* the two, so a wheel swap is caught here rather than at LOAD swan. */
const REQUIRED_DUCKDB = '1.5.4';
async function assetExists(url) {
try {
return (await fetch(url, { method: 'HEAD' })).ok;
} catch {
return false;
}
}
/** Hosts of a URL or an ordered chain of them, for saying where questions go. */
function hostOf(url) {
const list = (Array.isArray(url) ? url : [url]).filter(Boolean);
const hosts = list.map((u) => {
try { return new URL(u).host; } catch { return String(u); }
});
// "a, then b" rather than a list: the order is the point, since the second is
// only reached when the first refuses.
return hosts.length > 1 ? `${hosts[0]}, then ${hosts.slice(1).join(', ')}` : hosts[0];
}
/* A short name for whichever generator answered, for the status line.
*
* "gpu" and "cpu" rather than the host, because that is the difference the user
* actually feels -- one answers in seconds and the other in a minute or two --
* and `maxdemarzi-black-swan-pyrel-gpu.hf.space` does not fit next to a row
* count. Matched on the host so a chain pointed elsewhere still says something
* true: an unrecognised host is reported as itself rather than guessed at.
*/
export function generatorLabel(host) {
if (!host) return null;
// The proxy is a *route to* the GPU, not a third kind of generator, so it
// reports what the user gets rather than how it was reached β€” the hop is our
// problem, not theirs.
if (/-gpu\.hf\.space$/.test(host) || /-proxy\.hf\.space$/.test(host)) return 'gpu';
if (/black-swan-pyrel\.hf\.space$/.test(host)) return 'cpu';
if (/^(localhost|127\.0\.0\.1)(:|$)/.test(host)) return 'local';
return host;
}
/* Name the generator that answered, in place, on the way back to the caller.
*
* The worker reports the host it reached; naming it is presentation, so it
* happens here rather than in the worker. Left untouched when the worker said
* nothing, so an older reply or a backend without a chain does not grow an
* empty label. */
function labelVia(answer) {
if (answer && answer.via && answer.via.host) {
answer.via = { ...answer.via, label: generatorLabel(answer.via.host) };
}
return answer;
}
export function createWasmEngine({ onProgress = () => {}, nlq = null, nlqModel = null, nlqPrune = true } = {}) {
let info = null;
/* The PyRel front-end, started separately β€” see startPyrel(). */
let worker = null;
let pyrelReady = null;
const inflight = new Map();
let nextId = 1;
/* Resolved by the worker's unsolicited `rules` message, tens of seconds after
* the dataset is usable. Created up front so a caller that awaits rulesReady()
* before the message arrives does not miss it. */
let rulesResolve = null;
const rulesPromise = new Promise((resolve) => { rulesResolve = resolve; });
function settleRules(msg) {
if (msg.error) { rulesResolve({ error: msg.error }); return; }
if (info) info.rules = msg.rules;
rulesResolve(msg.rules);
}
// A dead worker takes every outstanding call with it; failing them explicitly
// beats leaving promises that never settle.
function failAll(reason) {
const err = new Error(`the Swan worker failed: ${reason}`);
for (const [, p] of inflight) p.reject(err);
inflight.clear();
}
/** One request/response round trip with the Worker. */
function send(type, payload) {
return new Promise((resolve, reject) => {
const id = nextId++;
inflight.set(id, { resolve, reject });
worker.postMessage({ id, type, payload });
});
}
async function probe() {
onProgress('looking for the Swan wasm assets…');
const res = await fetch(ASSETS.manifest).catch(() => null);
if (!res || !res.ok) {
throw new Error(
'web/swan-wasm is not staged β€” run `node tools/vendor_swan_wasm.mjs`',
);
}
const manifest = await res.json();
if (manifest.duckdb !== REQUIRED_DUCKDB) {
throw new Error(
`the staged Swan extension targets duckdb ${manifest.duckdb}, but this engine expects ` +
`${REQUIRED_DUCKDB} β€” re-run tools/vendor_swan_wasm.mjs`,
);
}
/* Check the extension the worker actually loads.
*
* The worker writes python/swan.duckdb_extension.wasm into site-packages,
* where swan finds it by scanning sys.path. Checking anything else means
* guarding a file no code reads while the one every page depends on goes
* unchecked, which is exactly what this used to do. */
if (!(await assetExists(`${ASSETS.python}${PY_EXTENSION}`))) {
throw new Error(
'the Swan extension is not staged (web/swan-wasm/python/) β€” re-run tools/vendor_swan_wasm.mjs',
);
}
return manifest;
}
return {
name: 'wasm',
async ready() {
const manifest = await probe();
/* The worker owns the database. Everything β€” sql(), schema(), pyrel() β€”
* goes through it, so there is exactly one DuckDB and one Swan catalog.
*
* Two databases would mean data loaded through one being invisible to
* the other, with nothing to say so. The Python side needs a synchronous
* API, which cannot be driven from this thread anyway. */
/* A SharedWorker, so the engine outlives the page that started it.
*
* Every page here is a separate document -- this is a static site, not an
* SPA -- so a dedicated Worker died with whichever page created it, taking
* Pyodide, DuckDB, the model and all 117 evaluated rules with it.
*
* Measured on Chromium, what this does and does not buy:
*
* a second tab, first still open 31 s -> 0.3 s, and it answers
* navigate away and come back no better -- the worker is gone
*
* The second row is a property of SharedWorker, not a bug here: one lives
* only while at least one document is connected, and most of this site
* never connects -- Galaxy, Ontology and Ladder read a static graph.json
* and have no engine at all. Leaving Horizon drops the owner count to zero
* and the browser collects it.
*
* A keepalive port on those pages was tried and did not save it; the
* handoff still has a moment with no owner. It was removed rather than
* shipped, because an optimisation that did not work under measurement is
* worse than none -- it reads as covered.
*
* What would actually survive navigation is persisting the DuckDB file to
* IndexedDB and restoring on boot, which would survive closing the browser
* too. Pyodide's own ~10 s boot would remain.
*
* `worker` is the port rather than the worker: postMessage/onmessage look
* the same on it, so everything downstream is unchanged. */
const shared = new SharedWorker(new URL('./pyrel-worker.js', import.meta.url), { type: 'module' });
worker = shared.port;
shared.onerror = (e) => failAll(e.message || 'unknown error');
worker.onmessage = (e) => {
const msg = e.data || {};
if (msg.type === 'progress') { onProgress(msg.text); return; }
/* The rules tier finishes long after loadDataset() resolves, so it
* arrives unsolicited rather than as a reply. Kept as a promise so a
* caller can await it without knowing that. */
if (msg.type === 'rules') { settleRules(msg); return; }
const pending = inflight.get(msg.id);
if (!pending) return;
inflight.delete(msg.id);
if (msg.ok) { pending.resolve(msg.result); return; }
/* A failed question still carries the program that failed and the
* translated database error. Carrying them on the Error is what lets the
* page show the PyRel it tried next to why it did not run β€” the
* difference between "it didn't work" and a query you can fix by hand in
* the box below. horizon.js reads `err.code`. */
const failure = new Error(msg.error);
if (msg.code) failure.code = msg.code;
if (msg.detail) failure.detail = msg.detail;
pending.reject(failure);
};
/* A port does not surface worker errors -- only the SharedWorker object
* does, via onerror above -- but a messageerror still lands here, and a
* failure that reaches neither would leave every caller hanging. */
worker.onmessageerror = () => failAll('the worker sent a message this page could not read');
worker.start();
onProgress('starting the Swan worker…');
/* `init` answers with whatever this shared engine has already done, which
* on a second page is usually everything. */
const boot = await send('init');
if (boot.rules) settleRules(boot.rules.error ? { error: boot.rules.error } : { rules: boot.rules });
/* Adopt the shared worker's state as this page's own.
*
* `pyrelReady` gates pyrel(), and it is per-page: without this, a page that
* returned to an engine another page had already started would show the
* loaded model, accept a query, and then refuse it with "the PyRel
* front-end is not started" -- the page's local flag disagreeing with the
* worker it is attached to. */
if (boot.pyrelStarted) pyrelReady = Promise.resolve(boot.python || null);
info = {
swan: manifest.swan,
duckdb: manifest.duckdb,
/* Not null when another page already loaded it. horizon.js keys the
* "Load it" invitation off this, so a returning page goes straight to
* a queryable model instead of being offered one it already has. */
model: boot.dataset?.model || null,
rules: boot.rules || null,
/* True once the page has been told where to send a prompt. The pipeline
* itself is already here β€” see ask() β€” so this reports configuration
* rather than capability. Without an endpoint it stays false and `auto`
* behaves exactly as it did, falling through to the sidecar for a page
* that needs natural language. */
nl: !!nlq,
/* Available but not loaded: startPyrel() brings up the Python front-end
* on demand, because it is ~100 MB. `auto` reads this flag, so leaving it
* false is what makes a page needing PyRel fall through to the sidecar
* rather than settling on an engine that would refuse on first use. */
pyrel: !!boot.pyrelStarted,
pyrelAvailable: !!manifest.python,
/* Say where questions go, because by default they go somewhere. Every
* other claim this page makes is "nothing leaves the tab", so the one
* thing that does has to be visible rather than discovered. */
note: manifest.python
? (nlq
? `Swan runs in the browser; questions go to ${hostOf(nlq)}`
: 'Swan runs in the browser; PyRel available via startPyrel()')
: 'Swan runs in the browser; the Python front-end is not staged',
};
return info;
},
/** Raw SQL, executed in the worker with Swan's optimizer loaded. */
async sql(text) {
const { columns, rows } = await send('sql', text);
return { code: text, rows: rows.map((r) => Object.fromEntries(columns.map((c, i) => [c, r[i]]))) };
},
schema() { return send('schema'); },
/**
* Add the PyRel front-end β€” Pyodide, numpy, pandas and pyrel_duckdb β€” on top
* of the database ready() already opened in the worker.
*
* Separate from ready() and never automatic, because it is roughly 100 MB
* and tens of seconds. A page that paid that on open would look broken
* rather than slow, so the caller decides when β€” and gets `onProgress`
* throughout, since silence for that long is indistinguishable from a hang.
*
* It lands in the same worker and on the same connection, so a model built
* here sees data loaded through sql() and vice versa.
*/
async startPyrel({ onProgress: report = onProgress } = {}) {
if (pyrelReady) return pyrelReady;
if (!worker) throw new Error('engine not ready β€” await ready() first');
if (!info?.pyrelAvailable) {
throw new Error(
'the Python front-end is not staged β€” re-run tools/vendor_swan_wasm.mjs with ' +
'pyodide installed and a .venv containing rai-swan',
);
}
pyrelReady = (async () => {
report('starting the PyRel front-end (~100 MB, first time only)…');
const result = await send('python');
info.pyrel = true;
/* Say where questions go, because by default they go somewhere.
*
* Everything else about this engine is "nothing leaves the tab", which
* is the claim the page is built on -- so the one thing that does leave
* has to be visible rather than discovered. The host, not the full URL:
* enough to know where it went, short enough to sit in a header. */
info.note = nlq
? `Swan and PyRel run in the browser; questions go to ${hostOf(nlq)}`
: 'Swan and PyRel both run in the browser';
return result;
})();
return pyrelReady;
},
/** Define the model inside the Worker, from PyRel source. */
loadModel(source) { return send('model', source); },
/**
* Load the staged demo dataset and the model describing it.
*
* Without this the engine has Swan and an empty database β€” a demo that
* cannot demo. Separate from startPyrel() because the two costs are very
* different: the runtime is ~100 MB, the data is ~2 MB, and a caller may
* reasonably want one without the other.
*/
async loadDataset() {
await this.startPyrel();
const result = await send('dataset');
// Reflect it on the info object callers already hold, so a UI that
// rendered "no model" at boot can show the concept count without
// re-reading anything.
if (info) info.model = result.model;
return result;
},
/** Resolves when the rules tier finishes, which is well after loadDataset().
* `{error}` if it could not be evaluated at all. */
rulesReady() { return rulesPromise; },
/**
* Re-read the model and recompute every rule against the data as it stands.
*
* A warm load declares the rules and reuses values a previous session
* computed. This is the way back to computing them, for when those values
* are not trusted. Slow on purpose β€” it is the full rules run.
*
* Note that rulesReady() has long since settled by the time anyone can press
* this, so it does not re-arm; the fresh tally comes back in the reply, and
* `info.rules` is updated for anything reading it later.
*/
async reloadModel() {
const result = await send('reloadModel');
if (info) { info.model = result.model; info.rules = result.rules; }
return result;
},
/**
* Forget the cached database, then stand the worker down.
*
* The caller reloads the page afterwards β€” that is not optional, it is how
* the data gets rebuilt. Everything this worker holds goes with it, which is
* the point: a rebuild in place would have to pull the database out from
* under a live model.
*/
reloadData() { return send('reloadData'); },
async pyrel(code) {
if (!pyrelReady) {
/* Not proxied to the sidecar on the caller's behalf. Quietly forwarding
* would report "wasm" for a query that ran elsewhere, and which engine
* answered is the one thing this engine exists to make visible. */
throw new Error(
'the PyRel front-end is not started β€” call startPyrel() first (it is a ~100 MB download), ' +
'or use the server engine',
);
}
await pyrelReady;
return send('pyrel', code);
},
/**
* Ask in English, with generation done by whatever `?nlq=` points at.
*
* The pipeline is entirely in the browser β€” schema, few-shot selection,
* sandbox, error translation, retries β€” because `pyrel_duckdb.nlq` ships in
* the compiled module. Only the text has to come from somewhere, and
* `answer_question` takes that as an injected function. So this engine is no
* longer barred from natural language by not holding credentials; it is
* barred only by not being told where to send a prompt.
*/
async ask(question) {
if (!nlq) {
throw new Error(
'the wasm engine has no generation endpoint β€” load the page with '
+ '?nlq=<url> to point it at a model, or use the server engine',
);
}
await this.startPyrel();
return labelVia(await send('ask', { question, endpoint: nlq, model: nlqModel, prune: nlqPrune }));
},
/* ---- templates ------------------------------------------------------
*
* Swan's templates are notebooks: prose, then a cell, then the next cell
* using what the last one built. So this is two calls rather than one β€”
* prepare the data and a namespace, then run cells into it β€” and the page
* decides when each cell runs.
*
* Deliberately not part of ask()/pyrel(). Those answer questions about the
* *loaded* model; a template brings its own model, its own data and its own
* solver, and shares nothing with the TPC-DS engine but the Pyodide runtime.
*/
async prepareTemplate({ slug, database, tables, packages }) {
// startPyrel(), not loadDataset(): a template needs the Python front-end
// but none of TPC-DS, and making a visitor wait ~100 MB for data no
// template reads would be the wrong trade entirely.
await this.startPyrel();
return send('templatePrepare', { slug, database, tables, packages });
},
/** One notebook cell, into the namespace prepareTemplate() opened. */
async runTemplateCell(source) {
return send('templateCell', { source });
},
/** The model those cells built, as a three-tier graph the ladder can read.
* `{graph, models}` β€” `graph` is null when the cells built no Model at
* all, which is the honest answer for a template that only queries. */
async templateGraph() {
return send('templateGraph', {});
},
/** Tell the worker this page is done with its session.
*
* The worker cannot detect a closed page β€” a SharedWorker port gives no
* reliable signal, and inferring one from a failed postMessage tore down
* a live tab's namespace. So the page says so itself, and the worker
* bounds the rest. */
async closeTemplate() {
return send('templateClose', {});
},
/** ask() with the conversation so far β€” `[{question, code}]`, held by the
* caller for the same reason the sidecar does not hold it: two tabs must
* not capture each other's context. */
async chat(question, history = []) {
if (!nlq) {
throw new Error(
'the wasm engine has no generation endpoint β€” load the page with ?nlq=<url>',
);
}
await this.startPyrel();
return labelVia(await send('ask', { question, history, endpoint: nlq, model: nlqModel, prune: nlqPrune }));
},
};
}