Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion __test_utils__/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import type { Quad } from '@rdfjs/types';
import 'jest-rdf';
import { DataFactory, Parser, Store } from 'n3';
import { data, dataSplit, dataStar, query, queryAll, result, trig as socratesTrig } from '../data/socrates';
import { n3reasoner, linguareasoner } from '../dist';
import { n3reasoner, linguareasoner, runQuery, SwiplEye, bridgeCallback, qaQuery } from '../dist';
import {
collectAllData, collectAllResult,
conclusionData, conclusionResult,
nestedScopeData, nestedScopeResult,
satisfiableData, satisfiableResult,
execData,
} from '../data/scoped';
import { data as blogicData, result as blogicResult } from '../data/blogic';
import { data as regexData, result as regexResult } from '../data/regex';
import { askCallback, askQuery, askResult } from '../data/ask';
Expand Down Expand Up @@ -321,4 +328,67 @@ export function universalTests() {
await expect(n3reasoner(surfaceSocratesQuery, undefined, { output: 'none' , outputType: 'quads' })).resolves.toBeRdfIsomorphic(surfaceSocratesResultQuads);
});
});

// The graph-scoped builtins are evaluated by running a fresh sub-reasoner
// over the scope graph (https://github.com/eyereasoner/eye-js/issues/873)
describe('testing graph-scoped builtins', () => {
it('should execute log:collectAllIn on an explicit scope graph (#873)', () => expect(
n3reasoner(collectAllData, undefined, { outputType: 'string' }),
).resolves.toContain(collectAllResult), 90_000);

it('should execute log:conclusion', () => expect(
n3reasoner(conclusionData, undefined, { outputType: 'string' }),
).resolves.toContain(conclusionResult), 90_000);

it('should execute a scoped builtin nested inside a scope graph', () => expect(
n3reasoner(nestedScopeData, undefined, { outputType: 'string' }),
).resolves.toContain(nestedScopeResult), 90_000);

it('should execute log:satisfiable, including on unsatisfiable scopes', () => expect(
n3reasoner(satisfiableData, undefined, { outputType: 'string' }),
).resolves.toContain(satisfiableResult), 90_000);

it('should reject e:exec with a descriptive error', () => expect(
n3reasoner(execData('echo hello'), undefined, { outputType: 'string' }),
).rejects.toThrow(/not supported in the WASM build/), 90_000);

it('should reject exec requests without an output redirect', () => expect(
n3reasoner(execData('eye missing-redirect.n3s'), undefined, { outputType: 'string' }),
).rejects.toThrow(/not supported in the WASM build/), 90_000);

it('should reject exec requests without an output redirect target', () => expect(
n3reasoner(execData('eye missing-target.n3s >'), undefined, { outputType: 'string' }),
).rejects.toThrow(/not supported in the WASM build/), 90_000);

it('should reject question yields when no cb option is provided', () => expect(
n3reasoner([], askQuery),
).rejects.toThrow(/no cb option was provided/), 90_000);

it('should cache the two identical sub-reasoner invocations of a scope evaluation', async () => {
let res = '';
let spawns = 0;
const module = await SwiplEye({ print: (str: string) => { res += `${str}\n`; }, printErr: () => {} });
const spawn = (opts?: Parameters<typeof SwiplEye>[0]) => { spawns += 1; return SwiplEye(opts); };
module.FS.writeFile('data_0.n3s', collectAllData);
await qaQuery(module, 'main', ['--nope', '--quiet', 'data_0.n3s', '--pass-only-new'], bridgeCallback(module, { spawn }));
expect(res).toContain(collectAllResult);
expect(spawns).toBe(1);
}, 90_000);

it('should answer with an error when the sub-reasoner cannot be spawned', async () => {
const module = await SwiplEye({ print: () => {}, printErr: () => {} });
module.FS.writeFile('scope.n3s', '');
const cb = bridgeCallback(module, { spawn: () => Promise.reject(new Error('spawning disabled')) });
await expect(cb('eyejs:exec eye --nope --quiet scope.n3s > out.n3s')).resolves.toMatch(/sub-reasoner run failed.*spawning disabled/);
}, 90_000);
});

