event-horizon / src /engine /engine.js
maxdemarzi's picture
Deploy 4ed0390343adb220188e58f95ab1a8e7e1dd995e (manual: Actions blocked on billing) (part 2)
1b9ed71 verified
Raw
History Blame Contribute Delete
15.8 kB
/* 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=<url>` 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();
},
};
}