Skip to content

🚧 fix: bridge scoped-builtin sub-reasoner calls to nested WASM instances - #1956

Draft
jeswr wants to merge 1 commit into
mainfrom
fix/wasm-scoped-builtins-bridge
Draft

🚧 fix: bridge scoped-builtin sub-reasoner calls to nested WASM instances#1956
jeswr wants to merge 1 commit into
mainfrom
fix/wasm-scoped-builtins-bridge

Conversation

@jeswr

@jeswr jeswr commented Jul 4, 2026

Copy link
Copy Markdown
Member

🚧 Draft. This PR was generated by an AI agent (Claude Fable 5) working for @jeswr; please review before considering it for merge.

Closes #873

What

All graph-literal-scoped builtins failed in eye-js because they are evaluated through one helper in eye.pl, exec/2, which spawns a fresh eye process (shell/2) to compute the deductive closure of the scope graph in isolation — and there is no shell under Emscripten. This PR bridges those sub-reasoner invocations to fresh swipl-wasm module instances instead, following the design in this investigation comment on #873.

Covered builtins (everything that funnels through exec/2):
log:collectAllIn, log:forAllIn, log:ifThenElseIn, log:conclusion, log:satisfiable, e:call, e:fail, e:findall. The ninth exec/2 caller, e:exec (arbitrary shell commands), stays unsupported and now fails with a descriptive error naming this issue instead of shell/2: Function not implemented.

On the exact repro from #873, n3reasoner now returns (:x) a :Result. — identical to native EYE.

How

  1. Build-time shim (scripts/generate-pvm.ts): the fetched eye.pl gets an emscripten-conditional snippet appended before qsave_program that redefines exec/2 to yield the command line to the host via await/2 — the same mechanism eye.pl already uses for log:ask (userInput/2). Since the image is generated by running the source inside swipl-wasm, the conditional is live at consult time and the redefinition is compiled into the saved state; native builds are untouched. lingua.pl defines no exec/2 and is left as-is.
  2. Host-side driver (lib/bridge.ts, new): bridgeCallback(module, { spawn, cb }) produces a callback for the existing async yield loop (qaQuery). On an eyejs:exec yield it parses the command line (eye --nope [--quiet] Data [--query Query | --pass-all] > Out), boots a fresh swipl-wasm module with the same image — the isolation analogue of the process spawn, preserving the scoped semantics (the sub-run must see only the scope graph, and EYE's KB/flags/globals are instance-wide) — copies the temp files across the two MEMFS instances, runs the sub-query, writes the captured stdout to the redirect target in the requesting instance's FS, and resumes with "ok". Failures surface as the recorded exit code (eye.pl's main/1 traps halt/1 into the exit_code global), so e.g. an inference fuse in the sub-run makes exec/2 raise exactly like a non-zero process exit does natively — which is how log:satisfiable false works.
  3. Recursion: the child module is driven by the same bridge, so a scoped builtin nested inside a scope graph boots its own grandchild instance (covered by a test).
  4. Double-invocation cache: EYE issues each scope's sub-reasoner call twice per evaluation with alpha-equivalent inputs (same content, fresh temp file names and fresh ?_N variable numbering from a process-wide counter). The bridge caches sub-runs keyed on argument structure + file contents with variable numbering canonicalised by order of first appearance, so the second invocation is free — native EYE pays two process spawns here, the WASM build now pays one module boot (~85ms).
  5. Wiring: executeBasicEyeQuery (i.e. n3reasoner/linguareasoner) now always drives the reasoner through the async yield loop with the bridge installed, and the CLI wraps its stdin log:ask callback in the bridge too. A user-supplied cb keeps receiving all non-exec yields unchanged.

Compatibility & perf

  • Public API unchanged. runQuery's synchronous cb-less path is kept (and now has an explicit test); only the internal invocation path of executeBasicEyeQuery moved from queryOnce to the qaQuery loop.
  • No measurable overhead for yield-free queries: the async call machinery only engages on actual yields. Median over 21 runs of the socrates example on the same warm image: sync queryOnce 4.4ms vs async loop 3.3ms — indistinguishable within scheduler noise. Node memory leak tests (test:memory:node, test:memory:node:error) pass unchanged.
  • Queries yielding log:ask questions without a cb option now reject with The reasoner yielded a question [...] but no cb option was provided to answer it; on main they rejected too, with ** ERROR ** eam ** error(permission_error(run,goal,await(...))).
  • A pre-commit-style caveat remains for synchronous direct users of queryOnce: scoped builtins can only be bridged in the async path, so queryOnce(module, 'main', args) on data with scoped builtins keeps failing (as today, but now with the exec_error of an unanswerable yield rather than the shell/2 error).

Tests

New cases in universalTests (run in node and jsdom, all green; full local run: 115 passed, 2 pre-existing skips; coverage thresholds met, lib/bridge.ts at 100%):

  • the log:collectAllIn error #873 repro asserting (:x) a :Result.
  • log:conclusion with log:includes over the closure
  • a scoped builtin nested inside a scope graph (recursive bridging)
  • log:satisfiable incl. an unsatisfiable scope (sub-run inference fuse → false)
  • e:exec rejection with the descriptive error (plus malformed exec command lines)
  • cache: the two identical sub-invocations of one evaluation boot a single module
  • bridge error reply when the sub-reasoner cannot be spawned
  • the synchronous cb-less runQuery path
  • ask queries without cb reject with the descriptive message

Follow-up / upstream ask

  • @josd: would you take a small upstream patch wrapping exec/2 in the same emscripten conditional as userInput/2, e.g. exec(A, 0) :- await(exec(A), _) — ideally yielding a structured term (args + output path) rather than a shell string? That would make the host contract explicit for any WASM embedder and let eye-js drop the build-time append (the host side here already anticipates it). Filed from the analysis in the #873 investigation.
  • If eye.pl ever changes the exec(A, B) :- helper shape, withWasmExecBridge appends nothing and the scoped-builtin tests fail loudly at the next pvm regeneration, which is the intended tripwire.

🤖 Generated with Claude Code

The graph-scoped builtins (log:collectAllIn, log:forAllIn,
log:ifThenElseIn, log:conclusion, log:satisfiable, e:call, e:fail,
e:findall) evaluate their scope graph by spawning a fresh eye process
through exec/2 -> shell/2, which is unavailable under Emscripten, so
they all failed in eye-js.

Generate the pvm with an emscripten-conditional redefinition of exec/2
that yields the sub-reasoner command line to the JavaScript host with
await/2 (the mechanism already used for log:ask), and answer those
yields by booting a fresh swipl-wasm module with the same image -
the isolation analogue of the process spawn. The bridge copies the
temporary input files into the fresh module, runs the sub-query,
writes the captured stdout to the redirect target of the requesting
instance, and reports the recorded exit code. Sub-reasoners get the
same driver recursively, so scoped builtins nested inside scope graphs
work too, and the two identical invocations EYE issues per scope
evaluation are answered from a content-keyed cache.

e:exec (arbitrary shell commands) remains unsupported and now fails
with a descriptive error.

Closes #873

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

log:collectAllIn error

1 participant