describe('testing runQuery', () => {
it('should keep the synchronous cb-less path working', async () => {
let res = '';
const module = await SwiplEye({ print: (str: string) => { res += `${str}\n`; }, printErr: () => {} });
expect(runQuery(module, [data], undefined)).toBe(module);
expect(res).toContain(':Socrates a :Mortal.');
}, 90_000);
});
}
52 changes: 52 additions & 0 deletions data/scoped.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Fixtures for the graph-scoped builtins, which are evaluated by running a
// sub-reasoner over the scope graph (https://github.com/eyereasoner/eye-js/issues/873)
const prefixes = `
@prefix log: <http://www.w3.org/2000/10/swap/log#> .
@prefix : <http://example.org/> .
`;

// The reproduction from https://github.com/eyereasoner/eye-js/issues/873
export const collectAllData = `${prefixes}
{ (?x { ?x a :Test } ?xs) log:collectAllIn { :x a :Test } } => { ?xs a :Result }.
`;
export const collectAllResult = '(:x) a :Result.';

export const conclusionData = `${prefixes}
{
{ :alice :likes :bob. { :alice :likes :bob } => { :bob :likes :alice } } log:conclusion ?closure.
?closure log:includes { :bob :likes :alice }.
} => { :test a :Success }.
`;
export const conclusionResult = ':test a :Success.';

// A scoped builtin nested inside a scope graph: computing the deductive
// closure of the outer scope requires a sub-reasoner that itself needs a
// sub-sub-reasoner for the inner log:collectAllIn
export const nestedScopeData = `${prefixes}
{
(?x { ?x a :Outer } ?xs) log:collectAllIn {
:seed a :Seed.
{ (?y { ?y a :Seed } ?ys) log:collectAllIn { :seed a :Seed } } => { :found a :Outer }.
}
} => { ?xs a :Result }.
`;
export const nestedScopeResult = '(:found) a :Result.';

// The unsatisfiable scope makes the sub-reasoner exit with an inference fuse,
// which the bridge reports back as a failing exec/2 - so log:satisfiable
// resolves to false, exactly like the non-zero process exit in native EYE
export const satisfiableData = `${prefixes}
{
{ :a :b :c } log:satisfiable true.
{ :d :e :f. { :d :e :f } => false } log:satisfiable false.
} => { :test a :Success }.
`;
export const satisfiableResult = ':test a :Success.';

// e:exec runs arbitrary shell commands in native EYE and stays unsupported in
// the WASM build; the commands below also exercise the rejection of anything
// that is not a well-formed eye sub-reasoner command line
export const execData = (command: string) => `${prefixes}
@prefix e: <http://eulersharp.sourceforge.net/2003/03swap/log-rules#> .
{ "${command}" e:exec ?exit } => { :test a :Failure }.
`;
9 changes: 8 additions & 1 deletion lib/bin/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path, { PlatformPath } from 'path';
import fs from 'fs';
import readline from 'readline';
import { SwiplEye } from '..';
import { bridgeCallback } from '../bridge';
import { qaQuery } from '../query';

export function convertToPosixPath(filePath: string, pathLib: PlatformPath = path): string {
Expand Down Expand Up @@ -46,6 +47,12 @@ export async function mainFunc(proc: NodeJS.Process) {
}
}

await qaQuery(Module, 'main', posixArgv, (q) => rl.question(`${q}\n|: `));
// The bridge answers the sub-reasoner requests of graph-scoped builtins
// (https://github.com/eyereasoner/eye-js/issues/873); remaining yields are
// log:ask questions answered over stdin
await qaQuery(Module, 'main', posixArgv, bridgeCallback(Module, {
spawn: SwiplEye,
cb: (q) => rl.question(`${q}\n|: `),
}));
rl.close();
}
184 changes: 184 additions & 0 deletions lib/bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Host-side driver for the sub-reasoner invocations of EYE's graph-scoped
// builtins (log:collectAllIn, log:forAllIn, log:ifThenElseIn, log:conclusion,
// log:satisfiable, e:call, e:fail and e:findall).
//
// Native EYE evaluates these builtins by spawning a fresh `eye` process
// (exec/2 -> shell/2) that computes the deductive closure of the scope graph
// in isolation. Under Emscripten there is no shell, so the pvm generated by
// scripts/generate-pvm.ts redefines exec/2 to yield the command line to the
// JavaScript host with await/2 instead. The callback produced by
// bridgeCallback/2 answers those yields by booting a fresh swipl-wasm module
// with the same reasoner image (the isolation analogue of the process spawn),
// copying the temporary input files across, running the sub-query, and
// writing the captured stdout to the redirect target of the requesting
// instance. See https://github.com/eyereasoner/eye-js/issues/873.
import type { SWIPLModule } from 'swipl-wasm';
import { qaQuery } from './query';

