/* The Swan engine interface, and the runtime choice between backends. * * Event Horizon asks Swan two things: "run this PyRel" and "turn this question * into PyRel and run it". Where that happens is a deployment detail: * * wasm Pyodide + DuckDB + swan.duckdb_extension, in the tab. Swan itself * loads and runs (verified: tools/verify_pyrel_wasm.mjs), but PyRel * compilation is Python, so this backend serves SQL only for now. * Staged by tools/vendor_swan_wasm.mjs. * server tools/swan_sidecar.py behind server.js's /api proxy, running the * native extension from the rai-swan wheel. Works today. * * Both satisfy the same interface, so nothing above this module knows which one * answered: * * await engine.ready() -> { engine, swan, duckdb, model, nl } * await engine.schema() -> { schema, model } * await engine.pyrel(code) -> { code, rows } * await engine.ask(question) -> { question, code, rows, attempts, examples } * await engine.chat(q, history) -> the same, with prior turns as context * * `auto` prefers wasm and falls back to server, reporting *why* it fell back * rather than silently degrading — a browser-only Swan and a proxied one have * very different privacy and latency characteristics, and which one you got is * something the page shows the user. */ import { createServerEngine } from './server_engine.js'; import { createWasmEngine } from './wasm_engine.js'; const BACKENDS = { wasm: createWasmEngine, server: createServerEngine }; /** Engine order for `auto`. wasm first: it needs no server and no proxy hop. */ const AUTO_ORDER = ['wasm', 'server']; export function engineModeFromLocation(search = (typeof location !== 'undefined' ? location.search : '')) { const mode = new URLSearchParams(search).get('engine'); return mode && mode in BACKENDS ? mode : (mode === 'auto' ? 'auto' : 'auto'); } /* The project's own hosted generator, used when a page says nothing else. * * A default is a decision about where a visitor's question travels, so it is a * named public endpoint this project runs rather than a guess at localhost — and * the page says where questions go, in `note` below, rather than leaving it to * be discovered. `?nlq=off` opts out and `?nlq=` points elsewhere. * * Everything except producing text already runs in the browser: schema * rendering, few-shot selection, the sandbox, error translation and retries are * all in the compiled `pyrel_duckdb.nlq`. Only the prompt leaves. */ export const DEFAULT_NLQ = [ /* Unquantized weights on a shared GPU, ~2s a question — reached through a * proxy rather than directly, and the indirection is the whole point. * * ZeroGPU bills GPU time to a Hugging Face account, and the daily allowance * depends on which one: 2 minutes for an unauthenticated caller, 40 for PRO, * with the highest queue priority. A request from a visitor's tab names no * account, so it draws on that 2 minutes — which a public page exhausts almost * immediately, and every call after that is refused in ~0.4s with * `event: error` and a null body. No status code, no message, and nothing in * the Space's own log, so an exhausted quota and a hard block look exactly * alike from here. black-swan-proxy holds a token and lends the account. * * Pointing at the GPU Space directly instead would spend that 2 minutes and * then be a guaranteed half-second of nothing before every question. */ 'https://maxdemarzi-black-swan-proxy.hf.space/v1/chat/completions', /* The same model quantized, on CPU, unmetered. The proxy spends a finite * daily quota; when it runs out this is what answers, which is why the chain * outlived the problem that created it. Slower — though 14s on a pruned * schema, not the ~110s a full one costs. */ 'https://maxdemarzi-black-swan-pyrel.hf.space/v1/chat/completions', ]; export const DEFAULT_NLQ_MODEL = 'maxdemarzi/black-swan-lora'; /** * Where the browser engine sends a prompt. * * (nothing) the hosted generator above * ?nlq=off no generation; PyRel still works, `nl: false` * ?nlq=http://host/... somewhere else */ export function nlqEndpointFromLocation(search = (typeof location !== 'undefined' ? location.search : '')) { const raw = new URLSearchParams(search).get('nlq'); if (raw === null) return DEFAULT_NLQ; if (/^(off|none|0|false)$/i.test(raw)) return null; // Comma-separated, so a caller can supply their own chain rather than being // stuck with one host. const urls = raw.split(',').map((u) => u.trim()).filter(Boolean).map((u) => { try { // Reject anything that is not an absolute http(s) URL rather than letting // a typo become a request to this origin's own 404 page, parsed as a model. const parsed = new URL(u, typeof location !== 'undefined' ? location.href : undefined); return /^https?:$/.test(parsed.protocol) ? parsed.href : null; } catch { return null; } }).filter(Boolean); return urls.length ? urls : null; } /** * Whether to send only the concepts a question needs. `?prune=0` turns it off. * * On by default because the alternative is measurably broken: TPC-DS renders to * 8,203 prompt tokens whole, against the 317-640 the adapter was trained on, and * the attention that costs peaks at 11 GiB on a 12 GiB card — which then spills * to system memory and takes ~150s instead of ~4s. Pruning brings it to ~1,850. * A retry escalates to the full schema, so a question the pruner misjudges costs * an extra attempt rather than an answer, which is what makes a default safe. */ export function nlqPruneFromLocation(search = (typeof location !== 'undefined' ? location.search : '')) { const v = new URLSearchParams(search).get('prune'); return !(v === '0' || v === 'false' || v === 'off'); } /** * Which model to name in the request. `?nlqModel=black-swan-sft15-q4:1.5b` * * Not optional for every server, which is why it is here rather than assumed: a * host serving one fine-tune usually ignores the field, but ollama's * OpenAI-compatible endpoint rejects a request without it. Named `nlqModel` * rather than `model` because this page already has a *data* model and a `graph` * parameter, and one of those confusions is enough. */ export function nlqModelFromLocation(search = (typeof location !== 'undefined' ? location.search : '')) { const params = new URLSearchParams(search); const named = params.get('nlqModel'); if (named) return named; // Only default the model name alongside the default endpoint. Sending our // model's name to someone else's server is at best ignored and at worst a 404 // naming a model they have never heard of. return params.get('nlq') === null ? DEFAULT_NLQ_MODEL : null; } /** * @param {object} opts * @param {'auto'|'wasm'|'server'} opts.mode * @param {(msg: string) => void} [opts.onProgress] surfaced in the UI while a * backend boots — the wasm one downloads tens of megabytes, so silence * for that long reads as a hang. */ export function createEngine({ mode = 'auto', onProgress = () => {}, nlq = null, nlqPrune = true, nlqModel = null } = {}) { const attempts = []; let chosen = null; let readyPromise = null; async function boot() { const order = mode === 'auto' ? AUTO_ORDER : [mode]; /* An engine that booted but cannot compile PyRel *yet*. Held back in case a * later one can answer immediately, and selected if none can — see the end * of this function for why "none can" is the normal case, not the odd one. */ let deferred = null; for (const name of order) { onProgress(`starting ${name} engine…`); // `nlq` means something only to the wasm backend; the sidecar holds its // own generator. Passed to both rather than special-cased, so adding a // third backend does not need this line changed. const candidate = BACKENDS[name]({ onProgress, nlq, nlqPrune, nlqModel }); try { const info = await candidate.ready(); /* Booting is not the same as being able to answer. The wasm engine loads * Swan and runs SQL, but has no PyRel front-end — compiling PyRel needs * pyrel_duckdb, which is Python. `auto` exists to find an engine that can * serve this page, so an engine that cannot compile PyRel is passed over * with a reason rather than selected and then failing on first use. * `?engine=wasm` still reaches it, for SQL. */ if (mode === 'auto' && info.pyrel === false && info.pyrelAvailable) { const note = { engine: name, error: 'loaded, but has no PyRel front-end yet (needs Pyodide + pyrel_duckdb)', }; attempts.push(note); deferred ||= { candidate, info: { ...info, engine: name }, note }; onProgress(`${name} engine cannot compile PyRel — trying the next`); continue; } chosen = candidate; return { ...info, engine: name, attempts }; } catch (err) { attempts.push({ engine: name, error: String(err && err.message || err) }); // An explicitly requested backend that fails is an error, not a cue to // try something else — the caller asked for that one specifically. if (mode !== 'auto') throw err; onProgress(`${name} engine unavailable — ${attempts[attempts.length - 1].error}`); } } /* Nothing could answer straight away, so take the one that can answer once * it is asked to. This is the ordinary case wherever there is no sidecar — * a static host, or anyone who cloned the repo and opened a page without * starting a second process — and reporting "no Swan engine available" for * it was wrong: the engine is right there, it just wanted a hundred * megabytes of Python first, which is a question for the user rather than * grounds for refusing to run. * * Its own passed-over note is dropped on the way out. It is what the page * ends up running, and listing it under "engine unavailable" would describe * the thing that is working. A genuinely failed backend keeps its note. */ if (deferred) { const i = attempts.indexOf(deferred.note); if (i >= 0) attempts.splice(i, 1); chosen = deferred.candidate; return { ...deferred.info, attempts }; } const why = attempts.map((a) => `${a.engine}: ${a.error}`).join('; '); throw new Error(`no Swan engine available (${why})`); } function require() { if (!chosen) throw new Error('engine not ready — await ready() first'); return chosen; } return { /** Idempotent: repeated calls share one boot, so two callers can't race two backends. */ ready() { return (readyPromise ||= boot()); }, get name() { return chosen ? chosen.name : null; }, attempts, schema() { return require().schema(); }, pyrel(code) { return require().pyrel(code); }, ask(question) { return require().ask(question); }, chat(question, history) { const backend = require(); if (!backend.chat) throw new Error(`the ${backend.name} engine has no conversational endpoint`); return backend.chat(question, history); }, /* Backend-specific capabilities, forwarded rather than assumed. * * These exist only on the wasm engine. Leaving them off the façade meant * `engine.sql(...)` was `undefined` even when the backend implemented it — * invisible to every static check, because the façade is an object literal * and nothing cross-references it against the backends. A browser session * calling sql() is what surfaced it. */ sql(text) { const backend = require(); if (!backend.sql) throw new Error(`the ${backend.name} engine does not execute raw SQL`); return backend.sql(text); }, startPyrel(opts) { const backend = require(); if (!backend.startPyrel) { throw new Error(`the ${backend.name} engine has no separate PyRel front-end to start`); } return backend.startPyrel(opts); }, loadModel(source) { const backend = require(); if (!backend.loadModel) throw new Error(`the ${backend.name} engine cannot load a model in-process`); return backend.loadModel(source); }, loadDataset() { const backend = require(); if (!backend.loadDataset) { throw new Error(`the ${backend.name} engine already has its data — loadDataset is for the browser`); } return backend.loadDataset(); }, /* Templates, forwarded for exactly the reason the comment above sql() gives * — and it caught me anyway. Both were implemented on the wasm backend and * verified there, the worker contract check passed, and the page still met * `engine.prepareTemplate is not a function`, because the façade is an * object literal that nothing cross-references against the backends. */ prepareTemplate(spec) { const backend = require(); if (!backend.prepareTemplate) { throw new Error(`the ${backend.name} engine cannot run templates — they need the browser engine`); } return backend.prepareTemplate(spec); }, runTemplateCell(source) { const backend = require(); if (!backend.runTemplateCell) { throw new Error(`the ${backend.name} engine cannot run templates — they need the browser engine`); } return backend.runTemplateCell(source); }, templateGraph() { const backend = require(); if (!backend.templateGraph) { throw new Error(`the ${backend.name} engine cannot read a template's model — that needs the browser engine`); } return backend.templateGraph(); }, /* Best-effort and fire-and-forget: this runs while the page is going away, * so an engine that cannot do it is not worth an error nobody will see. */ closeTemplate() { const backend = require(); return backend.closeTemplate ? backend.closeTemplate() : Promise.resolve(null); }, /* The rules tier lands well after the dataset does — see the comment on * loadDataset in pyrel-worker.js. Engines that have no separate rules phase * (the sidecar evaluated them at startup) report so immediately, so a caller * can await this unconditionally. */ rulesReady() { const backend = require(); return backend.rulesReady ? backend.rulesReady() : Promise.resolve(null); }, /* The two escape hatches from the cache, forwarded for the same reason as * sql() above: a capability the façade does not name is a capability no * caller can reach, and nothing static would notice. Both are browser-only — * the sidecar has no cache to be stale. */ reloadModel() { const backend = require(); if (!backend.reloadModel) { throw new Error(`the ${backend.name} engine evaluates its rules at startup — there is nothing cached to rebuild`); } return backend.reloadModel(); }, reloadData() { const backend = require(); if (!backend.reloadData) { throw new Error(`the ${backend.name} engine reads its data directly — there is no cached copy to discard`); } return backend.reloadData(); }, }; }