File size: 15,751 Bytes
1b9ed71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
/* 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();
    },
  };
}