/** Prefix of the requests yielded by the exec/2 bridge in the generated pvm */
export const EXEC_REQUEST_PREFIX = 'eyejs:exec ';

/** Boots a fresh SWIPL module preloaded with a reasoner image */
export type SwiplLoader = (options?: Partial<EmscriptenModule>) => Promise<SWIPLModule>;

export interface BridgeOptions {
/**
* Loader used to boot the fresh sub-reasoner modules; it should load the
* same image as the module the callback is attached to
*/
spawn: SwiplLoader;
/** Callback answering the remaining (non-exec) yields, i.e. log:ask questions */
cb?: (res: string) => Promise<string>;
}

interface ExecRequest {
/** The argv of the sub-reasoner, without the leading program name */
args: string[];
/** The file that the command line redirects stdout to */
outputFile: string;
}

interface SubRunResult {
/** Whether the sub-reasoner completed with exit code 0 */
ok: boolean;
stdout: string;
stderr: string;
}

/**
* Parses the command line that exec/2 was asked to run. eye.pl builds the
* sub-reasoner command lines as
* `eye --nope [--quiet] <TmpData> --query <TmpQuery> > <TmpOut>` or
* `eye --nope [--quiet] <TmpData> --pass-all > <TmpOut> 2> /dev/null`;
* anything else (in particular the arbitrary shell commands of e:exec) is not
* supported in the WASM build.
*/
function parseExecRequest(command: string): ExecRequest | undefined {
const tokens = command.trim().split(/\s+/);
const redirect = tokens.indexOf('>');
if (tokens[0] !== 'eye' || redirect === -1 || !tokens[redirect + 1]) {
return undefined;
}
return { args: tokens.slice(1, redirect), outputFile: tokens[redirect + 1] };
}

/**
* eye.pl numbers the anonymous variables it writes into the temporary input
* files with a process-wide counter, so the two otherwise identical
* sub-reasoner invocations of a scope evaluation differ in variable
* numbering (`?_7102` vs `?_13330`). Renaming the variables in order of
* first appearance maps alpha-equivalent inputs onto the same cache key
* while keeping distinct variable structures distinct.
*/
function canonicalizeVariables(content: string): string {
const seen = new Map<string, string>();
return content.replace(/\?_\d+/g, (name) => {
let canonical = seen.get(name);
if (!canonical) {
canonical = `?_v${seen.size}`;
seen.set(name, canonical);
}
return canonical;
});
}

/**
* Cache key for a sub-reasoner invocation. EYE issues the sub-reasoner call
* for a scope twice per evaluation, under fresh temporary file names, so the
* key is built from the argument structure and the (variable-canonicalized)
* input file *contents* rather than their paths.
*/
function requestKey(module: SWIPLModule, args: string[]): string {
return JSON.stringify(args.map((arg) => (arg.startsWith('-')
? arg
: { file: canonicalizeVariables(module.FS.readFile(arg, { encoding: 'utf8' })) })));
}

async function runSubReasoner(
module: SWIPLModule,
options: BridgeOptions,
args: string[],
): Promise<SubRunResult> {
let stdout = '';
const stderr: string[] = [];
const child = await options.spawn({
print: (str: string) => { stdout += `${str}\n`; },
printErr: (str: string) => { stderr.push(str); },
});

const childArgs: string[] = [];
for (let i = 0; i < args.length; i += 1) {
if (args[i].startsWith('-')) {
childArgs.push(args[i]);
} else {
// Copy the temporary file prepared by the requesting reasoner into the
// filesystem of the fresh module
const name = `sub_input_${i}.n3s`;
child.FS.writeFile(name, module.FS.readFile(args[i]));
childArgs.push(name);
}
}

// The sub-reasoner gets the same driver, so that scoped builtins nested
// inside scope graphs recursively get fresh instances of their own
// eslint-disable-next-line no-use-before-define
await qaQuery(child, 'main', childArgs, bridgeCallback(child, options));

// eye.pl's main/1 traps halt/1 and records the would-be exit code in the
// exit_code global instead (0 on success, e.g. 2 on inference fuse)
const status = child.prolog.query('nb_getval(exit_code, Code).').once() as { Code?: unknown };
return { ok: status.Code === 0, stdout, stderr: stderr.join('\n') };
}

