eyeling 1.15.9 → 1.15.11

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 CHANGED
@@ -376,6 +376,10 @@ That’s alpha-equivalence:
376
376
 
377
377
  Eyeling implements alpha-equivalence by checking whether there exists a consistent renaming mapping between the two formulas’ variables/blanks that makes the triples match.
378
378
 
379
+ Important scope nuance: only blanks/variables that are local to the quoted formula participate in alpha-renaming. If a formula is being matched after an outer substitution has already instantiated part of it, those substituted terms are treated as fixed. In other words, alpha-equivalence may rename formula-local placeholders, but it must not rename names that came from the enclosing match. This prevents a substituted outer blank node from being confused with a local blank node inside the quoted formula.
380
+
381
+ So `{ _:x :p :o }` obtained by substituting `?A = _:x` into `{ ?A :p :o }` must not alpha-match `{ _:b :p :o }` by renaming `_:x` to `_:b`.
382
+
379
383
  ### 6.2 Groundness: “variables inside formulas don’t leak”
380
384
 
381
385
  Eyeling makes a deliberate choice about _groundness_:
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # eyeling
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/eyereasoner.svg)](https://www.npmjs.com/package/eyereasoner) [![DOI](https://zenodo.org/badge/581706557.svg)](https://doi.org/10.5281/zenodo.19068086)
4
+
3
5
  A compact [Notation3 (N3)](https://notation3.org/) reasoner in **JavaScript**.
4
6
 
5
7
  - Single self-contained bundle (`eyeling.js`), no external runtime dependencies
@@ -0,0 +1,211 @@
1
+ # Faltings’ theorem (emulated) in Notation3: a genus‑2 curve over Q
2
+
3
+ This deck explains the example `faltings-genus2-finiteness.n3` ([Playground][1]).
4
+
5
+ The goal is to show—at a friendly, “wide audience” level—how an N3 file can _model_ a famous mathematical implication:
6
+
7
+ > “If a curve has genus ≥ 2, then it has only finitely many rational points.”
8
+
9
+ ---
10
+
11
+ ## The problem in plain language
12
+
13
+ People often ask:
14
+
15
+ > “Which solutions can an equation have if you only allow fractions?”
16
+
17
+ A **rational point** means a solution where the coordinates are rational numbers (fractions like 3/7).
18
+
19
+ Some equations have **infinitely many** rational points. Others have **only finitely many**.
20
+
21
+ This example is about expressing the “only finitely many” conclusion as a machine-checkable **rule**.
22
+
23
+ ---
24
+
25
+ ## The concrete curve in this example
26
+
27
+ We model this curve:
28
+
29
+ \[ y^2 = x(x+1)(x-2)(x+2)(x-3) \]
30
+
31
+ You can spot some obvious rational solutions just by making the right-hand side zero:
32
+
33
+ - (0, 0)
34
+ - (-1, 0)
35
+ - (2, 0)
36
+ - (-2, 0)
37
+ - (3, 0)
38
+
39
+ The file includes these as example data points.
40
+
41
+ ---
42
+
43
+ ## What is “genus” (without the heavy math)?
44
+
45
+ A good mental model is:
46
+
47
+ - **genus 0**: sphere-like (0 holes)
48
+ - **genus 1**: donut-like (1 hole)
49
+ - **genus 2**: “two-hole donut”
50
+ - **genus ≥ 2**: more complicated surfaces
51
+
52
+ In algebraic geometry, _genus_ is a deep invariant, but for this deck you only need:
53
+
54
+ > genus is a number that measures how “holey” a curve is.
55
+
56
+ ---
57
+
58
+ ## The famous implication (Faltings’ theorem)
59
+
60
+ Very roughly:
61
+
62
+ - For genus 0: rational points are “none or infinite”
63
+ - For genus 1: rational points can be infinite, but structured (elliptic curves)
64
+ - For genus ≥ 2: rational points are **finite**
65
+
66
+ Faltings proved (in 1983) the last bullet (formerly the Mordell conjecture).
67
+
68
+ ---
69
+
70
+ ## What this N3 example _does_
71
+
72
+ It does **not** re-prove the theorem.
73
+
74
+ Instead, it treats “Faltings’ theorem” as a named rule:
75
+
76
+ - If something is a curve,
77
+ - over a number field,
78
+ - with genus ≥ 2,
79
+ - then infer: “its rational points are finite.”
80
+
81
+ That’s the kind of modeling you do when you want a reasoner to apply well-known results reliably.
82
+
83
+ ---
84
+
85
+ ## Why this is useful (even though it’s an emulation)
86
+
87
+ Think of it like a _library function_.
88
+
89
+ You may not re-derive calculus every time you compute a derivative; you rely on a trusted rule.
90
+
91
+ Similarly, you can:
92
+
93
+ - store curve facts as data,
94
+ - encode trusted theorems as rules,
95
+ - and let a reasoner apply them consistently.
96
+
97
+ ---
98
+
99
+ ## N3 in one minute
100
+
101
+ N3 is RDF + rules.
102
+
103
+ - **Facts** look like triples:
104
+ - `:C :genus 2.`
105
+ - **Rules** look like:
106
+ - `{ ... } => { ... } .`
107
+
108
+ Variables start with `?`, like `?curve`, `?g`.
109
+
110
+ ---
111
+
112
+ ## The data section (what we assert)
113
+
114
+ The file asserts:
115
+
116
+ ```n3
117
+ :C a :Curve ;
118
+ :equation "y^2 = x(x+1)(x-2)(x+2)(x-3)" ;
119
+ :definedOver :Q ;
120
+ :genus 2 .
121
+ ```
122
+
123
+ And it also asserts that `:Q` is a `:NumberField`, plus a few sample points.
124
+
125
+ ---
126
+
127
+ ## The rule section (the “theorem” as logic)
128
+
129
+ Here is the core rule (lightly formatted):
130
+
131
+ ```n3
132
+ {
133
+ ?curve a :Curve ;
134
+ :definedOver ?field ;
135
+ :genus ?g .
136
+ ?field a :NumberField .
137
+ ?g math:notLessThan 2 .
138
+ }
139
+ =>
140
+ {
141
+ ?curve :coveredBy :FaltingsTheorem ;
142
+ :hasRationalPointsCardinality :Finite .
143
+ } .
144
+ ```
145
+
146
+ That `math:notLessThan` is a standard N3 math builtin: it means “≥”.
147
+
148
+ ---
149
+
150
+ ## What gets inferred
151
+
152
+ Once a reasoner sees the facts:
153
+
154
+ - `:C a :Curve`
155
+ - `:C :definedOver :Q`
156
+ - `:C :genus 2`
157
+ - `:Q a :NumberField`
158
+
159
+ …the rule fires and it can derive:
160
+
161
+ ```n3
162
+ :C :hasRationalPointsCardinality :Finite .
163
+ :C :coveredBy :FaltingsTheorem .
164
+ ```
165
+
166
+ There’s also a small follow-on rule that derives a friendlier Boolean:
167
+
168
+ ```n3
169
+ :C :doesNotHaveInfinitelyManyRationalPoints true .
170
+ ```
171
+
172
+ ---
173
+
174
+ ## Important: what it does _not_ compute
175
+
176
+ This file does **not** find all rational points.
177
+
178
+ Faltings’ theorem is about **finiteness**, not an explicit list.
179
+
180
+ So the example’s job is:
181
+
182
+ - represent the curve,
183
+ - represent the theorem as a rule,
184
+ - and show that the reasoner can draw the finiteness conclusion.
185
+
186
+ ---
187
+
188
+ ## Try it
189
+
190
+ ### In your browser
191
+
192
+ Use the playground link at the top: [Playground][1].
193
+
194
+ ### On the command line
195
+
196
+ ```bash
197
+ eyeling faltings-genus2-finiteness.n3
198
+ ```
199
+
200
+ ---
201
+
202
+ ## Where you can take this next
203
+
204
+ Easy extensions:
205
+
206
+ - Add more example curves with different genera
207
+ - Add rules that _classify_ genus based on curve families (toy versions)
208
+ - Connect this to a small “math knowledge base” of reusable lemmas
209
+ - Use the same pattern for other theorems: “if conditions, then property”
210
+
211
+ [1]: https://eyereasoner.github.io/eyeling/demo?url=https://raw.githubusercontent.com/eyereasoner/eyeling/refs/heads/main/examples/faltings-genus2-finiteness.n3 'Playground'
@@ -0,0 +1,69 @@
1
+ # ==================================================================
2
+ # File: faltings-genus2-finiteness.n3
3
+ # Purpose:
4
+ # Encode a simple Notation3 example inspired by Faltings' theorem.
5
+ #
6
+ # Mathematical content:
7
+ # The curve
8
+ # y^2 = x(x+1)(x-2)(x+2)(x-3)
9
+ # is treated as a genus-2 curve over Q.
10
+ # By an N3 rule that emulates Faltings' theorem,
11
+ # any curve over a number field with genus >= 2
12
+ # is inferred to have only finitely many rational points.
13
+ #
14
+ # Notes:
15
+ # - This file is a logical emulation, not a formal proof.
16
+ # - Several explicit rational points are included as examples.
17
+ # ==================================================================
18
+
19
+ @prefix : <http://example.org/faltings#> .
20
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
21
+ @prefix math: <http://www.w3.org/2000/10/swap/math#> .
22
+ @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
23
+
24
+ # --- the example curve ---
25
+ :C
26
+ a :Curve ;
27
+ :equation "y^2 = x(x+1)(x-2)(x+2)(x-3)" ;
28
+ :definedOver :Q ;
29
+ :genus 2 ;
30
+ rdfs:label "Example genus-2 curve" .
31
+
32
+ :Q a :NumberField ;
33
+ rdfs:label "the rationals" .
34
+
35
+ :FaltingsTheorem
36
+ a :Theorem ;
37
+ rdfs:label "If a curve over a number field has genus >= 2, then it has finitely many rational points." .
38
+
39
+ :Finite a :CardinalityClass .
40
+
41
+ # --- some explicit rational points on the curve ---
42
+ :P0 a :RationalPoint ; :onCurve :C ; :x 0 ; :y 0 .
43
+ :P1 a :RationalPoint ; :onCurve :C ; :x -1 ; :y 0 .
44
+ :P2 a :RationalPoint ; :onCurve :C ; :x 2 ; :y 0 .
45
+ :P3 a :RationalPoint ; :onCurve :C ; :x -2 ; :y 0 .
46
+ :P4 a :RationalPoint ; :onCurve :C ; :x 3 ; :y 0 .
47
+
48
+ # --- emulation of Faltings' theorem ---
49
+ {
50
+ ?curve a :Curve ;
51
+ :definedOver ?field ;
52
+ :genus ?g .
53
+ ?field a :NumberField .
54
+ ?g math:notLessThan 2 .
55
+ }
56
+ =>
57
+ {
58
+ ?curve :coveredBy :FaltingsTheorem ;
59
+ :hasRationalPointsCardinality :Finite .
60
+ } .
61
+
62
+ # --- optional derived statement ---
63
+ {
64
+ ?curve :hasRationalPointsCardinality :Finite .
65
+ }
66
+ =>
67
+ {
68
+ ?curve :doesNotHaveInfinitelyManyRationalPoints true .
69
+ } .
@@ -29,3 +29,5 @@
29
29
  # OWL rules
30
30
  {?P a owl:TransitiveProperty. ?S ?P ?X. ?X ?P ?O.} => {?S ?P ?O}.
31
31
 
32
+ # query
33
+ {?C :path :nantes} log:query {?C :path :nantes}.
@@ -0,0 +1,92 @@
1
+ # =================================================================
2
+ # More advanced jsonterm example.
3
+ #
4
+ # Mapping used:
5
+ # - JSON objects become anonymous node structures.
6
+ # - JSON arrays become N3 lists.
7
+ # - JSON null becomes a normal symbol here, j:null.
8
+ # - JSON booleans become ordinary boolean literals: true and false.
9
+ # =================================================================
10
+
11
+ @prefix ex: <https://example.org/#> .
12
+ @prefix j: <https://example.org/json#> .
13
+
14
+ [
15
+ j:id "user-101";
16
+ j:profile [
17
+ j:name "Alice";
18
+ j:active true;
19
+ j:address [
20
+ j:city "Ghent";
21
+ j:country "BE"
22
+ ]
23
+ ];
24
+ j:roles ("admin" "editor");
25
+ j:preferences [
26
+ j:languages ("nl" "fr" "en");
27
+ j:theme "dark";
28
+ j:beta false
29
+ ];
30
+ j:orders (
31
+ [
32
+ j:id "ord-1";
33
+ j:total 125;
34
+ j:items ("book" "pen")
35
+ ]
36
+ [
37
+ j:id "ord-2";
38
+ j:total 45;
39
+ j:items ("notebook");
40
+ j:coupon j:null
41
+ ]
42
+ )
43
+ ] ex:kind j:UserDocument .
44
+
45
+ {
46
+ ?Doc ex:kind j:UserDocument .
47
+ ?Doc j:profile [
48
+ j:name ?Name;
49
+ j:active true;
50
+ j:address [ j:country "BE" ]
51
+ ] .
52
+ }
53
+ =>
54
+ {
55
+ ?Doc ex:userName ?Name .
56
+ ?Doc ex:eligibleForReview true .
57
+ } .
58
+
59
+ {
60
+ ?Doc j:preferences [
61
+ j:languages ("nl" "fr" "en");
62
+ j:theme "dark";
63
+ j:beta false
64
+ ] .
65
+ }
66
+ =>
67
+ {
68
+ ?Doc ex:profileTag "multilingual-dark-profile" .
69
+ } .
70
+
71
+ {
72
+ ?Doc j:orders (
73
+ [ j:id ?FirstId; j:total 125; j:items ("book" "pen") ]
74
+ [ j:id ?SecondId; j:total 45; j:items ("notebook"); j:coupon j:null ]
75
+ ) .
76
+ }
77
+ =>
78
+ {
79
+ ?Doc ex:hasHighValueStarterOrder true .
80
+ ?Doc ex:hasCouponlessFollowup true .
81
+ } .
82
+
83
+ {
84
+ ?Doc ex:eligibleForReview true .
85
+ ?Doc ex:profileTag "multilingual-dark-profile" .
86
+ ?Doc ex:hasHighValueStarterOrder true .
87
+ ?Doc ex:hasCouponlessFollowup true .
88
+ }
89
+ =>
90
+ {
91
+ ex:test ex:is true .
92
+ } .
@@ -0,0 +1,37 @@
1
+ # =================================================
2
+ # jsonterm example
3
+ #
4
+ # Mapping used:
5
+ # - JSON objects become anonymous node structures.
6
+ # - JSON arrays become N3 lists.
7
+ # - JSON null becomes a normal symbol here, j:null.
8
+ # =================================================
9
+
10
+ @prefix ex: <https://example.org/#> .
11
+ @prefix j: <https://example.org/json#> .
12
+
13
+ [
14
+ j:user "alice";
15
+ j:roles ("admin" j:null)
16
+ ]
17
+ ("likes" 1)
18
+ [
19
+ j:city "Paris";
20
+ j:coords (2.35 48.85)
21
+ ] .
22
+
23
+ {
24
+ [
25
+ j:user ?U;
26
+ j:roles ("admin" j:null)
27
+ ]
28
+ ("likes" 1)
29
+ [
30
+ j:city "Paris";
31
+ j:coords (2.35 48.85)
32
+ ] .
33
+ }
34
+ =>
35
+ {
36
+ ex:test ex:is true .
37
+ } .
@@ -0,0 +1,5 @@
1
+ @prefix : <http://example.org/faltings#> .
2
+
3
+ :C :coveredBy :FaltingsTheorem .
4
+ :C :hasRationalPointsCardinality :Finite .
5
+ :C :doesNotHaveInfinitelyManyRationalPoints true .
@@ -1,23 +1,6 @@
1
1
  @prefix : <http://www.agfa.com/w3c/euler/graph.axiom#> .
2
2
 
3
- :paris :path :orleans .
4
- :paris :path :chartres .
5
- :paris :path :amiens .
6
- :orleans :path :blois .
7
- :orleans :path :bourges .
8
- :blois :path :tours .
9
- :chartres :path :lemans .
10
- :lemans :path :angers .
11
- :lemans :path :tours .
12
3
  :angers :path :nantes .
13
- :paris :path :blois .
14
- :paris :path :bourges .
15
- :paris :path :lemans .
16
- :orleans :path :tours .
17
- :chartres :path :angers .
18
- :chartres :path :tours .
19
- :lemans :path :nantes .
20
- :paris :path :tours .
21
- :paris :path :angers .
22
4
  :chartres :path :nantes .
5
+ :lemans :path :nantes .
23
6
  :paris :path :nantes .
@@ -0,0 +1,8 @@
1
+ @prefix ex: <https://example.org/#> .
2
+
3
+ _:b1 ex:userName "Alice" .
4
+ _:b1 ex:eligibleForReview true .
5
+ _:b1 ex:profileTag "multilingual-dark-profile" .
6
+ _:b1 ex:hasHighValueStarterOrder true .
7
+ _:b1 ex:hasCouponlessFollowup true .
8
+ ex:test ex:is true .
@@ -0,0 +1,3 @@
1
+ @prefix ex: <https://example.org/#> .
2
+
3
+ ex:test ex:is true .
package/eyeling.js CHANGED
@@ -110,6 +110,35 @@ function makeBuiltins(deps) {
110
110
  return { evalBuiltin, isBuiltinPred };
111
111
  }
112
112
 
113
+ function __builtinCollectVarsInTerm(t, out) {
114
+ if (t instanceof Var) {
115
+ out.add(t.name);
116
+ return;
117
+ }
118
+ if (t instanceof ListTerm) {
119
+ for (const e of t.elems) __builtinCollectVarsInTerm(e, out);
120
+ return;
121
+ }
122
+ if (t instanceof OpenListTerm) {
123
+ for (const e of t.prefix) __builtinCollectVarsInTerm(e, out);
124
+ out.add(t.tailVar);
125
+ return;
126
+ }
127
+ if (t instanceof GraphTerm) {
128
+ for (const tr of t.triples) __builtinCollectVarsInTriple(tr, out);
129
+ }
130
+ }
131
+
132
+ function __builtinCollectVarsInTriple(tr, out) {
133
+ __builtinCollectVarsInTerm(tr.s, out);
134
+ __builtinCollectVarsInTerm(tr.p, out);
135
+ __builtinCollectVarsInTerm(tr.o, out);
136
+ }
137
+
138
+ function __builtinCollectVarsInTriples(triples, out) {
139
+ for (const tr of triples) __builtinCollectVarsInTriple(tr, out);
140
+ }
141
+
113
142
  function literalHasLangTag(lit) {
114
143
  // True iff the literal is a quoted string literal with a language tag suffix,
115
144
  // e.g. "hello"@en or """hello"""@en.
@@ -3040,6 +3069,8 @@ function evalBuiltin(goal, subst, facts, backRules, depth, varGen, maxResults) {
3040
3069
  if (!(g.o instanceof GraphTerm)) return [];
3041
3070
 
3042
3071
  const visited2 = [];
3072
+ const keepVars = new Set();
3073
+ if (g.s instanceof GraphTerm) __builtinCollectVarsInTriples(g.s.triples, keepVars);
3043
3074
  // Start from the incoming substitution so bindings flow outward.
3044
3075
  return proveGoals(
3045
3076
  Array.from(g.o.triples),
@@ -3050,6 +3081,7 @@ function evalBuiltin(goal, subst, facts, backRules, depth, varGen, maxResults) {
3050
3081
  visited2,
3051
3082
  varGen,
3052
3083
  maxResults,
3084
+ keepVars.size ? { keepVars } : undefined,
3053
3085
  );
3054
3086
  }
3055
3087
 
@@ -5352,27 +5384,80 @@ function triplesListEqual(xs, ys) {
5352
5384
  return true;
5353
5385
  }
5354
5386
 
5355
- // Alpha-equivalence for quoted formulas, up to *variable* and blank-node renaming.
5387
+ function collectProtectedNamesInTerm(t, protectedVars, protectedBlanks) {
5388
+ if (t instanceof Var) {
5389
+ protectedVars.add(t.name);
5390
+ return;
5391
+ }
5392
+ if (t instanceof Blank) {
5393
+ protectedBlanks.add(t.label);
5394
+ return;
5395
+ }
5396
+ if (t instanceof ListTerm) {
5397
+ for (const e of t.elems) collectProtectedNamesInTerm(e, protectedVars, protectedBlanks);
5398
+ return;
5399
+ }
5400
+ if (t instanceof OpenListTerm) {
5401
+ for (const e of t.prefix) collectProtectedNamesInTerm(e, protectedVars, protectedBlanks);
5402
+ protectedVars.add(t.tailVar);
5403
+ return;
5404
+ }
5405
+ if (t instanceof GraphTerm) {
5406
+ for (const tr of t.triples) {
5407
+ collectProtectedNamesInTerm(tr.s, protectedVars, protectedBlanks);
5408
+ collectProtectedNamesInTerm(tr.p, protectedVars, protectedBlanks);
5409
+ collectProtectedNamesInTerm(tr.o, protectedVars, protectedBlanks);
5410
+ }
5411
+ }
5412
+ }
5413
+
5414
+ function collectProtectedNamesFromSubst(subst) {
5415
+ const protectedVars = new Set();
5416
+ const protectedBlanks = new Set();
5417
+ if (!subst) return { protectedVars, protectedBlanks };
5418
+ for (const k in subst) {
5419
+ if (!Object.prototype.hasOwnProperty.call(subst, k)) continue;
5420
+ collectProtectedNamesInTerm(subst[k], protectedVars, protectedBlanks);
5421
+ }
5422
+ return { protectedVars, protectedBlanks };
5423
+ }
5424
+
5425
+ // Alpha-equivalence for quoted formulas, up to *local* variable and blank-node renaming.
5426
+ // Terms that originate from the surrounding substitution are treated as fixed and are
5427
+ // therefore not alpha-renamable inside the quoted formula.
5356
5428
  // Treats a formula as an unordered set of triples (order-insensitive match).
5357
- function alphaEqVarName(x, y, vmap) {
5429
+ function alphaEqVarName(x, y, vmap, protectedVarsA, protectedVarsB) {
5430
+ const xProtected = protectedVarsA && protectedVarsA.has(x);
5431
+ const yProtected = protectedVarsB && protectedVarsB.has(y);
5432
+ if (xProtected || yProtected) return xProtected && yProtected && x === y;
5358
5433
  if (Object.prototype.hasOwnProperty.call(vmap, x)) return vmap[x] === y;
5359
5434
  vmap[x] = y;
5360
5435
  return true;
5361
5436
  }
5362
5437
 
5363
- function alphaEqTermInGraph(a, b, vmap, bmap) {
5364
- // Blank nodes: renamable
5438
+ function alphaEqBlankLabel(x, y, bmap, protectedBlanksA, protectedBlanksB) {
5439
+ const xProtected = protectedBlanksA && protectedBlanksA.has(x);
5440
+ const yProtected = protectedBlanksB && protectedBlanksB.has(y);
5441
+ if (xProtected || yProtected) return xProtected && yProtected && x === y;
5442
+ if (Object.prototype.hasOwnProperty.call(bmap, x)) return bmap[x] === y;
5443
+ bmap[x] = y;
5444
+ return true;
5445
+ }
5446
+
5447
+ function alphaEqTermInGraph(a, b, vmap, bmap, opts) {
5448
+ const protectedVarsA = opts && opts.protectedVarsA;
5449
+ const protectedVarsB = opts && opts.protectedVarsB;
5450
+ const protectedBlanksA = opts && opts.protectedBlanksA;
5451
+ const protectedBlanksB = opts && opts.protectedBlanksB;
5452
+
5453
+ // Blank nodes: renamable only when they are local to the formula.
5365
5454
  if (a instanceof Blank && b instanceof Blank) {
5366
- const x = a.label;
5367
- const y = b.label;
5368
- if (Object.prototype.hasOwnProperty.call(bmap, x)) return bmap[x] === y;
5369
- bmap[x] = y;
5370
- return true;
5455
+ return alphaEqBlankLabel(a.label, b.label, bmap, protectedBlanksA, protectedBlanksB);
5371
5456
  }
5372
5457
 
5373
- // Variables: renamable (ONLY inside quoted formulas)
5458
+ // Variables: renamable only when they are local to the formula.
5374
5459
  if (a instanceof Var && b instanceof Var) {
5375
- return alphaEqVarName(a.name, b.name, vmap);
5460
+ return alphaEqVarName(a.name, b.name, vmap, protectedVarsA, protectedVarsB);
5376
5461
  }
5377
5462
 
5378
5463
  if (a instanceof Iri && b instanceof Iri) return a.value === b.value;
@@ -5381,7 +5466,7 @@ function alphaEqTermInGraph(a, b, vmap, bmap) {
5381
5466
  if (a instanceof ListTerm && b instanceof ListTerm) {
5382
5467
  if (a.elems.length !== b.elems.length) return false;
5383
5468
  for (let i = 0; i < a.elems.length; i++) {
5384
- if (!alphaEqTermInGraph(a.elems[i], b.elems[i], vmap, bmap)) return false;
5469
+ if (!alphaEqTermInGraph(a.elems[i], b.elems[i], vmap, bmap, opts)) return false;
5385
5470
  }
5386
5471
  return true;
5387
5472
  }
@@ -5389,29 +5474,30 @@ function alphaEqTermInGraph(a, b, vmap, bmap) {
5389
5474
  if (a instanceof OpenListTerm && b instanceof OpenListTerm) {
5390
5475
  if (a.prefix.length !== b.prefix.length) return false;
5391
5476
  for (let i = 0; i < a.prefix.length; i++) {
5392
- if (!alphaEqTermInGraph(a.prefix[i], b.prefix[i], vmap, bmap)) return false;
5477
+ if (!alphaEqTermInGraph(a.prefix[i], b.prefix[i], vmap, bmap, opts)) return false;
5393
5478
  }
5394
- // tailVar is a var-name string, so treat it as renamable too
5395
- return alphaEqVarName(a.tailVar, b.tailVar, vmap);
5479
+ // tailVar is a var-name string, so treat it as renamable too when local.
5480
+ return alphaEqVarName(a.tailVar, b.tailVar, vmap, protectedVarsA, protectedVarsB);
5396
5481
  }
5397
5482
 
5398
- // Nested formulas: compare with fresh maps (separate scope)
5483
+ // Nested formulas: compare with fresh maps (separate scope), but keep the same
5484
+ // protected outer names so already-substituted terms stay fixed everywhere.
5399
5485
  if (a instanceof GraphTerm && b instanceof GraphTerm) {
5400
- return alphaEqGraphTriples(a.triples, b.triples);
5486
+ return alphaEqGraphTriples(a.triples, b.triples, opts);
5401
5487
  }
5402
5488
 
5403
5489
  return false;
5404
5490
  }
5405
5491
 
5406
- function alphaEqTripleInGraph(a, b, vmap, bmap) {
5492
+ function alphaEqTripleInGraph(a, b, vmap, bmap, opts) {
5407
5493
  return (
5408
- alphaEqTermInGraph(a.s, b.s, vmap, bmap) &&
5409
- alphaEqTermInGraph(a.p, b.p, vmap, bmap) &&
5410
- alphaEqTermInGraph(a.o, b.o, vmap, bmap)
5494
+ alphaEqTermInGraph(a.s, b.s, vmap, bmap, opts) &&
5495
+ alphaEqTermInGraph(a.p, b.p, vmap, bmap, opts) &&
5496
+ alphaEqTermInGraph(a.o, b.o, vmap, bmap, opts)
5411
5497
  );
5412
5498
  }
5413
5499
 
5414
- function alphaEqGraphTriples(xs, ys) {
5500
+ function alphaEqGraphTriples(xs, ys, opts) {
5415
5501
  if (xs.length !== ys.length) return false;
5416
5502
  // Fast path: exact same sequence.
5417
5503
  if (triplesListEqual(xs, ys)) return true;
@@ -5430,7 +5516,7 @@ function alphaEqGraphTriples(xs, ys) {
5430
5516
 
5431
5517
  const v2 = { ...vmap };
5432
5518
  const b2 = { ...bmap };
5433
- if (!alphaEqTripleInGraph(x, y, v2, b2)) continue;
5519
+ if (!alphaEqTripleInGraph(x, y, v2, b2, opts)) continue;
5434
5520
 
5435
5521
  used[j] = true;
5436
5522
  if (step(i + 1, v2, b2)) return true;
@@ -5512,7 +5598,16 @@ function tripleFastKey(tr) {
5512
5598
  }
5513
5599
 
5514
5600
  function ensureFactIndexes(facts) {
5515
- if (facts.__byPred && facts.__byPS && facts.__byPO && facts.__keySet) return;
5601
+ if (
5602
+ facts.__byPred &&
5603
+ facts.__byPS &&
5604
+ facts.__byPO &&
5605
+ facts.__wildPred &&
5606
+ facts.__wildPS &&
5607
+ facts.__wildPO &&
5608
+ facts.__keySet
5609
+ )
5610
+ return;
5516
5611
 
5517
5612
  Object.defineProperty(facts, '__byPred', {
5518
5613
  value: new Map(),
@@ -5529,6 +5624,21 @@ function ensureFactIndexes(facts) {
5529
5624
  enumerable: false,
5530
5625
  writable: true,
5531
5626
  });
5627
+ Object.defineProperty(facts, '__wildPred', {
5628
+ value: [],
5629
+ enumerable: false,
5630
+ writable: true,
5631
+ });
5632
+ Object.defineProperty(facts, '__wildPS', {
5633
+ value: new Map(),
5634
+ enumerable: false,
5635
+ writable: true,
5636
+ });
5637
+ Object.defineProperty(facts, '__wildPO', {
5638
+ value: new Map(),
5639
+ enumerable: false,
5640
+ writable: true,
5641
+ });
5532
5642
  Object.defineProperty(facts, '__keySet', {
5533
5643
  value: new Set(),
5534
5644
  enumerable: false,
@@ -5539,6 +5649,9 @@ function ensureFactIndexes(facts) {
5539
5649
  }
5540
5650
 
5541
5651
  function indexFact(facts, tr, idx) {
5652
+ const sk = termFastKey(tr.s);
5653
+ const ok = termFastKey(tr.o);
5654
+
5542
5655
  if (tr.p instanceof Iri) {
5543
5656
  // Use predicate term id as the primary key to avoid hashing long IRI strings.
5544
5657
  const pk = tr.p.__tid;
@@ -5550,7 +5663,6 @@ function indexFact(facts, tr, idx) {
5550
5663
  }
5551
5664
  pb.push(idx);
5552
5665
 
5553
- const sk = termFastKey(tr.s);
5554
5666
  if (sk !== null) {
5555
5667
  let ps = facts.__byPS.get(pk);
5556
5668
  if (!ps) {
@@ -5565,7 +5677,6 @@ function indexFact(facts, tr, idx) {
5565
5677
  psb.push(idx);
5566
5678
  }
5567
5679
 
5568
- const ok = termFastKey(tr.o);
5569
5680
  if (ok !== null) {
5570
5681
  let po = facts.__byPO.get(pk);
5571
5682
  if (!po) {
@@ -5579,6 +5690,26 @@ function indexFact(facts, tr, idx) {
5579
5690
  }
5580
5691
  pob.push(idx);
5581
5692
  }
5693
+ } else {
5694
+ facts.__wildPred.push(idx);
5695
+
5696
+ if (sk !== null) {
5697
+ let psb = facts.__wildPS.get(sk);
5698
+ if (!psb) {
5699
+ psb = [];
5700
+ facts.__wildPS.set(sk, psb);
5701
+ }
5702
+ psb.push(idx);
5703
+ }
5704
+
5705
+ if (ok !== null) {
5706
+ let pob = facts.__wildPO.get(ok);
5707
+ if (!pob) {
5708
+ pob = [];
5709
+ facts.__wildPO.set(ok, pob);
5710
+ }
5711
+ pob.push(idx);
5712
+ }
5582
5713
  }
5583
5714
 
5584
5715
  const key = tripleFastKey(tr);
@@ -5608,11 +5739,30 @@ function candidateFacts(facts, goal) {
5608
5739
  if (po) byPO = po.get(ok) || null;
5609
5740
  }
5610
5741
 
5611
- if (byPS && byPO) return byPS.length <= byPO.length ? byPS : byPO;
5612
- if (byPS) return byPS;
5613
- if (byPO) return byPO;
5742
+ let exact = null;
5743
+ if (byPS && byPO) exact = byPS.length <= byPO.length ? byPS : byPO;
5744
+ else if (byPS) exact = byPS;
5745
+ else if (byPO) exact = byPO;
5746
+ else exact = facts.__byPred.get(pk) || null;
5614
5747
 
5615
- return facts.__byPred.get(pk) || [];
5748
+ /** @type {number[] | null} */
5749
+ let wildPS = null;
5750
+ if (sk !== null) wildPS = facts.__wildPS.get(sk) || null;
5751
+
5752
+ /** @type {number[] | null} */
5753
+ let wildPO = null;
5754
+ if (ok !== null) wildPO = facts.__wildPO.get(ok) || null;
5755
+
5756
+ let wild = null;
5757
+ if (wildPS && wildPO) wild = wildPS.length <= wildPO.length ? wildPS : wildPO;
5758
+ else if (wildPS) wild = wildPS;
5759
+ else if (wildPO) wild = wildPO;
5760
+ else wild = facts.__wildPred.length ? facts.__wildPred : null;
5761
+
5762
+ if (exact && wild) return exact.concat(wild);
5763
+ if (exact) return exact;
5764
+ if (wild) return wild;
5765
+ return [];
5616
5766
  }
5617
5767
 
5618
5768
  return null;
@@ -5767,7 +5917,13 @@ function __goalMemoKey(goals, subst, facts, opts) {
5767
5917
  const mode = opts && opts.deferBuiltins ? 'D1' : 'D0';
5768
5918
  const scopedLevel = facts && typeof facts.__scopedClosureLevel === 'number' ? facts.__scopedClosureLevel : 0;
5769
5919
  const scopedTag = facts && facts.__scopedSnapshot ? 'S' : 'N';
5770
- return `${mode}|${scopedTag}|${scopedLevel}|${parts.join('\n')}`;
5920
+ let keepVarsTag = '';
5921
+ if (opts && opts.keepVars) {
5922
+ const keepVars = Array.isArray(opts.keepVars) ? opts.keepVars.slice() : Array.from(opts.keepVars);
5923
+ keepVars.sort();
5924
+ keepVarsTag = `|K:${keepVars.join(',')}`;
5925
+ }
5926
+ return `${mode}|${scopedTag}|${scopedLevel}${keepVarsTag}|${parts.join('\n')}`;
5771
5927
  }
5772
5928
 
5773
5929
  function __cloneGoalSolutions(solutions) {
@@ -6149,7 +6305,17 @@ function unifyTermWithOptions(a, b, subst, opts) {
6149
6305
 
6150
6306
  // Graphs
6151
6307
  if (a instanceof GraphTerm && b instanceof GraphTerm) {
6152
- if (alphaEqGraphTriples(a.triples, b.triples)) return subst;
6308
+ const protectedNames = collectProtectedNamesFromSubst(subst);
6309
+ if (
6310
+ alphaEqGraphTriples(a.triples, b.triples, {
6311
+ protectedVarsA: protectedNames.protectedVars,
6312
+ protectedVarsB: protectedNames.protectedVars,
6313
+ protectedBlanksA: protectedNames.protectedBlanks,
6314
+ protectedBlanksB: protectedNames.protectedBlanks,
6315
+ })
6316
+ ) {
6317
+ return subst;
6318
+ }
6153
6319
  return unifyGraphTriples(a.triples, b.triples, subst);
6154
6320
  }
6155
6321
 
@@ -6299,6 +6465,9 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
6299
6465
  // Variables from the original goal list (needed by the caller to instantiate conclusions)
6300
6466
  const answerVars = new Set();
6301
6467
  gcCollectVarsInGoals(initialGoals, answerVars);
6468
+ if (opts && opts.keepVars) {
6469
+ for (const v of opts.keepVars) answerVars.add(v);
6470
+ }
6302
6471
 
6303
6472
  if (!initialGoals.length) {
6304
6473
  results.push(gcCompactForGoals(substMut, [], answerVars));
@@ -6521,7 +6690,17 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
6521
6690
 
6522
6691
  // Graphs
6523
6692
  if (a instanceof GraphTerm && b instanceof GraphTerm) {
6524
- if (alphaEqGraphTriples(a.triples, b.triples)) return true;
6693
+ const protectedNames = collectProtectedNamesFromSubst(substMut);
6694
+ if (
6695
+ alphaEqGraphTriples(a.triples, b.triples, {
6696
+ protectedVarsA: protectedNames.protectedVars,
6697
+ protectedVarsB: protectedNames.protectedVars,
6698
+ protectedBlanksA: protectedNames.protectedBlanks,
6699
+ protectedBlanksB: protectedNames.protectedBlanks,
6700
+ })
6701
+ ) {
6702
+ return true;
6703
+ }
6525
6704
  // Fallback: reuse allocation-heavy graph unifier rarely hit in typical workloads.
6526
6705
  const delta = unifyGraphTriples(a.triples, b.triples, {});
6527
6706
  if (delta === null) return false;
package/lib/builtins.js CHANGED
@@ -98,6 +98,35 @@ function makeBuiltins(deps) {
98
98
  return { evalBuiltin, isBuiltinPred };
99
99
  }
100
100
 
101
+ function __builtinCollectVarsInTerm(t, out) {
102
+ if (t instanceof Var) {
103
+ out.add(t.name);
104
+ return;
105
+ }
106
+ if (t instanceof ListTerm) {
107
+ for (const e of t.elems) __builtinCollectVarsInTerm(e, out);
108
+ return;
109
+ }
110
+ if (t instanceof OpenListTerm) {
111
+ for (const e of t.prefix) __builtinCollectVarsInTerm(e, out);
112
+ out.add(t.tailVar);
113
+ return;
114
+ }
115
+ if (t instanceof GraphTerm) {
116
+ for (const tr of t.triples) __builtinCollectVarsInTriple(tr, out);
117
+ }
118
+ }
119
+
120
+ function __builtinCollectVarsInTriple(tr, out) {
121
+ __builtinCollectVarsInTerm(tr.s, out);
122
+ __builtinCollectVarsInTerm(tr.p, out);
123
+ __builtinCollectVarsInTerm(tr.o, out);
124
+ }
125
+
126
+ function __builtinCollectVarsInTriples(triples, out) {
127
+ for (const tr of triples) __builtinCollectVarsInTriple(tr, out);
128
+ }
129
+
101
130
  function literalHasLangTag(lit) {
102
131
  // True iff the literal is a quoted string literal with a language tag suffix,
103
132
  // e.g. "hello"@en or """hello"""@en.
@@ -3028,6 +3057,8 @@ function evalBuiltin(goal, subst, facts, backRules, depth, varGen, maxResults) {
3028
3057
  if (!(g.o instanceof GraphTerm)) return [];
3029
3058
 
3030
3059
  const visited2 = [];
3060
+ const keepVars = new Set();
3061
+ if (g.s instanceof GraphTerm) __builtinCollectVarsInTriples(g.s.triples, keepVars);
3031
3062
  // Start from the incoming substitution so bindings flow outward.
3032
3063
  return proveGoals(
3033
3064
  Array.from(g.o.triples),
@@ -3038,6 +3069,7 @@ function evalBuiltin(goal, subst, facts, backRules, depth, varGen, maxResults) {
3038
3069
  visited2,
3039
3070
  varGen,
3040
3071
  maxResults,
3072
+ keepVars.size ? { keepVars } : undefined,
3041
3073
  );
3042
3074
  }
3043
3075
 
package/lib/engine.js CHANGED
@@ -736,27 +736,80 @@ function triplesListEqual(xs, ys) {
736
736
  return true;
737
737
  }
738
738
 
739
- // Alpha-equivalence for quoted formulas, up to *variable* and blank-node renaming.
739
+ function collectProtectedNamesInTerm(t, protectedVars, protectedBlanks) {
740
+ if (t instanceof Var) {
741
+ protectedVars.add(t.name);
742
+ return;
743
+ }
744
+ if (t instanceof Blank) {
745
+ protectedBlanks.add(t.label);
746
+ return;
747
+ }
748
+ if (t instanceof ListTerm) {
749
+ for (const e of t.elems) collectProtectedNamesInTerm(e, protectedVars, protectedBlanks);
750
+ return;
751
+ }
752
+ if (t instanceof OpenListTerm) {
753
+ for (const e of t.prefix) collectProtectedNamesInTerm(e, protectedVars, protectedBlanks);
754
+ protectedVars.add(t.tailVar);
755
+ return;
756
+ }
757
+ if (t instanceof GraphTerm) {
758
+ for (const tr of t.triples) {
759
+ collectProtectedNamesInTerm(tr.s, protectedVars, protectedBlanks);
760
+ collectProtectedNamesInTerm(tr.p, protectedVars, protectedBlanks);
761
+ collectProtectedNamesInTerm(tr.o, protectedVars, protectedBlanks);
762
+ }
763
+ }
764
+ }
765
+
766
+ function collectProtectedNamesFromSubst(subst) {
767
+ const protectedVars = new Set();
768
+ const protectedBlanks = new Set();
769
+ if (!subst) return { protectedVars, protectedBlanks };
770
+ for (const k in subst) {
771
+ if (!Object.prototype.hasOwnProperty.call(subst, k)) continue;
772
+ collectProtectedNamesInTerm(subst[k], protectedVars, protectedBlanks);
773
+ }
774
+ return { protectedVars, protectedBlanks };
775
+ }
776
+
777
+ // Alpha-equivalence for quoted formulas, up to *local* variable and blank-node renaming.
778
+ // Terms that originate from the surrounding substitution are treated as fixed and are
779
+ // therefore not alpha-renamable inside the quoted formula.
740
780
  // Treats a formula as an unordered set of triples (order-insensitive match).
741
- function alphaEqVarName(x, y, vmap) {
781
+ function alphaEqVarName(x, y, vmap, protectedVarsA, protectedVarsB) {
782
+ const xProtected = protectedVarsA && protectedVarsA.has(x);
783
+ const yProtected = protectedVarsB && protectedVarsB.has(y);
784
+ if (xProtected || yProtected) return xProtected && yProtected && x === y;
742
785
  if (Object.prototype.hasOwnProperty.call(vmap, x)) return vmap[x] === y;
743
786
  vmap[x] = y;
744
787
  return true;
745
788
  }
746
789
 
747
- function alphaEqTermInGraph(a, b, vmap, bmap) {
748
- // Blank nodes: renamable
790
+ function alphaEqBlankLabel(x, y, bmap, protectedBlanksA, protectedBlanksB) {
791
+ const xProtected = protectedBlanksA && protectedBlanksA.has(x);
792
+ const yProtected = protectedBlanksB && protectedBlanksB.has(y);
793
+ if (xProtected || yProtected) return xProtected && yProtected && x === y;
794
+ if (Object.prototype.hasOwnProperty.call(bmap, x)) return bmap[x] === y;
795
+ bmap[x] = y;
796
+ return true;
797
+ }
798
+
799
+ function alphaEqTermInGraph(a, b, vmap, bmap, opts) {
800
+ const protectedVarsA = opts && opts.protectedVarsA;
801
+ const protectedVarsB = opts && opts.protectedVarsB;
802
+ const protectedBlanksA = opts && opts.protectedBlanksA;
803
+ const protectedBlanksB = opts && opts.protectedBlanksB;
804
+
805
+ // Blank nodes: renamable only when they are local to the formula.
749
806
  if (a instanceof Blank && b instanceof Blank) {
750
- const x = a.label;
751
- const y = b.label;
752
- if (Object.prototype.hasOwnProperty.call(bmap, x)) return bmap[x] === y;
753
- bmap[x] = y;
754
- return true;
807
+ return alphaEqBlankLabel(a.label, b.label, bmap, protectedBlanksA, protectedBlanksB);
755
808
  }
756
809
 
757
- // Variables: renamable (ONLY inside quoted formulas)
810
+ // Variables: renamable only when they are local to the formula.
758
811
  if (a instanceof Var && b instanceof Var) {
759
- return alphaEqVarName(a.name, b.name, vmap);
812
+ return alphaEqVarName(a.name, b.name, vmap, protectedVarsA, protectedVarsB);
760
813
  }
761
814
 
762
815
  if (a instanceof Iri && b instanceof Iri) return a.value === b.value;
@@ -765,7 +818,7 @@ function alphaEqTermInGraph(a, b, vmap, bmap) {
765
818
  if (a instanceof ListTerm && b instanceof ListTerm) {
766
819
  if (a.elems.length !== b.elems.length) return false;
767
820
  for (let i = 0; i < a.elems.length; i++) {
768
- if (!alphaEqTermInGraph(a.elems[i], b.elems[i], vmap, bmap)) return false;
821
+ if (!alphaEqTermInGraph(a.elems[i], b.elems[i], vmap, bmap, opts)) return false;
769
822
  }
770
823
  return true;
771
824
  }
@@ -773,29 +826,30 @@ function alphaEqTermInGraph(a, b, vmap, bmap) {
773
826
  if (a instanceof OpenListTerm && b instanceof OpenListTerm) {
774
827
  if (a.prefix.length !== b.prefix.length) return false;
775
828
  for (let i = 0; i < a.prefix.length; i++) {
776
- if (!alphaEqTermInGraph(a.prefix[i], b.prefix[i], vmap, bmap)) return false;
829
+ if (!alphaEqTermInGraph(a.prefix[i], b.prefix[i], vmap, bmap, opts)) return false;
777
830
  }
778
- // tailVar is a var-name string, so treat it as renamable too
779
- return alphaEqVarName(a.tailVar, b.tailVar, vmap);
831
+ // tailVar is a var-name string, so treat it as renamable too when local.
832
+ return alphaEqVarName(a.tailVar, b.tailVar, vmap, protectedVarsA, protectedVarsB);
780
833
  }
781
834
 
782
- // Nested formulas: compare with fresh maps (separate scope)
835
+ // Nested formulas: compare with fresh maps (separate scope), but keep the same
836
+ // protected outer names so already-substituted terms stay fixed everywhere.
783
837
  if (a instanceof GraphTerm && b instanceof GraphTerm) {
784
- return alphaEqGraphTriples(a.triples, b.triples);
838
+ return alphaEqGraphTriples(a.triples, b.triples, opts);
785
839
  }
786
840
 
787
841
  return false;
788
842
  }
789
843
 
790
- function alphaEqTripleInGraph(a, b, vmap, bmap) {
844
+ function alphaEqTripleInGraph(a, b, vmap, bmap, opts) {
791
845
  return (
792
- alphaEqTermInGraph(a.s, b.s, vmap, bmap) &&
793
- alphaEqTermInGraph(a.p, b.p, vmap, bmap) &&
794
- alphaEqTermInGraph(a.o, b.o, vmap, bmap)
846
+ alphaEqTermInGraph(a.s, b.s, vmap, bmap, opts) &&
847
+ alphaEqTermInGraph(a.p, b.p, vmap, bmap, opts) &&
848
+ alphaEqTermInGraph(a.o, b.o, vmap, bmap, opts)
795
849
  );
796
850
  }
797
851
 
798
- function alphaEqGraphTriples(xs, ys) {
852
+ function alphaEqGraphTriples(xs, ys, opts) {
799
853
  if (xs.length !== ys.length) return false;
800
854
  // Fast path: exact same sequence.
801
855
  if (triplesListEqual(xs, ys)) return true;
@@ -814,7 +868,7 @@ function alphaEqGraphTriples(xs, ys) {
814
868
 
815
869
  const v2 = { ...vmap };
816
870
  const b2 = { ...bmap };
817
- if (!alphaEqTripleInGraph(x, y, v2, b2)) continue;
871
+ if (!alphaEqTripleInGraph(x, y, v2, b2, opts)) continue;
818
872
 
819
873
  used[j] = true;
820
874
  if (step(i + 1, v2, b2)) return true;
@@ -896,7 +950,16 @@ function tripleFastKey(tr) {
896
950
  }
897
951
 
898
952
  function ensureFactIndexes(facts) {
899
- if (facts.__byPred && facts.__byPS && facts.__byPO && facts.__keySet) return;
953
+ if (
954
+ facts.__byPred &&
955
+ facts.__byPS &&
956
+ facts.__byPO &&
957
+ facts.__wildPred &&
958
+ facts.__wildPS &&
959
+ facts.__wildPO &&
960
+ facts.__keySet
961
+ )
962
+ return;
900
963
 
901
964
  Object.defineProperty(facts, '__byPred', {
902
965
  value: new Map(),
@@ -913,6 +976,21 @@ function ensureFactIndexes(facts) {
913
976
  enumerable: false,
914
977
  writable: true,
915
978
  });
979
+ Object.defineProperty(facts, '__wildPred', {
980
+ value: [],
981
+ enumerable: false,
982
+ writable: true,
983
+ });
984
+ Object.defineProperty(facts, '__wildPS', {
985
+ value: new Map(),
986
+ enumerable: false,
987
+ writable: true,
988
+ });
989
+ Object.defineProperty(facts, '__wildPO', {
990
+ value: new Map(),
991
+ enumerable: false,
992
+ writable: true,
993
+ });
916
994
  Object.defineProperty(facts, '__keySet', {
917
995
  value: new Set(),
918
996
  enumerable: false,
@@ -923,6 +1001,9 @@ function ensureFactIndexes(facts) {
923
1001
  }
924
1002
 
925
1003
  function indexFact(facts, tr, idx) {
1004
+ const sk = termFastKey(tr.s);
1005
+ const ok = termFastKey(tr.o);
1006
+
926
1007
  if (tr.p instanceof Iri) {
927
1008
  // Use predicate term id as the primary key to avoid hashing long IRI strings.
928
1009
  const pk = tr.p.__tid;
@@ -934,7 +1015,6 @@ function indexFact(facts, tr, idx) {
934
1015
  }
935
1016
  pb.push(idx);
936
1017
 
937
- const sk = termFastKey(tr.s);
938
1018
  if (sk !== null) {
939
1019
  let ps = facts.__byPS.get(pk);
940
1020
  if (!ps) {
@@ -949,7 +1029,6 @@ function indexFact(facts, tr, idx) {
949
1029
  psb.push(idx);
950
1030
  }
951
1031
 
952
- const ok = termFastKey(tr.o);
953
1032
  if (ok !== null) {
954
1033
  let po = facts.__byPO.get(pk);
955
1034
  if (!po) {
@@ -963,6 +1042,26 @@ function indexFact(facts, tr, idx) {
963
1042
  }
964
1043
  pob.push(idx);
965
1044
  }
1045
+ } else {
1046
+ facts.__wildPred.push(idx);
1047
+
1048
+ if (sk !== null) {
1049
+ let psb = facts.__wildPS.get(sk);
1050
+ if (!psb) {
1051
+ psb = [];
1052
+ facts.__wildPS.set(sk, psb);
1053
+ }
1054
+ psb.push(idx);
1055
+ }
1056
+
1057
+ if (ok !== null) {
1058
+ let pob = facts.__wildPO.get(ok);
1059
+ if (!pob) {
1060
+ pob = [];
1061
+ facts.__wildPO.set(ok, pob);
1062
+ }
1063
+ pob.push(idx);
1064
+ }
966
1065
  }
967
1066
 
968
1067
  const key = tripleFastKey(tr);
@@ -992,11 +1091,30 @@ function candidateFacts(facts, goal) {
992
1091
  if (po) byPO = po.get(ok) || null;
993
1092
  }
994
1093
 
995
- if (byPS && byPO) return byPS.length <= byPO.length ? byPS : byPO;
996
- if (byPS) return byPS;
997
- if (byPO) return byPO;
1094
+ let exact = null;
1095
+ if (byPS && byPO) exact = byPS.length <= byPO.length ? byPS : byPO;
1096
+ else if (byPS) exact = byPS;
1097
+ else if (byPO) exact = byPO;
1098
+ else exact = facts.__byPred.get(pk) || null;
1099
+
1100
+ /** @type {number[] | null} */
1101
+ let wildPS = null;
1102
+ if (sk !== null) wildPS = facts.__wildPS.get(sk) || null;
1103
+
1104
+ /** @type {number[] | null} */
1105
+ let wildPO = null;
1106
+ if (ok !== null) wildPO = facts.__wildPO.get(ok) || null;
1107
+
1108
+ let wild = null;
1109
+ if (wildPS && wildPO) wild = wildPS.length <= wildPO.length ? wildPS : wildPO;
1110
+ else if (wildPS) wild = wildPS;
1111
+ else if (wildPO) wild = wildPO;
1112
+ else wild = facts.__wildPred.length ? facts.__wildPred : null;
998
1113
 
999
- return facts.__byPred.get(pk) || [];
1114
+ if (exact && wild) return exact.concat(wild);
1115
+ if (exact) return exact;
1116
+ if (wild) return wild;
1117
+ return [];
1000
1118
  }
1001
1119
 
1002
1120
  return null;
@@ -1151,7 +1269,13 @@ function __goalMemoKey(goals, subst, facts, opts) {
1151
1269
  const mode = opts && opts.deferBuiltins ? 'D1' : 'D0';
1152
1270
  const scopedLevel = facts && typeof facts.__scopedClosureLevel === 'number' ? facts.__scopedClosureLevel : 0;
1153
1271
  const scopedTag = facts && facts.__scopedSnapshot ? 'S' : 'N';
1154
- return `${mode}|${scopedTag}|${scopedLevel}|${parts.join('\n')}`;
1272
+ let keepVarsTag = '';
1273
+ if (opts && opts.keepVars) {
1274
+ const keepVars = Array.isArray(opts.keepVars) ? opts.keepVars.slice() : Array.from(opts.keepVars);
1275
+ keepVars.sort();
1276
+ keepVarsTag = `|K:${keepVars.join(',')}`;
1277
+ }
1278
+ return `${mode}|${scopedTag}|${scopedLevel}${keepVarsTag}|${parts.join('\n')}`;
1155
1279
  }
1156
1280
 
1157
1281
  function __cloneGoalSolutions(solutions) {
@@ -1533,7 +1657,17 @@ function unifyTermWithOptions(a, b, subst, opts) {
1533
1657
 
1534
1658
  // Graphs
1535
1659
  if (a instanceof GraphTerm && b instanceof GraphTerm) {
1536
- if (alphaEqGraphTriples(a.triples, b.triples)) return subst;
1660
+ const protectedNames = collectProtectedNamesFromSubst(subst);
1661
+ if (
1662
+ alphaEqGraphTriples(a.triples, b.triples, {
1663
+ protectedVarsA: protectedNames.protectedVars,
1664
+ protectedVarsB: protectedNames.protectedVars,
1665
+ protectedBlanksA: protectedNames.protectedBlanks,
1666
+ protectedBlanksB: protectedNames.protectedBlanks,
1667
+ })
1668
+ ) {
1669
+ return subst;
1670
+ }
1537
1671
  return unifyGraphTriples(a.triples, b.triples, subst);
1538
1672
  }
1539
1673
 
@@ -1683,6 +1817,9 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
1683
1817
  // Variables from the original goal list (needed by the caller to instantiate conclusions)
1684
1818
  const answerVars = new Set();
1685
1819
  gcCollectVarsInGoals(initialGoals, answerVars);
1820
+ if (opts && opts.keepVars) {
1821
+ for (const v of opts.keepVars) answerVars.add(v);
1822
+ }
1686
1823
 
1687
1824
  if (!initialGoals.length) {
1688
1825
  results.push(gcCompactForGoals(substMut, [], answerVars));
@@ -1905,7 +2042,17 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
1905
2042
 
1906
2043
  // Graphs
1907
2044
  if (a instanceof GraphTerm && b instanceof GraphTerm) {
1908
- if (alphaEqGraphTriples(a.triples, b.triples)) return true;
2045
+ const protectedNames = collectProtectedNamesFromSubst(substMut);
2046
+ if (
2047
+ alphaEqGraphTriples(a.triples, b.triples, {
2048
+ protectedVarsA: protectedNames.protectedVars,
2049
+ protectedVarsB: protectedNames.protectedVars,
2050
+ protectedBlanksA: protectedNames.protectedBlanks,
2051
+ protectedBlanksB: protectedNames.protectedBlanks,
2052
+ })
2053
+ ) {
2054
+ return true;
2055
+ }
1909
2056
  // Fallback: reuse allocation-heavy graph unifier rarely hit in typical workloads.
1910
2057
  const delta = unifyGraphTriples(a.triples, b.triples, {});
1911
2058
  if (delta === null) return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyeling",
3
- "version": "1.15.9",
3
+ "version": "1.15.11",
4
4
  "description": "A minimal Notation3 (N3) reasoner in JavaScript.",
5
5
  "main": "./index.js",
6
6
  "keywords": [
package/test/api.test.js CHANGED
@@ -1432,6 +1432,73 @@ ex:w a ex:Woman .
1432
1432
  `,
1433
1433
  expect: [/:(?:test)\s+:(?:is)\s+true\s*\./],
1434
1434
  },
1435
+
1436
+ {
1437
+ name: '59 regression: quoted-formula alpha-equivalence must not rename blanks introduced by outer substitution',
1438
+ opt: { proofComments: false },
1439
+ input: `@prefix : <http://example.org/> .
1440
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
1441
+ @prefix math: <http://www.w3.org/2000/10/swap/math#> .
1442
+
1443
+ _:x :hates { _:foo :making :mess }.
1444
+
1445
+ {
1446
+ ?A :hates { ?A :making :mess }.
1447
+ }
1448
+ =>
1449
+ {
1450
+ ?A :hates :Himself.
1451
+ }.
1452
+
1453
+ {
1454
+ ?A :hates :Himself.
1455
+ }
1456
+ =>
1457
+ {
1458
+ :test :is false.
1459
+ }.
1460
+ `,
1461
+ notExpect: [/:(?:test)\s+:(?:is)\s+false\s*\./],
1462
+ },
1463
+
1464
+ {
1465
+ name: '60 regression: log:includes must match quoted triples with variable predicates',
1466
+ opt: { proofComments: false },
1467
+ input: `@prefix : <http://example.org/> .
1468
+ @prefix log: <http://www.w3.org/2000/10/swap/log#> .
1469
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
1470
+ @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
1471
+ @base <http://example.org/>.
1472
+
1473
+ {
1474
+ { ?X ?Y ?Z. } log:includes { :a :b :c. }.
1475
+ }
1476
+ =>
1477
+ {
1478
+ ?X ?Y ?Z.
1479
+ {
1480
+ :a :b :c.
1481
+ }
1482
+ =>
1483
+ {
1484
+ :result :has :success-literal-3.
1485
+ }.
1486
+ }.
1487
+
1488
+ { } => {
1489
+ :test :contains :success-literal-3.
1490
+ }.
1491
+
1492
+ {
1493
+ :result :has :success-literal-3.
1494
+ }
1495
+ =>
1496
+ {
1497
+ :test :is true.
1498
+ }.
1499
+ `,
1500
+ expect: [/:(?:test)\s+:(?:contains)\s+:(?:success-literal-3)\s*\./, /:(?:test)\s+:(?:is)\s+true\s*\./],
1501
+ },
1435
1502
  ];
1436
1503
 
1437
1504
  let passed = 0;