eyeling 1.22.16 → 1.23.1
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.
- package/HANDBOOK.md +97 -18
- package/dist/browser/eyeling.browser.js +276 -38
- package/eyeling.js +267 -38
- package/index.d.ts +17 -5
- package/index.js +29 -8
- package/lib/cli.js +44 -32
- package/lib/engine.js +4 -2
- package/lib/multisource.js +198 -0
- package/lib/rules.js +28 -4
- package/package.json +1 -1
- package/test/api.test.js +125 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eyeling Reasoner — multi-source parsing helpers
|
|
3
|
+
*
|
|
4
|
+
* These helpers let the CLI/API parse several N3 documents independently and
|
|
5
|
+
* merge their parsed ASTs before reasoning. This avoids building one giant N3
|
|
6
|
+
* string while preserving the existing lexer/parser/engine pipeline.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { lex } = require('./lexer');
|
|
12
|
+
const { Parser } = require('./parser');
|
|
13
|
+
const {
|
|
14
|
+
Blank,
|
|
15
|
+
ListTerm,
|
|
16
|
+
OpenListTerm,
|
|
17
|
+
GraphTerm,
|
|
18
|
+
Triple,
|
|
19
|
+
Rule,
|
|
20
|
+
PrefixEnv,
|
|
21
|
+
annotateQuotedGraphTerm,
|
|
22
|
+
} = require('./prelude');
|
|
23
|
+
|
|
24
|
+
function emptyParsedDocument() {
|
|
25
|
+
return {
|
|
26
|
+
prefixes: PrefixEnv.newDefault(),
|
|
27
|
+
triples: [],
|
|
28
|
+
frules: [],
|
|
29
|
+
brules: [],
|
|
30
|
+
logQueryRules: [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseN3Text(text, opts = {}) {
|
|
35
|
+
const { baseIri = '', label = '<input>' } = opts || {};
|
|
36
|
+
const tokens = lex(text);
|
|
37
|
+
const parser = new Parser(tokens);
|
|
38
|
+
if (baseIri) parser.prefixes.setBase(baseIri);
|
|
39
|
+
const [prefixes, triples, frules, brules, logQueryRules] = parser.parseDocument();
|
|
40
|
+
return { prefixes, triples, frules, brules, logQueryRules, tokens, text, label };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sourceBlankPrefix(sourceIndex) {
|
|
44
|
+
return `_:src${sourceIndex}_`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scopedBlankLabel(label, sourceIndex, mapping) {
|
|
48
|
+
const key = String(label || '');
|
|
49
|
+
let out = mapping.get(key);
|
|
50
|
+
if (out) return out;
|
|
51
|
+
|
|
52
|
+
const bare = key.startsWith('_:') ? key.slice(2) : key;
|
|
53
|
+
out = sourceBlankPrefix(sourceIndex) + bare;
|
|
54
|
+
mapping.set(key, out);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function scopeBlankNodesInDocument(doc, sourceIndex) {
|
|
59
|
+
const mapping = new Map();
|
|
60
|
+
|
|
61
|
+
function cloneTerm(term) {
|
|
62
|
+
if (term instanceof Blank) return new Blank(scopedBlankLabel(term.label, sourceIndex, mapping));
|
|
63
|
+
if (term instanceof ListTerm) return new ListTerm(term.elems.map(cloneTerm));
|
|
64
|
+
if (term instanceof OpenListTerm) return new OpenListTerm(term.prefix.map(cloneTerm), term.tailVar);
|
|
65
|
+
if (term instanceof GraphTerm) return annotateQuotedGraphTerm(new GraphTerm(term.triples.map(cloneTriple)));
|
|
66
|
+
return term;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function cloneTriple(triple) {
|
|
70
|
+
return new Triple(cloneTerm(triple.s), cloneTerm(triple.p), cloneTerm(triple.o));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function cloneRule(rule) {
|
|
74
|
+
const headBlankLabels = new Set();
|
|
75
|
+
if (rule && rule.headBlankLabels instanceof Set) {
|
|
76
|
+
for (const label of rule.headBlankLabels) headBlankLabels.add(scopedBlankLabel(label, sourceIndex, mapping));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const out = new Rule(
|
|
80
|
+
(rule.premise || []).map(cloneTriple),
|
|
81
|
+
(rule.conclusion || []).map(cloneTriple),
|
|
82
|
+
rule.isForward,
|
|
83
|
+
rule.isFuse,
|
|
84
|
+
headBlankLabels,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (rule && Object.prototype.hasOwnProperty.call(rule, '__dynamicConclusionTerm')) {
|
|
88
|
+
Object.defineProperty(out, '__dynamicConclusionTerm', {
|
|
89
|
+
value: cloneTerm(rule.__dynamicConclusionTerm),
|
|
90
|
+
enumerable: false,
|
|
91
|
+
writable: false,
|
|
92
|
+
configurable: true,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
prefixes: doc.prefixes,
|
|
101
|
+
triples: (doc.triples || []).map(cloneTriple),
|
|
102
|
+
frules: (doc.frules || []).map(cloneRule),
|
|
103
|
+
brules: (doc.brules || []).map(cloneRule),
|
|
104
|
+
logQueryRules: (doc.logQueryRules || []).map(cloneRule),
|
|
105
|
+
tokens: doc.tokens,
|
|
106
|
+
text: doc.text,
|
|
107
|
+
label: doc.label,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function mergePrefixEnvs(target, source) {
|
|
112
|
+
if (!source) return target;
|
|
113
|
+
const map = source.map || {};
|
|
114
|
+
for (const [prefix, iri] of Object.entries(map)) {
|
|
115
|
+
// Every parser starts with an empty default namespace. Do not let a later
|
|
116
|
+
// source that never declared ':' erase a useful default namespace from an
|
|
117
|
+
// earlier source; prefix merging is for output readability only.
|
|
118
|
+
if (iri || !Object.prototype.hasOwnProperty.call(target.map, prefix)) target.set(prefix, iri);
|
|
119
|
+
}
|
|
120
|
+
if (source.baseIri) target.setBase(source.baseIri);
|
|
121
|
+
return target;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function mergeParsedDocuments(docs, opts = {}) {
|
|
125
|
+
const documents = Array.isArray(docs) ? docs : [];
|
|
126
|
+
const scopeBlankNodes = typeof opts.scopeBlankNodes === 'boolean' ? opts.scopeBlankNodes : documents.length > 1;
|
|
127
|
+
|
|
128
|
+
const merged = emptyParsedDocument();
|
|
129
|
+
const mergedSources = [];
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < documents.length; i++) {
|
|
132
|
+
const originalDoc = documents[i] || emptyParsedDocument();
|
|
133
|
+
const doc = scopeBlankNodes ? scopeBlankNodesInDocument(originalDoc, i + 1) : originalDoc;
|
|
134
|
+
|
|
135
|
+
mergePrefixEnvs(merged.prefixes, doc.prefixes);
|
|
136
|
+
merged.triples.push(...(doc.triples || []));
|
|
137
|
+
merged.frules.push(...(doc.frules || []));
|
|
138
|
+
merged.brules.push(...(doc.brules || []));
|
|
139
|
+
merged.logQueryRules.push(...(doc.logQueryRules || []));
|
|
140
|
+
mergedSources.push(doc);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
Object.defineProperty(merged, 'sources', {
|
|
144
|
+
value: mergedSources,
|
|
145
|
+
enumerable: false,
|
|
146
|
+
writable: false,
|
|
147
|
+
configurable: true,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return merged;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isN3SourceListInput(input) {
|
|
154
|
+
return !!(input && typeof input === 'object' && !Array.isArray(input) && Array.isArray(input.sources));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizeN3SourceItem(source, index) {
|
|
158
|
+
const sourceNumber = index + 1;
|
|
159
|
+
if (typeof source === 'string') {
|
|
160
|
+
return { text: source, label: `<source ${sourceNumber}>`, baseIri: '' };
|
|
161
|
+
}
|
|
162
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
|
163
|
+
throw new TypeError('Each N3 source must be a string or an object with an n3/text field');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const text = typeof source.n3 === 'string' ? source.n3 : typeof source.text === 'string' ? source.text : null;
|
|
167
|
+
if (text === null) throw new TypeError('Each N3 source object must provide an n3 or text string');
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
text,
|
|
171
|
+
label: typeof source.label === 'string' && source.label ? source.label : `<source ${sourceNumber}>`,
|
|
172
|
+
baseIri: typeof source.baseIri === 'string' ? source.baseIri : '',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseN3SourceList(input, opts = {}) {
|
|
177
|
+
if (!isN3SourceListInput(input)) return null;
|
|
178
|
+
const sources = input.sources.map(normalizeN3SourceItem);
|
|
179
|
+
const defaultBaseIri = typeof opts.baseIri === 'string' ? opts.baseIri : '';
|
|
180
|
+
const parsed = sources.map((source, index) =>
|
|
181
|
+
parseN3Text(source.text, {
|
|
182
|
+
label: source.label,
|
|
183
|
+
baseIri: source.baseIri || (sources.length === 1 ? defaultBaseIri : ''),
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
return mergeParsedDocuments(parsed, {
|
|
187
|
+
scopeBlankNodes: typeof input.scopeBlankNodes === 'boolean' ? input.scopeBlankNodes : parsed.length > 1,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
emptyParsedDocument,
|
|
193
|
+
parseN3Text,
|
|
194
|
+
mergeParsedDocuments,
|
|
195
|
+
scopeBlankNodesInDocument,
|
|
196
|
+
isN3SourceListInput,
|
|
197
|
+
parseN3SourceList,
|
|
198
|
+
};
|
package/lib/rules.js
CHANGED
|
@@ -7,9 +7,23 @@
|
|
|
7
7
|
|
|
8
8
|
'use strict';
|
|
9
9
|
|
|
10
|
-
const {
|
|
10
|
+
const {
|
|
11
|
+
LOG_NS,
|
|
12
|
+
Iri,
|
|
13
|
+
Var,
|
|
14
|
+
Blank,
|
|
15
|
+
ListTerm,
|
|
16
|
+
OpenListTerm,
|
|
17
|
+
GraphTerm,
|
|
18
|
+
Triple,
|
|
19
|
+
copyQuotedGraphMetadata,
|
|
20
|
+
} = require('./prelude');
|
|
11
21
|
|
|
12
22
|
function liftBlankRuleVars(premise, conclusion) {
|
|
23
|
+
function isLogIncludesLikePredicate(p) {
|
|
24
|
+
return p instanceof Iri && (p.value === LOG_NS + 'includes' || p.value === LOG_NS + 'notIncludes');
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
// Map blank labels to stable rule-local variable names.
|
|
14
28
|
// This runs at rule construction time; keep it simple and allocation-light.
|
|
15
29
|
const mapping = Object.create(null);
|
|
@@ -66,9 +80,19 @@ function liftBlankRuleVars(premise, conclusion) {
|
|
|
66
80
|
return t;
|
|
67
81
|
}
|
|
68
82
|
|
|
69
|
-
const newPremise = premise.map(
|
|
70
|
-
|
|
71
|
-
|
|
83
|
+
const newPremise = premise.map((tr) => {
|
|
84
|
+
// In log:includes / log:notIncludes, quoted formula operands are formulas
|
|
85
|
+
// consumed by the builtin rather than ordinary triple patterns. Keep their
|
|
86
|
+
// local blank nodes as Blank terms so the builtin can treat them as local
|
|
87
|
+
// existentials, and bindings returned from an explicit scope are blank nodes
|
|
88
|
+
// instead of synthetic rule variables such as ?_b1.
|
|
89
|
+
const keepFormulaBlanks = isLogIncludesLikePredicate(tr.p);
|
|
90
|
+
return new Triple(
|
|
91
|
+
keepFormulaBlanks && tr.s instanceof GraphTerm ? copyQuotedTerm(tr.s) : convertTerm(tr.s, true),
|
|
92
|
+
convertTerm(tr.p, true),
|
|
93
|
+
keepFormulaBlanks && tr.o instanceof GraphTerm ? copyQuotedTerm(tr.o) : convertTerm(tr.o, true),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
72
96
|
return [newPremise, conclusion];
|
|
73
97
|
}
|
|
74
98
|
|
package/package.json
CHANGED
package/test/api.test.js
CHANGED
|
@@ -1643,6 +1643,30 @@ _:x :hates { _:foo :making :mess }.
|
|
|
1643
1643
|
expect: [/:(?:test)\s+:(?:contains)\s+:(?:success-literal-3)\s*\./, /:(?:test)\s+:(?:is)\s+true\s*\./],
|
|
1644
1644
|
},
|
|
1645
1645
|
|
|
1646
|
+
{
|
|
1647
|
+
name: '60a regression: log:includes explicit-scope blank node is returned as blank, not synthetic variable',
|
|
1648
|
+
opt: { proofComments: false },
|
|
1649
|
+
input: `@prefix : <http://example.org/ns#> .
|
|
1650
|
+
@prefix log: <http://www.w3.org/2000/10/swap/log#>.
|
|
1651
|
+
|
|
1652
|
+
{
|
|
1653
|
+
{
|
|
1654
|
+
_:b1 a :Mortal .
|
|
1655
|
+
} log:includes {
|
|
1656
|
+
?CS ?CP ?CO .
|
|
1657
|
+
} .
|
|
1658
|
+
}
|
|
1659
|
+
=>
|
|
1660
|
+
{
|
|
1661
|
+
_:b2 :conclusion {
|
|
1662
|
+
?CS ?CP ?CO .
|
|
1663
|
+
} .
|
|
1664
|
+
} .
|
|
1665
|
+
`,
|
|
1666
|
+
expect: [/_:sk_\d+\s+:(?:conclusion)\s+\{\s*_:b\d+\s+a\s+:(?:Mortal)\s*\.\s*\}\s*\./s],
|
|
1667
|
+
notExpect: [/\?_b\d+\s+a\s+:(?:Mortal)/],
|
|
1668
|
+
},
|
|
1669
|
+
|
|
1646
1670
|
{
|
|
1647
1671
|
name: '61 RDF/JS input + rule objects: reason() accepts quads with rules',
|
|
1648
1672
|
run() {
|
|
@@ -2309,6 +2333,107 @@ _:b a ex:Person ; ex:name "B" .
|
|
|
2309
2333
|
`,
|
|
2310
2334
|
expect: [/^:test\s+:is\s+true\s*\./m],
|
|
2311
2335
|
},
|
|
2336
|
+
{
|
|
2337
|
+
name: '69 CLI multi-input: parses files separately and reasons over merged AST',
|
|
2338
|
+
run() {
|
|
2339
|
+
const os = require('node:os');
|
|
2340
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'eyeling-multi-input-'));
|
|
2341
|
+
const factsPath = path.join(tmp, 'facts.n3');
|
|
2342
|
+
const rulesPath = path.join(tmp, 'rules.n3');
|
|
2343
|
+
|
|
2344
|
+
fs.writeFileSync(factsPath, '@prefix : <http://example.org/> .\n:Socrates a :Man .\n', 'utf8');
|
|
2345
|
+
fs.writeFileSync(rulesPath, '@prefix : <http://example.org/> .\n{ ?x a :Man } => { ?x a :Mortal } .\n', 'utf8');
|
|
2346
|
+
|
|
2347
|
+
try {
|
|
2348
|
+
const r = spawnSync(process.execPath, [path.join(ROOT, 'eyeling.js'), factsPath, rulesPath], {
|
|
2349
|
+
encoding: 'utf8',
|
|
2350
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2351
|
+
maxBuffer: DEFAULT_MAX_BUFFER,
|
|
2352
|
+
});
|
|
2353
|
+
if (r.error) throw r.error;
|
|
2354
|
+
if (r.status !== 0) {
|
|
2355
|
+
const err = new Error(`CLI failed with exit ${r.status}`);
|
|
2356
|
+
err.code = r.status;
|
|
2357
|
+
err.stdout = r.stdout;
|
|
2358
|
+
err.stderr = r.stderr;
|
|
2359
|
+
throw err;
|
|
2360
|
+
}
|
|
2361
|
+
return r.stdout;
|
|
2362
|
+
} finally {
|
|
2363
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
2364
|
+
}
|
|
2365
|
+
},
|
|
2366
|
+
expect: [/:(?:Socrates)\s+a\s+:(?:Mortal)\s*\./],
|
|
2367
|
+
},
|
|
2368
|
+
{
|
|
2369
|
+
name: '70 CLI multi-input: scopes blank node labels per source',
|
|
2370
|
+
run() {
|
|
2371
|
+
const os = require('node:os');
|
|
2372
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'eyeling-multi-blank-'));
|
|
2373
|
+
const leftPath = path.join(tmp, 'left.n3');
|
|
2374
|
+
const rightPath = path.join(tmp, 'right.n3');
|
|
2375
|
+
const rulePath = path.join(tmp, 'rule.n3');
|
|
2376
|
+
|
|
2377
|
+
fs.writeFileSync(leftPath, '@prefix : <http://example.org/> .\n_:x :p :a .\n', 'utf8');
|
|
2378
|
+
fs.writeFileSync(rightPath, '@prefix : <http://example.org/> .\n_:x :q :b .\n', 'utf8');
|
|
2379
|
+
fs.writeFileSync(
|
|
2380
|
+
rulePath,
|
|
2381
|
+
'@prefix : <http://example.org/> .\n{ ?x :p :a . ?x :q :b . } => { :bad :merged true } .\n',
|
|
2382
|
+
'utf8',
|
|
2383
|
+
);
|
|
2384
|
+
|
|
2385
|
+
try {
|
|
2386
|
+
const r = spawnSync(process.execPath, [path.join(ROOT, 'eyeling.js'), leftPath, rightPath, rulePath], {
|
|
2387
|
+
encoding: 'utf8',
|
|
2388
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2389
|
+
maxBuffer: DEFAULT_MAX_BUFFER,
|
|
2390
|
+
});
|
|
2391
|
+
if (r.error) throw r.error;
|
|
2392
|
+
if (r.status !== 0) {
|
|
2393
|
+
const err = new Error(`CLI failed with exit ${r.status}`);
|
|
2394
|
+
err.code = r.status;
|
|
2395
|
+
err.stdout = r.stdout;
|
|
2396
|
+
err.stderr = r.stderr;
|
|
2397
|
+
throw err;
|
|
2398
|
+
}
|
|
2399
|
+
return r.stdout;
|
|
2400
|
+
} finally {
|
|
2401
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
2402
|
+
}
|
|
2403
|
+
},
|
|
2404
|
+
notExpect: [/^:bad\s+:merged\s+true\s*\./m],
|
|
2405
|
+
},
|
|
2406
|
+
{
|
|
2407
|
+
name: '71 API multi-source: reason() accepts source list input',
|
|
2408
|
+
run() {
|
|
2409
|
+
return reason(
|
|
2410
|
+
{ proofComments: false },
|
|
2411
|
+
{
|
|
2412
|
+
sources: [
|
|
2413
|
+
'@prefix : <http://example.org/> .\n:Socrates a :Man .\n',
|
|
2414
|
+
'@prefix : <http://example.org/> .\n{ ?x a :Man } => { ?x a :Mortal } .\n',
|
|
2415
|
+
],
|
|
2416
|
+
},
|
|
2417
|
+
);
|
|
2418
|
+
},
|
|
2419
|
+
expect: [/:(?:Socrates)\s+a\s+:(?:Mortal)\s*\./],
|
|
2420
|
+
},
|
|
2421
|
+
{
|
|
2422
|
+
name: '72 API multi-source: reasonStream() accepts source list input',
|
|
2423
|
+
run() {
|
|
2424
|
+
const result = reasonStream(
|
|
2425
|
+
{
|
|
2426
|
+
sources: [
|
|
2427
|
+
'@prefix : <http://example.org/> .\n:Socrates a :Man .\n',
|
|
2428
|
+
'@prefix : <http://example.org/> .\n{ ?x a :Man } => { ?x a :Mortal } .\n',
|
|
2429
|
+
],
|
|
2430
|
+
},
|
|
2431
|
+
{ proof: false },
|
|
2432
|
+
);
|
|
2433
|
+
return result.closureN3;
|
|
2434
|
+
},
|
|
2435
|
+
expect: [/:(?:Socrates)\s+a\s+:(?:Mortal)\s*\./],
|
|
2436
|
+
},
|
|
2312
2437
|
{
|
|
2313
2438
|
name: 'regression: log:semantics body alpha-renaming does not refire blank-head rule forever',
|
|
2314
2439
|
async run() {
|