chiasmus 0.1.18 → 0.1.21

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.
@@ -1,46 +1,317 @@
1
- import pl from "tau-prolog";
1
+ import { initProlog } from "prolog-wasm-full";
2
2
  const MAX_ANSWERS = 1000;
3
3
  const DEFAULT_MAX_INFERENCES = 100_000;
4
4
  const MAX_TRACE_ENTRIES = 500;
5
- // Tau Prolog is callback-based; these wrap it in promises.
6
- function consult(session, program) {
7
- return new Promise((resolve, reject) => {
8
- session.consult(program, {
9
- success: () => resolve(),
10
- error: (err) => reject(err),
11
- });
12
- });
5
+ // Internal variable for detecting inference-limit overrun. Picked to be
6
+ // unlikely to collide with anything a user program writes.
7
+ const LIMIT_MARKER_VAR = "ChiasmusLimitResult_3F2A1B";
8
+ const LIMIT_EXCEEDED_ATOM = "inference_limit_exceeded";
9
+ const SAFE_PATH_RE = /^[/A-Za-z0-9_.-]+$/;
10
+ // prolog-wasm-full has a single-init lifecycle (Emscripten factory
11
+ // invalidates after first instantiation), so we lazily init exactly
12
+ // one global instance shared across solver calls. Per-solve isolation
13
+ // happens by wrapping every consult in its own SWI module and tearing
14
+ // the module down afterwards — concurrent solves with overlapping
15
+ // predicate names see independent namespaces. The `/tmp/_chiasmus_*.pl`
16
+ // paths look like host filesystem paths but live in Emscripten's
17
+ // in-memory MEMFS — nothing touches the host disk.
18
+ let plPromise = null;
19
+ let pathCounter = 0;
20
+ let sessionCounter = 0;
21
+ function uniqueSessionModule() {
22
+ return `chiasmus_session_${++sessionCounter}`;
13
23
  }
14
- function query(session, goal) {
15
- return new Promise((resolve, reject) => {
16
- session.query(goal, {
17
- success: () => resolve(),
18
- error: (err) => reject(err),
19
- });
20
- });
24
+ async function getPl() {
25
+ plPromise ??= (async () => {
26
+ const pl = await initProlog();
27
+ // The message-capture predicate and hook must be module-qualified to
28
+ // `user:`. SWI invokes message_hook from whichever module is emitting
29
+ // the message (often `system:` for low-level errors), and an unqualified
30
+ // assertz inside the hook body would resolve to the caller's module —
31
+ // failing silently with `Unknown procedure: system:$chiasmus_msg/2` and
32
+ // dropping the error on the floor.
33
+ pl.consult(`
34
+ :- use_module(library(lists)).
35
+ :- use_module(library(clpfd)).
36
+ :- dynamic(user:'$chiasmus_msg'/2).
37
+ :- multifile(user:message_hook/3).
38
+ user:message_hook(_Term, Kind, Lines) :-
39
+ (Kind == error ; Kind == warning),
40
+ with_output_to(string(S),
41
+ print_message_lines(current_output, '', Lines)),
42
+ assertz(user:'$chiasmus_msg'(Kind, S)),
43
+ fail.
44
+ `);
45
+ return pl;
46
+ })();
47
+ return plPromise;
21
48
  }
22
- function nextAnswer(session) {
23
- return new Promise((resolve, reject) => {
24
- session.answer({
25
- success: (ans) => resolve(ans),
26
- fail: () => resolve(null),
27
- error: (err) => reject(err),
28
- limit: () => reject(new Error("inference limit exceeded")),
29
- });
30
- });
49
+ function clearMessages(pl) {
50
+ try {
51
+ pl.stock.call(`retractall(user:'$chiasmus_msg'(_, _))`);
52
+ }
53
+ catch {
54
+ /* best-effort */
55
+ }
56
+ }
57
+ function collectErrorMessages(pl) {
58
+ try {
59
+ const rows = pl.query(`user:'$chiasmus_msg'(error, M)`).all();
60
+ return rows
61
+ .map((r) => (r.M == null ? "" : String(r.M).trim()))
62
+ .filter((s) => s.length > 0);
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ }
68
+ /**
69
+ * Validate a query string by attempting to parse it. Returns null on
70
+ * success, or the syntax-error description on failure.
71
+ *
72
+ * `pl.query()` does not propagate parse errors through the JS layer
73
+ * (they print to stderr but the handle just returns no answers). We
74
+ * pre-parse via `read_term_from_atom/3` so callers see a structured
75
+ * error rather than silent empty results.
76
+ */
77
+ function validateQuery(pl, goal) {
78
+ // Single-quote escape for embedding in Prolog atom syntax.
79
+ const atom = goal.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
80
+ clearMessages(pl);
81
+ const ok = pl.stock.call(`catch(read_term_from_atom('${atom}', _, []), Err, ` +
82
+ `(with_output_to(string(EStr), write(Err)), ` +
83
+ `assertz(user:'$chiasmus_msg'(error, EStr))))`);
84
+ const errs = collectErrorMessages(pl);
85
+ if (errs.length > 0)
86
+ return errs.join("\n");
87
+ if (!ok)
88
+ return "query parse failed";
89
+ return null;
90
+ }
91
+ function uniqueTempPath() {
92
+ return `/tmp/_chiasmus_prolog_${Date.now()}_${pathCounter++}.pl`;
93
+ }
94
+ // ---------------------------------------------------------------------
95
+ // Term rendering — reverse of prolog-wasm-full's stock marshaller.
96
+ //
97
+ // The stock query API returns these JS shapes:
98
+ // atom → string ("knight")
99
+ // integer / float → number
100
+ // true / false → boolean
101
+ // list → array
102
+ // compound foo(...) → { $t: "t", functor: "foo", foo: [[arg1, ...]] }
103
+ // Args are double-wrapped (`foo: [[a, b, c]]`) — outer array has one
104
+ // element, the args tuple. We unwrap and render to Prolog syntax.
105
+ function isAtomBare(s) {
106
+ return /^[a-z][a-zA-Z0-9_]*$/.test(s);
107
+ }
108
+ function termToProlog(value) {
109
+ if (value === null || value === undefined)
110
+ return "_";
111
+ if (typeof value === "boolean")
112
+ return value ? "true" : "false";
113
+ if (typeof value === "number")
114
+ return String(value);
115
+ if (typeof value === "string") {
116
+ if (isAtomBare(value))
117
+ return value;
118
+ return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
119
+ }
120
+ if (Array.isArray(value)) {
121
+ return `[${value.map(termToProlog).join(", ")}]`;
122
+ }
123
+ if (typeof value === "object") {
124
+ const v = value;
125
+ if (v.$t === "t" && typeof v.functor === "string") {
126
+ const fn = v.functor;
127
+ const argsWrap = v[fn];
128
+ const args = Array.isArray(argsWrap) &&
129
+ argsWrap.length === 1 &&
130
+ Array.isArray(argsWrap[0])
131
+ ? argsWrap[0]
132
+ : Array.isArray(argsWrap)
133
+ ? argsWrap
134
+ : [argsWrap];
135
+ if (fn === "-" && args.length === 2) {
136
+ return `${termToProlog(args[0])}-${termToProlog(args[1])}`;
137
+ }
138
+ return `${fn}(${args.map(termToProlog).join(", ")})`;
139
+ }
140
+ return JSON.stringify(v);
141
+ }
142
+ return JSON.stringify(value);
143
+ }
144
+ function bindingsToFormatted(bindings) {
145
+ const entries = Object.entries(bindings);
146
+ if (entries.length === 0)
147
+ return "true";
148
+ return entries.map(([k, v]) => `${k} = ${v}`).join(", ");
149
+ }
150
+ /**
151
+ * Strip a trailing `.` or leading `?-` the user/model sometimes includes
152
+ * around a goal — SWI's stock query API expects a bare goal expression.
153
+ */
154
+ function normalizeQuery(q) {
155
+ let s = q.trim();
156
+ if (s.startsWith("?-"))
157
+ s = s.slice(2).trim();
158
+ if (s.endsWith("."))
159
+ s = s.slice(0, -1).trim();
160
+ return s;
161
+ }
162
+ class CapReachedError extends Error {
163
+ }
164
+ class LimitExceededError extends Error {
165
+ }
166
+ function runQuery(pl, goal, opts = {}) {
167
+ const normalized = normalizeQuery(goal);
168
+ if (!normalized)
169
+ return { error: "empty query" };
170
+ let handle;
171
+ try {
172
+ handle = pl.query(normalized);
173
+ }
174
+ catch (e) {
175
+ return { error: e instanceof Error ? e.message : String(e) };
176
+ }
177
+ const answers = [];
178
+ const cap = opts.maxAnswers ?? MAX_ANSWERS;
179
+ let limitExceeded = false;
180
+ try {
181
+ try {
182
+ handle.forEach((rawBindings) => {
183
+ if (answers.length >= cap)
184
+ throw new CapReachedError();
185
+ if (opts.detectLimitMarker) {
186
+ const marker = rawBindings[LIMIT_MARKER_VAR];
187
+ if (marker === LIMIT_EXCEEDED_ATOM) {
188
+ limitExceeded = true;
189
+ throw new LimitExceededError();
190
+ }
191
+ }
192
+ const bindings = {};
193
+ for (const [k, v] of Object.entries(rawBindings)) {
194
+ if (k === LIMIT_MARKER_VAR)
195
+ continue;
196
+ bindings[k] = termToProlog(v);
197
+ }
198
+ answers.push(bindings);
199
+ });
200
+ }
201
+ finally {
202
+ try {
203
+ handle.close();
204
+ }
205
+ catch {
206
+ /* best-effort */
207
+ }
208
+ }
209
+ }
210
+ catch (e) {
211
+ if (e instanceof LimitExceededError || limitExceeded) {
212
+ return { error: "inference limit exceeded" };
213
+ }
214
+ if (!(e instanceof CapReachedError)) {
215
+ return { error: e instanceof Error ? e.message : String(e) };
216
+ }
217
+ // Cap reached — return what we have.
218
+ }
219
+ return { answers };
31
220
  }
32
221
  /**
33
- * Instrument a Prolog program for derivation tracing.
34
- * Rewrites each rule `head :- body.` to `head :- body, assertz(trace_goal(head)).`
35
- * so trace_goal/1 accumulates which rules fired with bound variables.
36
- * Facts and directives are left unchanged.
222
+ * Tear down a session module: abolish every locally-defined predicate
223
+ * (skipping imports), unload the source file, unlink the MEMFS path.
224
+ * Returns warnings strings naming each step that failed. Cleanup is
225
+ * best-effort: a failure leaks a small amount of state into the global
226
+ * SWI namespace but the user already has their answer, so we surface
227
+ * it on the result rather than masking the solve outcome.
37
228
  */
229
+ function cleanupSession(pl, moduleId, path) {
230
+ const warnings = [];
231
+ // Enumerate user-defined predicates in the module. `imported_from(_)`
232
+ // filter excludes preds pulled in by `use_module(library(...))` — we
233
+ // don't want to abolish library bindings, only the user's clauses.
234
+ let preds = [];
235
+ try {
236
+ preds = pl
237
+ .query(`current_predicate(${moduleId}:F/A), ` +
238
+ `functor(H, F, A), ` +
239
+ `\\+ predicate_property(${moduleId}:H, imported_from(_))`)
240
+ .all();
241
+ }
242
+ catch (e) {
243
+ warnings.push(`enumerate predicates in ${moduleId}: ${e instanceof Error ? e.message : String(e)}`);
244
+ }
245
+ for (const row of preds) {
246
+ const f = row.F;
247
+ const a = row.A;
248
+ if (typeof f !== "string" || typeof a !== "number") {
249
+ warnings.push(`skip non-canonical predicate spec: ${String(f)}/${String(a)}`);
250
+ continue;
251
+ }
252
+ if (!isAtomBare(f)) {
253
+ // Quoted atoms (e.g. internal `$...` preds) — abolish via quoted form.
254
+ try {
255
+ const quoted = `'${f.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
256
+ const ok = pl.stock.call(`abolish(${moduleId}:${quoted}/${a})`);
257
+ if (!ok)
258
+ warnings.push(`abolish ${moduleId}:${quoted}/${a} returned false`);
259
+ }
260
+ catch (e) {
261
+ warnings.push(`abolish ${moduleId}:${f}/${a}: ${e instanceof Error ? e.message : String(e)}`);
262
+ }
263
+ continue;
264
+ }
265
+ try {
266
+ const ok = pl.stock.call(`abolish(${moduleId}:${f}/${a})`);
267
+ if (!ok)
268
+ warnings.push(`abolish ${moduleId}:${f}/${a} returned false`);
269
+ }
270
+ catch (e) {
271
+ warnings.push(`abolish ${moduleId}:${f}/${a}: ${e instanceof Error ? e.message : String(e)}`);
272
+ }
273
+ }
274
+ try {
275
+ pl.stock.call(`unload_file('${path}')`);
276
+ }
277
+ catch (e) {
278
+ warnings.push(`unload_file ${path}: ${e instanceof Error ? e.message : String(e)}`);
279
+ }
280
+ try {
281
+ pl.em.FS.unlink(path);
282
+ }
283
+ catch {
284
+ // File may already be gone (e.g. consult never ran). Not a warning —
285
+ // we explicitly call cleanupSession even on the never-consulted path.
286
+ }
287
+ return warnings;
288
+ }
289
+ /**
290
+ * Best-effort unlink for the case where the program never made it to
291
+ * consult. Failures here are silent — there's nothing actionable for
292
+ * the caller (the file simply may not exist).
293
+ */
294
+ function tryUnlink(pl, path) {
295
+ try {
296
+ pl.em.FS.unlink(path);
297
+ }
298
+ catch {
299
+ /* never written */
300
+ }
301
+ return [];
302
+ }
303
+ // ---------------------------------------------------------------------
304
+ // Tracing instrumentation
305
+ // ---------------------------------------------------------------------
306
+ //
307
+ // Rewrites each rule `head :- body.` to `head :- body, assertz(trace_goal(head)).`
308
+ // so trace_goal/1 accumulates which rules fired with bound variables.
309
+ // Facts and directives are left unchanged.
38
310
  function instrumentForTracing(program) {
39
311
  const parts = [":- dynamic(trace_goal/1).\n\n"];
40
312
  let pos = 0;
41
313
  const len = program.length;
42
314
  while (pos < len) {
43
- // Skip whitespace in bulk
44
315
  const wsStart = pos;
45
316
  while (pos < len && /\s/.test(program[pos]))
46
317
  pos++;
@@ -49,7 +320,6 @@ function instrumentForTracing(program) {
49
320
  }
50
321
  if (pos >= len)
51
322
  break;
52
- // Skip line comments in bulk
53
323
  if (program[pos] === "%") {
54
324
  const nlIdx = program.indexOf("\n", pos);
55
325
  if (nlIdx === -1) {
@@ -60,15 +330,14 @@ function instrumentForTracing(program) {
60
330
  pos = nlIdx + 1;
61
331
  continue;
62
332
  }
63
- // Scan a clause: from pos to the next period at depth 0
64
333
  const clauseStart = pos;
65
334
  let depth = 0;
66
335
  let inQuote = false;
336
+ let emittedClause = false;
67
337
  while (pos < len) {
68
338
  const ch = program[pos];
69
339
  if (inQuote) {
70
340
  if (ch === "\\") {
71
- // Backslash escape: skip the next char (covers \', \\, \n, etc.)
72
341
  pos += 2;
73
342
  continue;
74
343
  }
@@ -107,10 +376,10 @@ function instrumentForTracing(program) {
107
376
  continue;
108
377
  }
109
378
  if (ch === "." && depth === 0) {
110
- // A `.` flanked by digits is a decimal literal (e.g. `3.14`), not
111
- // a clause terminator. In Prolog only whitespace, EOF, or `%`
112
- // comment legitimately follow a clause-ending period, so the
113
- // digit-flanked case is unambiguous.
379
+ // A `.` flanked by digits is a decimal literal, not a clause
380
+ // terminator. In Prolog only whitespace, EOF, or `%` legitimately
381
+ // follows a clause-ending period, so the digit-flanked case is
382
+ // unambiguous.
114
383
  const prevCh = pos > 0 ? program[pos - 1] : "";
115
384
  const nextCh = pos + 1 < len ? program[pos + 1] : "";
116
385
  if (prevCh >= "0" && prevCh <= "9" && nextCh >= "0" && nextCh <= "9") {
@@ -121,22 +390,29 @@ function instrumentForTracing(program) {
121
390
  const clause = program.slice(clauseStart, pos).trim();
122
391
  if (clause.startsWith(":-")) {
123
392
  parts.push(clause + "\n");
124
- break;
125
- }
126
- const neckIdx = findNeck(clause);
127
- if (neckIdx >= 0) {
128
- const head = clause.slice(0, neckIdx).trim();
129
- const body = clause.slice(neckIdx + 2).slice(0, -1).trim();
130
- parts.push(`${head} :- ${body}, assertz(trace_goal(${head})).\n`);
131
393
  }
132
394
  else {
133
- parts.push(clause + "\n");
395
+ const neckIdx = findNeck(clause);
396
+ if (neckIdx >= 0) {
397
+ const head = clause.slice(0, neckIdx).trim();
398
+ const body = clause.slice(neckIdx + 2).slice(0, -1).trim();
399
+ parts.push(`${head} :- ${body}, assertz(trace_goal(${head})).\n`);
400
+ }
401
+ else {
402
+ parts.push(clause + "\n");
403
+ }
134
404
  }
405
+ emittedClause = true;
135
406
  break;
136
407
  }
137
408
  pos++;
138
409
  }
139
- if (pos >= len && program.slice(clauseStart, pos).trim()) {
410
+ // If we exhausted the input without finding a clause-terminator, the
411
+ // tail is a partial clause — push it verbatim so SWI surfaces the
412
+ // syntax error rather than us silently dropping it. Don't push if we
413
+ // already emitted a clause from the inner while: pos can equal len
414
+ // there too (final clause flush against EOF) and we'd double-emit.
415
+ if (!emittedClause && program.slice(clauseStart, pos).trim()) {
140
416
  parts.push(program.slice(clauseStart));
141
417
  }
142
418
  }
@@ -151,7 +427,7 @@ function findNeck(clause) {
151
427
  if (ch === "\\") {
152
428
  i++;
153
429
  continue;
154
- } // backslash escape: skip next char
430
+ }
155
431
  if (ch === "'" && clause[i + 1] === "'") {
156
432
  i++;
157
433
  continue;
@@ -185,16 +461,6 @@ function findNeck(clause) {
185
461
  }
186
462
  return -1;
187
463
  }
188
- function formatError(session, err) {
189
- if (err instanceof Error)
190
- return err.message;
191
- try {
192
- return session.format_answer(err) || String(err);
193
- }
194
- catch {
195
- return String(err);
196
- }
197
- }
198
464
  export function createPrologSolver() {
199
465
  let disposed = false;
200
466
  return {
@@ -206,71 +472,136 @@ export function createPrologSolver() {
206
472
  if (input.type !== "prolog") {
207
473
  return { status: "error", error: "Expected prolog input type" };
208
474
  }
209
- const explain = input.explain ?? false;
210
- const program = explain ? instrumentForTracing(input.program) : input.program;
211
- const inferenceBudget = input.maxInferences ?? DEFAULT_MAX_INFERENCES;
212
- const session = pl.create(inferenceBudget);
475
+ let pl;
213
476
  try {
214
- await consult(session, program);
477
+ pl = await getPl();
215
478
  }
216
479
  catch (e) {
217
- return { status: "error", error: formatError(session, e) };
480
+ return {
481
+ status: "error",
482
+ error: `prolog init failed: ${e instanceof Error ? e.message : String(e)}`,
483
+ };
484
+ }
485
+ const explain = input.explain ?? false;
486
+ const userProgram = explain
487
+ ? instrumentForTracing(input.program)
488
+ : input.program;
489
+ const inferenceBudget = input.maxInferences ?? DEFAULT_MAX_INFERENCES;
490
+ const moduleId = uniqueSessionModule();
491
+ const path = uniqueTempPath();
492
+ if (!SAFE_PATH_RE.test(path)) {
493
+ return {
494
+ status: "error",
495
+ error: `internal: tempfile path failed safety check: ${path}`,
496
+ };
218
497
  }
498
+ // Wrap the user code in a fresh module. `:- module(M, [])` puts
499
+ // every definition in M's namespace; built-ins still resolve via
500
+ // the auto-imported `system` module, and we explicitly import
501
+ // the libraries the user is likely to reach for. This is what
502
+ // gives concurrent solves with overlapping predicate names
503
+ // independent state.
504
+ const program = `:- module(${moduleId}, []).\n` +
505
+ `:- use_module(library(lists)).\n` +
506
+ `:- use_module(library(clpfd)).\n` +
507
+ userProgram;
508
+ let consulted = false;
509
+ const finalize = (result, prefix = []) => {
510
+ const cleanupWarnings = consulted
511
+ ? cleanupSession(pl, moduleId, path)
512
+ : tryUnlink(pl, path);
513
+ const warnings = [...prefix, ...cleanupWarnings];
514
+ if (warnings.length === 0)
515
+ return result;
516
+ if (result.status === "success") {
517
+ return { ...result, warnings };
518
+ }
519
+ if (result.status === "error") {
520
+ return { ...result, warnings };
521
+ }
522
+ return result;
523
+ };
219
524
  try {
220
- await query(session, input.query);
525
+ pl.em.FS.writeFile(path, program);
221
526
  }
222
527
  catch (e) {
223
- return { status: "error", error: formatError(session, e) };
528
+ return finalize({
529
+ status: "error",
530
+ error: `failed to stage program: ${e instanceof Error ? e.message : String(e)}`,
531
+ });
224
532
  }
225
- const answers = [];
533
+ clearMessages(pl);
226
534
  try {
227
- for (let i = 0; i < MAX_ANSWERS; i++) {
228
- const ans = await nextAnswer(session);
229
- if (ans === null)
230
- break;
231
- const bindings = {};
232
- const links = ans.links;
233
- if (links) {
234
- for (const [name, term] of Object.entries(links)) {
235
- bindings[name] = term.toString?.()
236
- ?? term.id
237
- ?? String(term);
238
- }
239
- }
240
- const formatted = pl.format_answer(ans) ?? "";
241
- answers.push({ bindings, formatted });
242
- }
535
+ pl.stock.call(`consult('${path}')`);
536
+ consulted = true;
243
537
  }
244
538
  catch (e) {
245
- return { status: "error", error: formatError(session, e) };
246
- }
247
- // Collect derivation trace if explain mode is on
248
- if (explain) {
249
- try {
250
- await query(session, "trace_goal(X).");
251
- const trace = [];
252
- const seen = new Set();
253
- for (let i = 0; i < MAX_TRACE_ENTRIES; i++) {
254
- const t = await nextAnswer(session);
255
- if (t === null)
256
- break;
257
- const links = t.links;
258
- if (links?.X) {
259
- const entry = links.X.toString?.() ?? String(links.X);
260
- if (!seen.has(entry)) {
261
- seen.add(entry);
262
- trace.push(entry);
263
- }
264
- }
265
- }
266
- return { status: "success", answers, trace };
267
- }
268
- catch {
269
- // Trace collection failed return answers without trace
270
- return { status: "success", answers };
539
+ return finalize({
540
+ status: "error",
541
+ error: `consult failed: ${e instanceof Error ? e.message : String(e)}`,
542
+ });
543
+ }
544
+ const consultErrs = collectErrorMessages(pl);
545
+ if (consultErrs.length > 0) {
546
+ return finalize({ status: "error", error: consultErrs.join("\n") });
547
+ }
548
+ const normalized = normalizeQuery(input.query);
549
+ if (!normalized) {
550
+ return finalize({ status: "error", error: "empty query" });
551
+ }
552
+ const parseErr = validateQuery(pl, normalized);
553
+ if (parseErr) {
554
+ return finalize({ status: "error", error: parseErr });
555
+ }
556
+ // Wrap the user goal in three concentric layers (innermost first):
557
+ // 1. `${moduleId}:Goal` — resolve predicates against the session
558
+ // module, so the user's `parent(...)` finds their own clauses.
559
+ // 2. `call_with_inference_limit(..., Budget, Marker)` — bound
560
+ // runaway labelings; Marker binds to LIMIT_EXCEEDED_ATOM on
561
+ // overrun.
562
+ // 3. `catch(..., Err, recovery)` — turn existence_error and
563
+ // friends into structured solver errors instead of letting
564
+ // SWI print them and the JS handle silently return zero
565
+ // answers. Recovery asserts the error term to our shared
566
+ // message buffer and fails so the iterator terminates.
567
+ const wrapped = `catch(` +
568
+ `call_with_inference_limit((${moduleId}:(${normalized})), ${inferenceBudget}, ${LIMIT_MARKER_VAR}), ` +
569
+ `Err, ` +
570
+ `(with_output_to(string(EStr), write(Err)), ` +
571
+ `assertz(user:'$chiasmus_msg'(error, EStr)), fail))`;
572
+ clearMessages(pl);
573
+ const queryResult = runQuery(pl, wrapped, { detectLimitMarker: true });
574
+ const queryErrs = collectErrorMessages(pl);
575
+ if (queryErrs.length > 0) {
576
+ return finalize({ status: "error", error: queryErrs.join("\n") });
577
+ }
578
+ if ("error" in queryResult) {
579
+ return finalize({ status: "error", error: queryResult.error });
580
+ }
581
+ const answers = queryResult.answers.map((bindings) => ({
582
+ bindings,
583
+ formatted: bindingsToFormatted(bindings),
584
+ }));
585
+ if (!explain) {
586
+ return finalize({ status: "success", answers });
587
+ }
588
+ // Collect derivation trace from the session-local trace_goal/1.
589
+ const traceResult = runQuery(pl, `${moduleId}:trace_goal(X)`, {
590
+ maxAnswers: MAX_TRACE_ENTRIES,
591
+ });
592
+ if ("error" in traceResult) {
593
+ return finalize({ status: "success", answers });
594
+ }
595
+ const trace = [];
596
+ const seen = new Set();
597
+ for (const row of traceResult.answers) {
598
+ const entry = row.X;
599
+ if (typeof entry === "string" && !seen.has(entry)) {
600
+ seen.add(entry);
601
+ trace.push(entry);
271
602
  }
272
603
  }
273
- return { status: "success", answers };
604
+ return finalize({ status: "success", answers, trace });
274
605
  },
275
606
  dispose() {
276
607
  if (!disposed) {