/**
* Creates a yield callback for the async query loop (qaQuery) that answers
* the exec/2 requests of the WASM reasoner image by running the requested
* sub-reasoner on a fresh module, and forwards any other yield (log:ask
* questions) to the provided cb.
* @param module The module the callback answers the requests of
* @param options The bridge options
* - spawn: loader booting fresh modules with the same reasoner image
* - cb: A callback to be used for question/answering (default: undefined)
* @returns The callback to drive qaQuery with
*/
export function bridgeCallback(
module: SWIPLModule,
options: BridgeOptions,
): (request: string) => Promise<string> {
// EYE issues each scope's sub-reasoner invocation twice with identical
// inputs per evaluation; cache on contents so the repeat is free
const cache = new Map<string, Promise<SubRunResult>>();

return async (request: string): Promise<string> => {
if (!request.startsWith(EXEC_REQUEST_PREFIX)) {
if (options.cb) {
return options.cb(request);
}
throw new Error(`The reasoner yielded a question [${request}] but no cb option was provided to answer it`);
}

const parsed = parseExecRequest(request.slice(EXEC_REQUEST_PREFIX.length));
if (!parsed) {
// Reaches exec/2 in the image, which raises it as an exec_error
return 'eye-js: e:exec and other arbitrary shell commands are not supported in the WASM build; '
+ 'only the eye sub-reasoner invocations of the graph-scoped builtins are bridged '
+ '(see https://github.com/eyereasoner/eye-js/issues/873)';
}

try {
const key = requestKey(module, parsed.args);
let run = cache.get(key);
if (!run) {
run = runSubReasoner(module, options, parsed.args);
cache.set(key, run);
}
const { ok, stdout, stderr } = await run;
// Take the role of the shell redirect: expose the output of the
// sub-reasoner at the location the requesting instance will parse
module.FS.writeFile(parsed.outputFile, stdout);
return ok ? 'ok' : `eye-js: sub-reasoner exited with an error: ${stderr}`;
} catch (error) {
return `eye-js: sub-reasoner run failed: ${error}`;
}
};
}
1 change: 1 addition & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* istanbul ignore file */
export * from './bridge';
export * from './query';
export * from './transformers';
export { default as EYE_PVM } from './eye';
11 changes: 9 additions & 2 deletions lib/transformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Parser } from 'n3';
import SWIPL, { type SWIPLModule } from 'swipl-wasm/dist/swipl/swipl-bundle-no-data';
import strToBuffer from 'swipl-wasm/dist/strToBuffer';
import { write } from './n3Writer.temp';
import { bridgeCallback } from './bridge';
import EYE_PVM from './eye';
import SEE_PVM from './lingua';
import { qaQuery, queryOnce } from './query';
Expand Down Expand Up @@ -165,15 +166,21 @@ export async function executeBasicEyeQuery(swipl: typeof SWIPL, data: InputData,

let res = '';
const err: string[] = [];
const Module = await (options?.imageLoader ?? loadEyeImage)(swipl)({
const loader = (options?.imageLoader ?? loadEyeImage)(swipl);
const Module = await loader({
print: (str: string) => { res += `${str}\n`; },
printErr: (str: string) => { err.push(str); },
});
await runQuery(
Module,
inputDataToStrings(data),
query && (typeof query === 'string' ? query : write(query)),
options,
// Run the reasoner through the async yield loop so that the sub-reasoner
// requests of graph-scoped builtins (log:collectAllIn, log:conclusion,
// ...) can be answered by booting fresh modules with the same image; any
// remaining yields (log:ask) are still answered by the provided cb.
// See https://github.com/eyereasoner/eye-js/issues/873.
{ ...options, cb: bridgeCallback(Module, { spawn: loader, cb: options?.cb }) },
!!options?.imageLoader,
);

Expand Down
Loading
Loading