eyeling 1.22.12 → 1.22.14

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
@@ -3,7 +3,7 @@
3
3
  ## A compact Notation3 reasoner in JavaScript — a handbook
4
4
 
5
5
  > This handbook is written for a computer science student who wants to understand Eyeling as _code_ and as a _reasoning machine_.
6
- > It’s meant to be read linearly, but each chapter stands on its own.
6
+ > It is meant to be read linearly, but each chapter stands on its own.
7
7
 
8
8
  ## Contents
9
9
 
@@ -34,6 +34,7 @@
34
34
  - [Appendix G — Eyeling and the W3C CG Notation3 Semantics](#app-g)
35
35
  - [Appendix H — Applied Constructor-Theory and the N3 ARC examples](#app-h)
36
36
  - [Appendix I — The Eyeling Playground](#app-i)
37
+ - [Appendix J — Formalism Is Fine](#app-j)
37
38
 
38
39
  ---
39
40
 
@@ -48,7 +49,7 @@ Eyeling is a small Notation3 (N3) reasoner implemented in JavaScript. Its job is
48
49
 
49
50
  and compute consequences until nothing new follows.
50
51
 
51
- If you’ve seen Datalog or Prolog, the shape will feel familiar. Eyeling blends both:
52
+ If you have seen Datalog or Prolog, the shape will feel familiar. Eyeling blends both:
52
53
 
53
54
  - **Forward chaining** (like Datalog saturation) for `=>` rules.
54
55
  - **Backward chaining** (like Prolog goal solving) for `<=` rules _and_ for built-in predicates.
@@ -139,7 +140,7 @@ If you want to follow the code in the same order Eyeling “thinks”, read:
139
140
  10. `lib/cli.js` + `lib/entry.js` — command-line wiring and bundle entry exports.
140
141
  11. `index.js` — the npm API wrapper (spawns the bundled CLI synchronously).
141
142
 
142
- This is almost literally a tiny compiler pipeline:
143
+ This is very nearly a tiny compiler pipeline:
143
144
 
144
145
  ```
145
146
 
@@ -200,7 +201,7 @@ In addition, interned **Iri**/**Literal** terms (and generated **Blank** terms)
200
201
 
201
202
  For blanks, the id is derived from the blank label (so different blank labels remain different existentials).
202
203
 
203
- Terms are treated as immutable: once interned/created, the code assumes you won’t mutate `.value` (or `.label` for blanks).
204
+ Terms are treated as immutable: once interned/created, the code assumes you will not mutate `.value` (or `.label` for blanks).
204
205
 
205
206
  ### 3.4 Prefix environment
206
207
 
@@ -246,7 +247,7 @@ The parser supports:
246
247
  - keyword-ish sugar like `is ... of` and inverse arrows
247
248
  - path operators `!` and `^` that may generate helper triples via fresh blanks
248
249
 
249
- A nice detail: the parser maintains a `pendingTriples` list used when certain syntactic forms expand into helper triples (for example, some path/property-list expansions). It ensures the “surface statement” still emits all required triples even if the subject itself was syntactic sugar.
250
+ A useful detail: the parser maintains a `pendingTriples` list used when certain syntactic forms expand into helper triples (for example, some path/property-list expansions). It ensures the “surface statement” still emits all required triples even if the subject itself was syntactic sugar.
250
251
 
251
252
  ### 4.3 Parsing rules: `=>`, `<=`, and log idioms
252
253
 
@@ -287,7 +288,7 @@ Internally:
287
288
 
288
289
  ## Chapter 5 — Rule normalization: “compile-time” semantics (`lib/rules.js`)
289
290
 
290
- Before rules hit the engine, Eyeling performs one lightweight transformation. A second “make it work” trick—deferring built-ins that can’t run yet—happens later inside the goal prover.
291
+ Before rules hit the engine, Eyeling performs one lightweight transformation. A second “make it work” trick—deferring built-ins that cannot run yet—happens later inside the goal prover.
291
292
 
292
293
  ### 5.1 Lifting blank nodes in rule bodies into variables
293
294
 
@@ -309,9 +310,35 @@ This avoids the “existential in the body” trap and matches how most rule aut
309
310
 
310
311
  Blanks in the **conclusion** are _not_ lifted — they remain blanks and later become existentials (Chapter 9).
311
312
 
312
- ### 5.1.1 Quoted formulas keep their own blank-node scope
313
+ ### 5.1.1 Quoted formulas in rule bodies: direct pattern positions vs nested data positions
313
314
 
314
- There is one important exception to the “lift blanks in rule bodies” rule: **do not descend into a quoted formula** (`GraphTerm`) and lift the blanks that appear _inside_ it.
315
+ There is one important refinement to the “lift blanks in rule bodies” rule when a rule body mentions a quoted formula (`GraphTerm`).
316
+
317
+ Eyeling now distinguishes **direct quoted-formula positions** from **nested quoted-formula data**.
318
+
319
+ #### Direct quoted-formula positions in a premise triple
320
+
321
+ When a quoted formula appears **directly** as the subject, predicate, or object term of a premise triple, Eyeling treats blank nodes inside that quoted formula as **rule-body placeholders** and lifts them to rule variables.
322
+
323
+ Example:
324
+
325
+ ```n3
326
+ { :A :B :C } a :Statement.
327
+
328
+ {
329
+ { _:X :B :C } a :Statement.
330
+ } => {
331
+ :result :is true.
332
+ }.
333
+ ```
334
+
335
+ This matches and derives `:result :is true.` because the direct quoted formula `{ _:X :B :C }` is being used as a **pattern-bearing term** in the premise triple.
336
+
337
+ This behavior is mainly for interoperability with engines that treat blank nodes in such direct quoted-formula premise positions as pattern placeholders.
338
+
339
+ #### Nested quoted formulas remain data
340
+
341
+ If the quoted formula is nested **inside another term** in the rule body — for example inside a list used by `log:conjunction` — Eyeling preserves the quoted formula’s own blank-node scope.
315
342
 
316
343
  So this rule body:
317
344
 
@@ -321,24 +348,24 @@ So this rule body:
321
348
  } => { ... }.
322
349
  ```
323
350
 
324
- must keep the inner `[]` as a **formula-local blank node**. Eyeling should treat it as belonging to the quoted graph, not as a rule-body variable that escapes into the surrounding rule.
351
+ must keep the inner `[]` as a **formula-local blank node**. Eyeling treats it as belonging to the quoted graph, not as a rule-body variable that escapes into the surrounding rule.
325
352
 
326
- That distinction matters because quoted formulas play **two different roles** in Eyeling:
353
+ That distinction matters because quoted formulas still play **two different roles** in Eyeling:
327
354
 
328
- 1. **Formula as data** — for example when constructing a formula with `log:conjunction` or storing `{ ... }` in a triple. In this role, local blanks stay blanks. They print as blank nodes and participate in alpha-equivalence only within that quoted formula.
329
- 2. **Formula as a query pattern** — for example when `log:includes`, `log:notIncludes`, `log:collectAllIn`, or `log:forAllIn` prove a quoted formula. In that role, the builtin may treat the formula’s **local blanks existentially** while matching.
355
+ 1. **Formula as data** — for example when constructing a formula with `log:conjunction` or storing `{ ... }` inside another data term. In this role, local blanks stay blanks. They print as blank nodes and participate in alpha-equivalence only within that quoted formula.
356
+ 2. **Formula as a query pattern** — either through query-like builtins such as `log:includes`, `log:notIncludes`, `log:collectAllIn`, or `log:forAllIn`, or through a **direct quoted-formula premise position** as described above. In that role, the formula’s local blanks may be treated existentially while matching.
330
357
 
331
358
  The practical rule is:
332
359
 
333
- > **Rule normalization preserves blank-node scope inside quoted formulas; builtins may later interpret those preserved blanks as existential query placeholders when the formula is used as a pattern.**
360
+ > **Eyeling lifts blanks inside quoted formulas only when the quoted formula appears directly in a premise triple position. Nested quoted formulas remain scoped data unless a query-like builtin interprets them as patterns.**
334
361
 
335
- This separation is deliberate. It keeps `log:conjunction` and formula printing honest, while still allowing query-like builtins to match formulas containing local `[]` placeholders.
362
+ This keeps `log:conjunction` and formula printing honest, while still allowing direct quoted-formula premise patterns such as `{ _:X :B :C } a :Statement.` to match interoperably.
336
363
 
337
364
  ### 5.2 Builtin deferral in forward-rule bodies
338
365
 
339
- In a depth-first proof, the order of goals matters. Many built-ins only become informative once parts of the triple are **already instantiated** (for example comparisons, pattern tests, and other built-ins that don’t normally create bindings).
366
+ In a depth-first proof, the order of goals matters. Many built-ins only become informative once parts of the triple are **already instantiated** (for example comparisons, pattern tests, and other built-ins that do not normally create bindings).
340
367
 
341
- If such a builtin runs while its subject/object still contain variables or blanks, it may return **no solutions** (because it can’t decide yet) or only the **empty delta** (`{}`), even though it would succeed (or fail) once other goals have bound the needed values.
368
+ If such a builtin runs while its subject/object still contain variables or blanks, it may return **no solutions** (because it cannot decide yet) or only the **empty delta** (`{}`), even though it would succeed (or fail) once other goals have bound the needed values.
342
369
 
343
370
  Eyeling supports a runtime deferral mechanism inside `proveGoals(...)`, enabled only when proving the bodies of forward rules.
344
371
 
@@ -351,9 +378,9 @@ What happens when `proveGoals(..., { deferBuiltins: true })` sees a builtin goal
351
378
  - the goal list hasn’t already been rotated too many times,
352
379
  - then Eyeling **rotates that builtin goal to the end** of the current goal list and continues with the next goal first.
353
380
 
354
- A small counter (`deferCount`) caps how many rotations can happen (at most the length of the current goal list), so the prover can’t loop forever by endlessly “trying later”.
381
+ A small counter (`deferCount`) caps how many rotations can happen (at most the length of the current goal list), so the prover cannot loop forever by endlessly “trying later”.
355
382
 
356
- There is one extra guard for a small whitelist of built-ins that are considered satisfiable even when both subject and object are completely unbound (see `__builtinIsSatisfiableWhenFullyUnbound`). For these, if evaluation yields no deltas and there is nothing left to bind (either it is the last goal, or deferral has already been exhausted), Eyeling treats the builtin as a vacuous success (`[{}]`) so it doesn’t block the proof.
383
+ There is one extra guard for a small whitelist of built-ins that are considered satisfiable even when both subject and object are completely unbound (see `__builtinIsSatisfiableWhenFullyUnbound`). For these, if evaluation yields no deltas and there is nothing left to bind (either it is the last goal, or deferral has already been exhausted), Eyeling treats the builtin as a vacuous success (`[{}]`) so it does not block the proof.
357
384
 
358
385
  This is intentionally enabled for **forward-chaining rule bodies only**. Backward rules keep their normal left-to-right goal order, which can be important for termination on some programs.
359
386
 
@@ -399,7 +426,7 @@ Eyeling has ordinary structural equality (term-by-term) for most terms.
399
426
 
400
427
  But **quoted formulas** (`GraphTerm`) demand something stronger. Two formulas should match even if their internal blank/variable names differ, as long as the structure is the same.
401
428
 
402
- That’s alpha-equivalence:
429
+ That is alpha-equivalence:
403
430
 
404
431
  - `{ _:x :p ?y. }` should match `{ _:z :p ?w. }`
405
432
 
@@ -409,14 +436,16 @@ Important scope nuance: only blanks/variables that are local to the quoted formu
409
436
 
410
437
  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`.
411
438
 
412
- ### 6.2 Groundness: variables inside formulas don’t leak”
439
+ A related operational detail matters for rule execution: alpha-equivalence is only a **binding-free shortcut** when both quoted formulas are variable-free after substitution. If unbound variables still remain inside the formulas, Eyeling must fall back to structural quoted-formula unification so shared outer rule variables can actually bind. Otherwise a premise such as `?A :has { ?S ?P ?O }` could appear to match while leaving `?S ?P ?O` unbound for later goals.
440
+
441
+ ### 6.2 Groundness: “variables inside formulas do not leak”
413
442
 
414
443
  Eyeling makes a deliberate choice about _groundness_:
415
444
 
416
445
  - a triple is “ground” if it has no free variables in normal positions
417
446
  - **variables inside a `GraphTerm` do not make the surrounding triple non-ground**
418
447
 
419
- This is encoded in functions like `isGroundTermInGraph`. It’s what makes it possible to assert and store triples that _mention formulas with variables_ as data.
448
+ This is encoded in functions like `isGroundTermInGraph`. It is what makes it possible to assert and store triples that _mention formulas with variables_ as data.
420
449
 
421
450
  ### 6.3 Substitutions: chaining and application
422
451
 
@@ -467,8 +496,8 @@ Unification is implemented in `unifyTerm` / `unifyTriple`, with support for:
467
496
 
468
497
  There are two key traits of Eyeling’s graph unification:
469
498
 
470
- 1. It’s _set-like_: order doesn’t matter.
471
- 2. It’s _substitution-threaded_: choices made while matching one triple restrict the remaining matches, just like Prolog.
499
+ 1. It is _set-like_: order does not matter.
500
+ 2. It is _substitution-threaded_: choices made while matching one triple restrict the remaining matches, just like Prolog.
472
501
 
473
502
  ### 6.5 Literals: lexical vs semantic equality
474
503
 
@@ -569,7 +598,7 @@ for delta in deltas:
569
598
  undoTo(mark)
570
599
  ```
571
600
 
572
- **Implementation note (performance):** in the core DFS, Eyeling applies builtin (and unification) deltas into a single mutable substitution and uses a **trail** to undo bindings on backtracking. This preserves the meaning of “threading substitutions through a proof”, but avoids allocating and copying full substitution objects on every branch. Empty deltas (`{}`) are genuinely cheap: they don’t touch the trail and only incur the control-flow overhead of exploring a branch.
601
+ **Implementation note (performance):** in the core DFS, Eyeling applies builtin (and unification) deltas into a single mutable substitution and uses a **trail** to undo bindings on backtracking. This preserves the meaning of “threading substitutions through a proof”, but avoids allocating and copying full substitution objects on every branch. Empty deltas (`{}`) are genuinely cheap: they do not touch the trail and only incur the control-flow overhead of exploring a branch.
573
602
 
574
603
  **Implementation note (performance):** as of this version, Eyeling also avoids allocating short-lived substitution objects when matching goals against **facts** and when unifying a **backward-rule head** with the current goal. Instead of calling the pure `unifyTriple(..., subst)` (which clones the substitution on each variable bind), the prover performs an **in-place unification** directly into the mutable `substMut` store and records only the newly-bound variable names on the trail. This typically reduces GC pressure significantly on reachability / path-search workloads, where unification is executed extremely frequently.
575
604
 
@@ -577,9 +606,9 @@ So built-ins behave like relations that can generate zero, one, or many possible
577
606
 
578
607
  #### 8.3.1 Builtin deferral and “vacuous” solutions
579
608
 
580
- Conjunction in N3 is order-insensitive, but many builtins are only useful once some variables are bound by _other_ goals in the same body. When `proveGoals` is called from forward chaining, Eyeling enables **builtin deferral**: if a builtin goal can’t make progress yet, it is rotated to the end of the goal list and retried later (with a small cycle guard to avoid infinite rotation).
609
+ Conjunction in N3 is order-insensitive, but many builtins are only useful once some variables are bound by _other_ goals in the same body. When `proveGoals` is called from forward chaining, Eyeling enables **builtin deferral**: if a builtin goal cannot make progress yet, it is rotated to the end of the goal list and retried later (with a small cycle guard to avoid infinite rotation).
581
610
 
582
- Can’t make progress” includes both cases:
611
+ Cannot make progress” includes both cases:
583
612
 
584
613
  - the builtin returns **no solutions** (`[]`), and
585
614
  - the builtin returns only **vacuous solutions** (`[{}]`, i.e., success with _no new bindings_) while the goal still contains unbound vars/blanks.
@@ -716,7 +745,7 @@ Eyeling handles this by replacing head blank labels with fresh blank labels of t
716
745
 
717
746
  - `_:sk_0`, `_:sk_1`, …
718
747
 
719
- But it does something subtle and important: it caches skolemization per (rule firing, head blank label), so that the _same_ firing instance doesn’t keep generating new blanks across outer iterations.
748
+ But it does something subtle and important: it caches skolemization per (rule firing, head blank label), so that the _same_ firing instance does not keep generating new blanks across outer iterations.
720
749
 
721
750
  The “firing instance” is keyed by a deterministic string derived from the instantiated body (“firingKey”). This stabilizes the closure and prevents “existential churn.”
722
751
 
@@ -803,7 +832,7 @@ Some built-ins interpret a positive integer literal as a requested priority:
803
832
 
804
833
  If a rule requests priority `N`, Eyeling delays that builtin until `scopedClosureLevel >= N`.
805
834
 
806
- In practice this allows rule authors to write “don’t run this scoped query until the closure is stable enough” and is what lets Eyeling iterate safely when rule-producing rules introduce new needs.
835
+ In practice this allows rule authors to write “do not run this scoped query until the closure is stable enough” and is what lets Eyeling iterate safely when rule-producing rules introduce new needs.
807
836
 
808
837
  ### 10.3 `log:conclusion`: local deductive closure of a formula
809
838
 
@@ -814,7 +843,7 @@ In practice this allows rule authors to write “don’t run this scoped query u
814
843
  - extract rule triples inside it (`log:implies`, `log:impliedBy`)
815
844
  - run `forwardChain` locally over those triples
816
845
 
817
- - cache the result in a `WeakMap` so the same formula doesn’t get recomputed
846
+ - cache the result in a `WeakMap` so the same formula does not get recomputed
818
847
 
819
848
  Notably, `log:impliedBy` inside the formula is treated as forward implication too for closure computation (and also indexed as backward to help proving).
820
849
 
@@ -882,7 +911,7 @@ The N3 Builtins tradition often describes builtins using “schema” annotation
882
911
  Eyeling is a little more pragmatic: it implements the spirit of these schemas, but it also has several “engineering” conventions that appear across many builtins:
883
912
 
884
913
  1. **Variables (`?X`) may be bound** by a builtin if the builtin is written to do so.
885
- 2. **Blank nodes (`[]` / `_:`)** are frequently treated as “don’t care” placeholders. Many builtins accept a blank node in an output position and simply succeed without binding.
914
+ 2. **Blank nodes (`[]` / `_:`)** are frequently treated as “do not care” placeholders. Many builtins accept a blank node in an output position and simply succeed without binding.
886
915
  3. **Fully unbound relations are usually not enumerated.** If both sides are unbound and enumerating solutions would be infinite (or huge), a number of builtins treat that situation as “satisfiable” and succeed once without binding anything. (This is mainly to keep meta-tests and some N3 conformance cases happy.)
887
916
 
888
917
  With that, we can tour the builtin families as Eyeling actually implements them.
@@ -1006,7 +1035,7 @@ Eyeling supports:
1006
1035
  3. **DateTime minus duration**: `(dateTime durationOrSeconds) math:difference dateTime`
1007
1036
  - Subtracts a duration from a dateTime and yields a new dateTime.
1008
1037
 
1009
- If the types don’t fit any supported case, the builtin fails.
1038
+ If the types do not fit any supported case, the builtin fails.
1010
1039
 
1011
1040
  #### `math:quotient`
1012
1041
 
@@ -1062,7 +1091,7 @@ The **BigInt exact-integer mode** exists specifically to avoid rule-level “rep
1062
1091
 
1063
1092
  #### Unary “math relations” (often invertible)
1064
1093
 
1065
- Eyeling implements these as a shared pattern: if the subject is numeric, compute object; else if the object is numeric, compute subject via an inverse function; if both sides are unbound, succeed once (don’t enumerate).
1094
+ Eyeling implements these as a shared pattern: if the subject is numeric, compute object; else if the object is numeric, compute subject via an inverse function; if both sides are unbound, succeed once (do not enumerate).
1066
1095
 
1067
1096
  - `math:absoluteValue`
1068
1097
  - `math:negation`
@@ -1124,7 +1153,7 @@ Binds `?now` to the current local time as an `xsd:dateTime` literal.
1124
1153
 
1125
1154
  Two subtle but important engineering choices:
1126
1155
 
1127
- 1. Eyeling memoizes “now” per reasoning run so that repeated uses in one run don’t drift.
1156
+ 1. Eyeling memoizes “now” per reasoning run so that repeated uses in one run do not drift.
1128
1157
  2. Eyeling supports a fixed “now” override (used for deterministic tests).
1129
1158
 
1130
1159
  ---
@@ -1253,7 +1282,7 @@ Reversible in the sense that either side may be the list:
1253
1282
  - If subject is a list, object becomes its reversal.
1254
1283
  - If object is a list, subject becomes its reversal.
1255
1284
 
1256
- It does not enumerate arbitrary reversals; it’s a deterministic transform once one side is known.
1285
+ It does not enumerate arbitrary reversals; it is a deterministic transform once one side is known.
1257
1286
 
1258
1287
  #### `list:remove`
1259
1288
 
@@ -1271,7 +1300,7 @@ Succeeds iff the object cannot be unified with any element of the subject list.
1271
1300
 
1272
1301
  #### `list:append`
1273
1302
 
1274
- This is list concatenation, but Eyeling implements it in a pleasantly relational way.
1303
+ This is list concatenation, but Eyeling implements it in a usefully relational way.
1275
1304
 
1276
1305
  **Forward shape:** `( (a b) (c) (d e) ) list:append (a b c d e)`
1277
1306
 
@@ -1377,7 +1406,7 @@ These builtins reach outside the current fact set. They are synchronous by desig
1377
1406
 
1378
1407
  Dereferences and parses the remote/local resource as N3/Turtle-like syntax, returning a formula.
1379
1408
 
1380
- A nice detail: top-level rules in the parsed document are represented _as data_ inside the returned formula using `log:implies` / `log:impliedBy` triples between formula terms. This means you can treat “a document plus its rules” as a single first-class formula object.
1409
+ A useful detail: top-level rules in the parsed document are represented _as data_ inside the returned formula using `log:implies` / `log:impliedBy` triples between formula terms. This means you can treat “a document plus its rules” as a single first-class formula object.
1381
1410
 
1382
1411
  #### `log:semanticsOrError`
1383
1412
 
@@ -1499,7 +1528,7 @@ Eyeling has **two modes**:
1499
1528
 
1500
1529
  2. **Priority-gated global scope**: otherwise
1501
1530
  - Eyeling uses a _frozen snapshot_ of the current global closure.
1502
- - The “priority” is read from the subject if it’s a positive integer literal `N`.
1531
+ - The “priority” is read from the subject if it is a positive integer literal `N`.
1503
1532
  - If the closure level is below `N`, the builtin “delays” by failing at that point in the search.
1504
1533
 
1505
1534
  This priority mechanism exists because Eyeling’s forward chaining runs in outer iterations with a “freeze snapshot then evaluate scoped builtins” phase. The goal is to make scoped meta-builtins stable and deterministic: they query a fixed snapshot rather than chasing a fact store that is being mutated mid-iteration.
@@ -1565,7 +1594,7 @@ Bidirectional conversion between IRIs and their string form:
1565
1594
 
1566
1595
  - If subject is an IRI, object can be unified with a string literal of its IRI.
1567
1596
  - If object is a string literal, subject can be unified with the corresponding IRI — **but** Eyeling rejects strings that cannot be safely serialized as `<...>` in Turtle/N3, and it rejects `_:`-style strings to avoid confusing blank nodes with IRIs.
1568
- - Some “fully unbound / don’t-care” combinations succeed once to avoid infinite enumeration.
1597
+ - Some “fully unbound / do not-care” combinations succeed once to avoid infinite enumeration.
1569
1598
 
1570
1599
  ### Side effects and output directives
1571
1600
 
@@ -1588,7 +1617,7 @@ As a goal, this builtin simply checks that the terms are sufficiently bound/usab
1588
1617
  - When the final closure contains any `log:outputString` triples, the CLI collects all of them from the _saturated_ closure and renders those strings instead of the default N3 output.
1589
1618
  - It sorts them deterministically by the subject “key” and concatenates the string values in that order.
1590
1619
 
1591
- This is a pure test/side-effect marker (it shouldn’t drive search; it should merely validate that strings exist once other reasoning has produced them). In forward rules Eyeling may defer it if it is reached before the terms are usable.
1620
+ This is a pure test/side-effect marker (it should not drive search; it should merely validate that strings exist once other reasoning has produced them). In forward rules Eyeling may defer it if it is reached before the terms are usable.
1592
1621
 
1593
1622
  ---
1594
1623
 
@@ -1763,7 +1792,7 @@ Dereferencing is cached by IRI-without-fragment (fragments are stripped). There
1763
1792
  - parsed semantics (GraphTerm)
1764
1793
  - semantics-or-error
1765
1794
 
1766
- This is both a performance and a stability feature: repeated `log:semantics` calls in backward proofs won’t keep refetching.
1795
+ This is both a performance and a stability feature: repeated `log:semantics` calls in backward proofs will not keep refetching.
1767
1796
 
1768
1797
  ### 12.3 HTTPS enforcement
1769
1798
 
@@ -1796,7 +1825,7 @@ When enabled, Eyeling prints a compact comment block per derived triple:
1796
1825
  - the instantiated rule body that was provable
1797
1826
  - the schematic forward rule that produced it
1798
1827
 
1799
- It’s a “why this triple holds” explanation, not a globally exported proof graph.
1828
+ It is a “why this triple holds” explanation, not a globally exported proof graph.
1800
1829
 
1801
1830
  Implementation note: the engine records lightweight `DerivedFact` objects during forward chaining, and `lib/explain.js` (via `makeExplain(...)`) is responsible for turning those objects into the human-readable proof comment blocks.
1802
1831
 
@@ -2129,7 +2158,7 @@ What Eyeling does:
2129
2158
 
2130
2159
  6. The triple is ground and not already present, so it is added and (optionally) printed.
2131
2160
 
2132
- That’s the whole engine in miniature: unify, compose substitutions, emit head triples.
2161
+ That is the whole engine in miniature: unify, compose substitutions, emit head triples.
2133
2162
 
2134
2163
  ---
2135
2164
 
@@ -2659,7 +2688,7 @@ Example fuse:
2659
2688
  } => false.
2660
2689
  ```
2661
2690
 
2662
- If you don’t want “stop the world”, derive a `:Violation` fact instead, and keep going.
2691
+ If you do not want “stop the world”, derive a `:Violation` fact instead, and keep going.
2663
2692
 
2664
2693
  ### 3) Make the workflow test-driven (golden closures)
2665
2694
 
@@ -2673,7 +2702,7 @@ This turns rule edits into a normal change-management loop: diffs are explicit,
2673
2702
 
2674
2703
  ### 4) Use proofs/traces as the input to the LLM, not the other way around
2675
2704
 
2676
- If you want a natural-language explanation, don’t ask the model to “explain the rules from memory”. Instead:
2705
+ If you want a natural-language explanation, do not ask the model to “explain the rules from memory”. Instead:
2677
2706
 
2678
2707
  1. Run Eyeling with proof/trace enabled (Eyeling has explicit tracing hooks and proof-comment support in its output pipeline).
2679
2708
  2. Give the LLM the **derived triples + proof comments** and ask it to summarize:
@@ -2706,7 +2735,7 @@ A simple structure that keeps the LLM honest:
2706
2735
  - “Include at least N minimal tests as facts in a separate block/file.”
2707
2736
  - “If something is unknown, emit a placeholder fact (`:needsFact`) rather than guessing.”
2708
2737
 
2709
- The point isn’t that the LLM is “right”; it’s that **Eyeling makes the result checkable**, and the artifact becomes a maintainable program rather than a one-off generation.
2738
+ The point is not that the LLM is “right”; it is that **Eyeling makes the result checkable**, and the artifact becomes a maintainable program rather than a one-off generation.
2710
2739
 
2711
2740
  ---
2712
2741
 
@@ -3190,8 +3219,8 @@ The following examples are especially useful if you want to see Eyeling files th
3190
3219
  - [`examples/act-gravity-mediator-witness.n3`](examples/act-gravity-mediator-witness.n3) · [`examples/output/act-gravity-mediator-witness.txt`](examples/output/act-gravity-mediator-witness.txt) — applied constructor-theory witness showing that, under locality and interoperability, entanglement mediated only by gravity implies a non-classical gravitational mediator.
3191
3220
  - ['examples/act-yeast-self-reproduction.n3'](examples/act-yeast-self-reproduction.n3) · ['examples/output/act-yeast-self-reproduction.txt'](examples/output/act-yeast-self-reproduction.txt) — applied constructor-theory example of a yeast starter culture showing replicator, vehicle, self-reproduction, heritable variation, and natural selection under no-design laws.
3192
3221
  - ['examples/act-barley-seed-lineage.n3'](examples/act-barley-seed-lineage.n3) · ['examples/output/act-barley-seed-lineage.txt'](examples/output/act-barley-seed-lineage.txt) — applied constructor-theory ARC case showing both possible and impossible lineage tasks under no-design laws, including blocked reproduction, dormancy, and evolvability when key ingredients are missing.
3193
- - ['examples/act-tunnel-junction-wake-switch.n3'](examples/act-tunnel-junction-wake-switch.n3) · ['examples/output/act-tunnel-junction-wake-switch.txt'](examples/output/act-tunnel-junction-wake-switch.txt) — applied constructor-theory ARC case comparing a tunnel-junction wake switch with a conventional PN junction via explicit can/can’t rules for tunneling, sub-threshold current, negative differential response, and low-bias switching.
3194
- - ['examples/act-photosynthetic-exciton-transfer.n3'](examples/act-photosynthetic-exciton-transfer.n3) · ['examples/output/act-photosynthetic-exciton-transfer.txt'](examples/output/act-photosynthetic-exciton-transfer.txt) — applied constructor-theory ARC case for quantum-assisted exciton transfer in a photosynthetic antenna, contrasting a tuned complex with a detuned one via explicit can/can’t rules.
3222
+ - ['examples/act-tunnel-junction-wake-switch.n3'](examples/act-tunnel-junction-wake-switch.n3) · ['examples/output/act-tunnel-junction-wake-switch.txt'](examples/output/act-tunnel-junction-wake-switch.txt) — applied constructor-theory ARC case comparing a tunnel-junction wake switch with a conventional PN junction via explicit can/cannot rules for tunneling, sub-threshold current, negative differential response, and low-bias switching.
3223
+ - ['examples/act-photosynthetic-exciton-transfer.n3'](examples/act-photosynthetic-exciton-transfer.n3) · ['examples/output/act-photosynthetic-exciton-transfer.txt'](examples/output/act-photosynthetic-exciton-transfer.txt) — applied constructor-theory ARC case for quantum-assisted exciton transfer in a photosynthetic antenna, contrasting a tuned complex with a detuned one via explicit can/cannot rules.
3195
3224
  - ['examples/act-sensor-memory-reset.n3'](examples/act-sensor-memory-reset.n3) · ['examples/output/act-sensor-memory-reset.txt'](examples/output/act-sensor-memory-reset.txt) — applied constructor-theory ARC case showing that a sensor memory reset is possible with a work medium but not with heat alone, highlighting work/heat distinction and irreversibility.
3196
3225
 
3197
3226
  #### Deep-classification stress tests
@@ -3336,7 +3365,7 @@ and just as naturally:
3336
3365
  { ?system :lacks ?property . } => { ?system :cannot ?task . } .
3337
3366
  ```
3338
3367
 
3339
- That is already close to the “science of can and can’t” idiom.
3368
+ That is already close to the “science of can and cannot” idiom.
3340
3369
 
3341
3370
  Second, N3 can keep the explanation close to the answer. The conditions, the derived `:can` / `:cannot` facts, and the final human-readable report can all live in one file.
3342
3371
 
@@ -3633,3 +3662,21 @@ In short: the playground is best thought of as a compact interactive front end f
3633
3662
  The Eyeling Playground shows that N3 reasoning can be made substantially more approachable without flattening the underlying logic into a toy interface. A relatively small set of features — an editor, a URL loader, background knowledge mode, responsive execution, proof toggles, and shareable query parameters — is enough to support serious educational and exploratory work.
3634
3663
 
3635
3664
  That is the main value of the playground. It gives Eyeling a public-facing, browser-native environment where reasoning is not hidden behind setup overhead, and where examples can move easily between author, teacher, student, and reviewer.
3665
+
3666
+ <a id="app-j"></a>
3667
+
3668
+ ## Appendix J — Formalism Is Fine
3669
+
3670
+ For Eyeling, formal methods are not an obstacle to practical reasoning. They are part of what makes the system useful. A reasoner is easier to trust when its facts, rules, derivations, and limits can be stated explicitly rather than hidden in application code. That is the sense in which formalism matters here: not as ceremony, but as a way of keeping the behavior of the system inspectable.
3671
+
3672
+ Horn logic is fine because it gives a disciplined core. It does not try to express every possible form of reasoning. Instead, it offers a fragment that is small enough to implement clearly and strong enough to support a wide range of real tasks. That trade is often a good one. In a compact reasoner, expressiveness only helps when it does not destroy clarity or operational control.
3673
+
3674
+ Notation3 is fine because a logic language also needs a readable surface. Eyeling works with terms, triples, formulas, and rules, but those structures still have to be written, reviewed, debugged, and shared. N3 matters because it keeps the logic close to the page. A rule still looks like something a person can follow. A quoted formula still looks like a graph that can be inspected. That readability is part of what makes the reasoner teachable and portable.
3675
+
3676
+ Executable specification is fine because there is real value in keeping semantics and implementation close together. When a specification can be run, it becomes easier to test the intended behavior on concrete inputs, compare outcomes across examples, and find the points where an abstract account is still too vague. Execution does not replace semantics, but it is often the best way to expose whether the semantics is precise enough to guide an implementation.
3677
+
3678
+ Herbrand semantics is fine because it gives symbolic reasoning a concrete semantic basis. Instead of beginning with an opaque external domain, it begins with the symbolic constructions themselves and asks what follows from them under the rules. That is a natural fit for Eyeling. The engine reasons over terms, substitutions, triples, formulas, and proof states. Herbrand-style semantics therefore does not feel like an imported philosophical story. It describes the level at which the system actually works.
3679
+
3680
+ Gödel incompleteness is fine because the limits of formal systems are not a refutation of formal reasoning. They are part of its shape. Once a system becomes expressive enough, one should expect structural limits on what it can prove about itself. That does not make formal methods less serious. It shows that their boundaries are principled rather than accidental. For a handbook like this one, that is the right lesson: formal systems are valuable not because they say everything, but because they say some things clearly, explicitly, and in a form that can be checked.
3681
+
3682
+ Taken together, these positions support a straightforward attitude toward Eyeling. Horn logic is fine. Notation3 is fine. Executable specification is fine. Herbrand semantics is fine. Gödel incompleteness is fine. None of these commitments make the reasoner narrower in a harmful sense. They make it clearer, easier to inspect, and easier to trust. For this project, that is enough.
package/README.md CHANGED
@@ -17,11 +17,3 @@ echo '@prefix : <http://example.org/> .
17
17
  - **Handbook:** [eyereasoner.github.io/eyeling/HANDBOOK](https://eyereasoner.github.io/eyeling/HANDBOOK)
18
18
  - **Playground:** [eyereasoner.github.io/eyeling/demo](https://eyereasoner.github.io/eyeling/demo)
19
19
  - **Conformance report:** [codeberg.org/phochste/notation3tests/.../report.md](https://codeberg.org/phochste/notation3tests/src/branch/main/reports/report.md)
20
-
21
- ## Playground URL parameters
22
-
23
- - `edit` sets the editor content.
24
- - `url` fills the URL field.
25
- - `url` auto-loads when `loadbg=true`, or when no explicit `edit` was provided.
26
- - `proofcomments` and `httpsderef` initialize the two checkboxes.
27
- - Existing hash-based links are still read as a fallback, but new state updates write query parameters.
@@ -7625,6 +7625,22 @@
7625
7625
  return s2;
7626
7626
  }
7627
7627
 
7628
+ function graphTriplesContainVars(triples) {
7629
+ function termHasVar(t) {
7630
+ if (t instanceof Var) return true;
7631
+ if (t instanceof ListTerm) return t.elems.some(termHasVar);
7632
+ if (t instanceof OpenListTerm) return t.prefix.some(termHasVar) || true;
7633
+ if (t instanceof GraphTerm)
7634
+ return t.triples.some((tr) => termHasVar(tr.s) || termHasVar(tr.p) || termHasVar(tr.o));
7635
+ return false;
7636
+ }
7637
+
7638
+ for (const tr of triples) {
7639
+ if (termHasVar(tr.s) || termHasVar(tr.p) || termHasVar(tr.o)) return true;
7640
+ }
7641
+ return false;
7642
+ }
7643
+
7628
7644
  function unifyGraphTriples(xs, ys, subst) {
7629
7645
  if (xs.length !== ys.length) return null;
7630
7646
 
@@ -7786,17 +7802,22 @@
7786
7802
 
7787
7803
  // Graphs
7788
7804
  if (a instanceof GraphTerm && b instanceof GraphTerm) {
7789
- const protectedNamesA = collectProtectedNamesForTerm(aRaw, subst);
7790
- const protectedNamesB = collectProtectedNamesForTerm(bRaw, subst);
7791
- if (
7792
- alphaEqGraphTriples(a.triples, b.triples, {
7793
- protectedVarsA: protectedNamesA.protectedVars,
7794
- protectedVarsB: protectedNamesB.protectedVars,
7795
- protectedBlanksA: protectedNamesA.protectedBlanks,
7796
- protectedBlanksB: protectedNamesB.protectedBlanks,
7797
- })
7798
- ) {
7799
- return subst;
7805
+ // Only use alpha-equivalence as a binding-free fast path when both quoted
7806
+ // formulas are variable-free after substitution. If unbound variables remain,
7807
+ // they may be shared with the outer rule and must unify/bind structurally.
7808
+ if (!graphTriplesContainVars(a.triples) && !graphTriplesContainVars(b.triples)) {
7809
+ const protectedNamesA = collectProtectedNamesForTerm(aRaw, subst);
7810
+ const protectedNamesB = collectProtectedNamesForTerm(bRaw, subst);
7811
+ if (
7812
+ alphaEqGraphTriples(a.triples, b.triples, {
7813
+ protectedVarsA: protectedNamesA.protectedVars,
7814
+ protectedVarsB: protectedNamesB.protectedVars,
7815
+ protectedBlanksA: protectedNamesA.protectedBlanks,
7816
+ protectedBlanksB: protectedNamesB.protectedBlanks,
7817
+ })
7818
+ ) {
7819
+ return subst;
7820
+ }
7800
7821
  }
7801
7822
  return unifyGraphTriples(a.triples, b.triples, subst);
7802
7823
  }
@@ -8177,17 +8198,22 @@
8177
8198
 
8178
8199
  // Graphs
8179
8200
  if (a instanceof GraphTerm && b instanceof GraphTerm) {
8180
- const protectedNamesA = collectProtectedNamesForTerm(aRaw, substMut);
8181
- const protectedNamesB = collectProtectedNamesForTerm(bRaw, substMut);
8182
- if (
8183
- alphaEqGraphTriples(a.triples, b.triples, {
8184
- protectedVarsA: protectedNamesA.protectedVars,
8185
- protectedVarsB: protectedNamesB.protectedVars,
8186
- protectedBlanksA: protectedNamesA.protectedBlanks,
8187
- protectedBlanksB: protectedNamesB.protectedBlanks,
8188
- })
8189
- ) {
8190
- return true;
8201
+ // Only use alpha-equivalence as a binding-free fast path when both quoted
8202
+ // formulas are variable-free after substitution. If unbound variables remain,
8203
+ // they may be shared with the outer rule and must unify/bind structurally.
8204
+ if (!graphTriplesContainVars(a.triples) && !graphTriplesContainVars(b.triples)) {
8205
+ const protectedNamesA = collectProtectedNamesForTerm(aRaw, substMut);
8206
+ const protectedNamesB = collectProtectedNamesForTerm(bRaw, substMut);
8207
+ if (
8208
+ alphaEqGraphTriples(a.triples, b.triples, {
8209
+ protectedVarsA: protectedNamesA.protectedVars,
8210
+ protectedVarsB: protectedNamesB.protectedVars,
8211
+ protectedBlanksA: protectedNamesA.protectedBlanks,
8212
+ protectedBlanksB: protectedNamesB.protectedBlanks,
8213
+ })
8214
+ ) {
8215
+ return true;
8216
+ }
8191
8217
  }
8192
8218
  const merged = unifyGraphTriples(a.triples, b.triples, substMut);
8193
8219
  if (merged === null) return false;
@@ -13039,15 +13065,39 @@ ${triples.map((tr) => ` ${tripleToN3(tr, prefixes)}`).join('\n')}
13039
13065
  return t;
13040
13066
  }
13041
13067
 
13042
- function convertTerm(t) {
13068
+ function convertQuotedPatternTerm(t) {
13043
13069
  if (t instanceof Blank) return blankToVar(t.label);
13044
- if (t instanceof ListTerm) return new ListTerm(t.elems.map(convertTerm));
13045
- if (t instanceof OpenListTerm) return new OpenListTerm(t.prefix.map(convertTerm), t.tailVar);
13046
- if (t instanceof GraphTerm) return copyQuotedTerm(t);
13070
+ if (t instanceof ListTerm) return new ListTerm(t.elems.map(convertQuotedPatternTerm));
13071
+ if (t instanceof OpenListTerm) return new OpenListTerm(t.prefix.map(convertQuotedPatternTerm), t.tailVar);
13072
+ if (t instanceof GraphTerm) {
13073
+ const triples = t.triples.map(
13074
+ (tr) =>
13075
+ new Triple(
13076
+ convertQuotedPatternTerm(tr.s),
13077
+ convertQuotedPatternTerm(tr.p),
13078
+ convertQuotedPatternTerm(tr.o),
13079
+ ),
13080
+ );
13081
+ return copyQuotedGraphMetadata(t, new GraphTerm(triples));
13082
+ }
13047
13083
  return t;
13048
13084
  }
13049
13085
 
13050
- const newPremise = premise.map((tr) => new Triple(convertTerm(tr.s), convertTerm(tr.p), convertTerm(tr.o)));
13086
+ function convertTerm(t, allowDirectQuotedPattern = false) {
13087
+ if (t instanceof Blank) return blankToVar(t.label);
13088
+ if (t instanceof ListTerm) return new ListTerm(t.elems.map((e) => convertTerm(e, false)));
13089
+ if (t instanceof OpenListTerm)
13090
+ return new OpenListTerm(
13091
+ t.prefix.map((e) => convertTerm(e, false)),
13092
+ t.tailVar,
13093
+ );
13094
+ if (t instanceof GraphTerm) return allowDirectQuotedPattern ? convertQuotedPatternTerm(t) : copyQuotedTerm(t);
13095
+ return t;
13096
+ }
13097
+
13098
+ const newPremise = premise.map(
13099
+ (tr) => new Triple(convertTerm(tr.s, true), convertTerm(tr.p, true), convertTerm(tr.o, true)),
13100
+ );
13051
13101
  return [newPremise, conclusion];
13052
13102
  }
13053
13103
 
package/eyeling.js CHANGED
@@ -7605,6 +7605,22 @@ function unifyOpenWithList(prefix, tailv, ys, subst) {
7605
7605
  return s2;
7606
7606
  }
7607
7607
 
7608
+
7609
+ function graphTriplesContainVars(triples) {
7610
+ function termHasVar(t) {
7611
+ if (t instanceof Var) return true;
7612
+ if (t instanceof ListTerm) return t.elems.some(termHasVar);
7613
+ if (t instanceof OpenListTerm) return t.prefix.some(termHasVar) || true;
7614
+ if (t instanceof GraphTerm) return t.triples.some((tr) => termHasVar(tr.s) || termHasVar(tr.p) || termHasVar(tr.o));
7615
+ return false;
7616
+ }
7617
+
7618
+ for (const tr of triples) {
7619
+ if (termHasVar(tr.s) || termHasVar(tr.p) || termHasVar(tr.o)) return true;
7620
+ }
7621
+ return false;
7622
+ }
7623
+
7608
7624
  function unifyGraphTriples(xs, ys, subst) {
7609
7625
  if (xs.length !== ys.length) return null;
7610
7626
 
@@ -7766,17 +7782,22 @@ function unifyTermWithOptions(a, b, subst, opts) {
7766
7782
 
7767
7783
  // Graphs
7768
7784
  if (a instanceof GraphTerm && b instanceof GraphTerm) {
7769
- const protectedNamesA = collectProtectedNamesForTerm(aRaw, subst);
7770
- const protectedNamesB = collectProtectedNamesForTerm(bRaw, subst);
7771
- if (
7772
- alphaEqGraphTriples(a.triples, b.triples, {
7773
- protectedVarsA: protectedNamesA.protectedVars,
7774
- protectedVarsB: protectedNamesB.protectedVars,
7775
- protectedBlanksA: protectedNamesA.protectedBlanks,
7776
- protectedBlanksB: protectedNamesB.protectedBlanks,
7777
- })
7778
- ) {
7779
- return subst;
7785
+ // Only use alpha-equivalence as a binding-free fast path when both quoted
7786
+ // formulas are variable-free after substitution. If unbound variables remain,
7787
+ // they may be shared with the outer rule and must unify/bind structurally.
7788
+ if (!graphTriplesContainVars(a.triples) && !graphTriplesContainVars(b.triples)) {
7789
+ const protectedNamesA = collectProtectedNamesForTerm(aRaw, subst);
7790
+ const protectedNamesB = collectProtectedNamesForTerm(bRaw, subst);
7791
+ if (
7792
+ alphaEqGraphTriples(a.triples, b.triples, {
7793
+ protectedVarsA: protectedNamesA.protectedVars,
7794
+ protectedVarsB: protectedNamesB.protectedVars,
7795
+ protectedBlanksA: protectedNamesA.protectedBlanks,
7796
+ protectedBlanksB: protectedNamesB.protectedBlanks,
7797
+ })
7798
+ ) {
7799
+ return subst;
7800
+ }
7780
7801
  }
7781
7802
  return unifyGraphTriples(a.triples, b.triples, subst);
7782
7803
  }
@@ -8157,17 +8178,22 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
8157
8178
 
8158
8179
  // Graphs
8159
8180
  if (a instanceof GraphTerm && b instanceof GraphTerm) {
8160
- const protectedNamesA = collectProtectedNamesForTerm(aRaw, substMut);
8161
- const protectedNamesB = collectProtectedNamesForTerm(bRaw, substMut);
8162
- if (
8163
- alphaEqGraphTriples(a.triples, b.triples, {
8164
- protectedVarsA: protectedNamesA.protectedVars,
8165
- protectedVarsB: protectedNamesB.protectedVars,
8166
- protectedBlanksA: protectedNamesA.protectedBlanks,
8167
- protectedBlanksB: protectedNamesB.protectedBlanks,
8168
- })
8169
- ) {
8170
- return true;
8181
+ // Only use alpha-equivalence as a binding-free fast path when both quoted
8182
+ // formulas are variable-free after substitution. If unbound variables remain,
8183
+ // they may be shared with the outer rule and must unify/bind structurally.
8184
+ if (!graphTriplesContainVars(a.triples) && !graphTriplesContainVars(b.triples)) {
8185
+ const protectedNamesA = collectProtectedNamesForTerm(aRaw, substMut);
8186
+ const protectedNamesB = collectProtectedNamesForTerm(bRaw, substMut);
8187
+ if (
8188
+ alphaEqGraphTriples(a.triples, b.triples, {
8189
+ protectedVarsA: protectedNamesA.protectedVars,
8190
+ protectedVarsB: protectedNamesB.protectedVars,
8191
+ protectedBlanksA: protectedNamesA.protectedBlanks,
8192
+ protectedBlanksB: protectedNamesB.protectedBlanks,
8193
+ })
8194
+ ) {
8195
+ return true;
8196
+ }
8171
8197
  }
8172
8198
  const merged = unifyGraphTriples(a.triples, b.triples, substMut);
8173
8199
  if (merged === null) return false;
@@ -12998,15 +13024,35 @@ function liftBlankRuleVars(premise, conclusion) {
12998
13024
  return t;
12999
13025
  }
13000
13026
 
13001
- function convertTerm(t) {
13027
+ function convertQuotedPatternTerm(t) {
13002
13028
  if (t instanceof Blank) return blankToVar(t.label);
13003
- if (t instanceof ListTerm) return new ListTerm(t.elems.map(convertTerm));
13004
- if (t instanceof OpenListTerm) return new OpenListTerm(t.prefix.map(convertTerm), t.tailVar);
13005
- if (t instanceof GraphTerm) return copyQuotedTerm(t);
13029
+ if (t instanceof ListTerm) return new ListTerm(t.elems.map(convertQuotedPatternTerm));
13030
+ if (t instanceof OpenListTerm) return new OpenListTerm(t.prefix.map(convertQuotedPatternTerm), t.tailVar);
13031
+ if (t instanceof GraphTerm) {
13032
+ const triples = t.triples.map(
13033
+ (tr) =>
13034
+ new Triple(convertQuotedPatternTerm(tr.s), convertQuotedPatternTerm(tr.p), convertQuotedPatternTerm(tr.o)),
13035
+ );
13036
+ return copyQuotedGraphMetadata(t, new GraphTerm(triples));
13037
+ }
13006
13038
  return t;
13007
13039
  }
13008
13040
 
13009
- const newPremise = premise.map((tr) => new Triple(convertTerm(tr.s), convertTerm(tr.p), convertTerm(tr.o)));
13041
+ function convertTerm(t, allowDirectQuotedPattern = false) {
13042
+ if (t instanceof Blank) return blankToVar(t.label);
13043
+ if (t instanceof ListTerm) return new ListTerm(t.elems.map((e) => convertTerm(e, false)));
13044
+ if (t instanceof OpenListTerm)
13045
+ return new OpenListTerm(
13046
+ t.prefix.map((e) => convertTerm(e, false)),
13047
+ t.tailVar,
13048
+ );
13049
+ if (t instanceof GraphTerm) return allowDirectQuotedPattern ? convertQuotedPatternTerm(t) : copyQuotedTerm(t);
13050
+ return t;
13051
+ }
13052
+
13053
+ const newPremise = premise.map(
13054
+ (tr) => new Triple(convertTerm(tr.s, true), convertTerm(tr.p, true), convertTerm(tr.o, true)),
13055
+ );
13010
13056
  return [newPremise, conclusion];
13011
13057
  }
13012
13058
 
package/lib/engine.js CHANGED
@@ -1795,6 +1795,21 @@ function unifyOpenWithList(prefix, tailv, ys, subst) {
1795
1795
  return s2;
1796
1796
  }
1797
1797
 
1798
+ function graphTriplesContainVars(triples) {
1799
+ function termHasVar(t) {
1800
+ if (t instanceof Var) return true;
1801
+ if (t instanceof ListTerm) return t.elems.some(termHasVar);
1802
+ if (t instanceof OpenListTerm) return t.prefix.some(termHasVar) || true;
1803
+ if (t instanceof GraphTerm) return t.triples.some((tr) => termHasVar(tr.s) || termHasVar(tr.p) || termHasVar(tr.o));
1804
+ return false;
1805
+ }
1806
+
1807
+ for (const tr of triples) {
1808
+ if (termHasVar(tr.s) || termHasVar(tr.p) || termHasVar(tr.o)) return true;
1809
+ }
1810
+ return false;
1811
+ }
1812
+
1798
1813
  function unifyGraphTriples(xs, ys, subst) {
1799
1814
  if (xs.length !== ys.length) return null;
1800
1815
 
@@ -1956,17 +1971,22 @@ function unifyTermWithOptions(a, b, subst, opts) {
1956
1971
 
1957
1972
  // Graphs
1958
1973
  if (a instanceof GraphTerm && b instanceof GraphTerm) {
1959
- const protectedNamesA = collectProtectedNamesForTerm(aRaw, subst);
1960
- const protectedNamesB = collectProtectedNamesForTerm(bRaw, subst);
1961
- if (
1962
- alphaEqGraphTriples(a.triples, b.triples, {
1963
- protectedVarsA: protectedNamesA.protectedVars,
1964
- protectedVarsB: protectedNamesB.protectedVars,
1965
- protectedBlanksA: protectedNamesA.protectedBlanks,
1966
- protectedBlanksB: protectedNamesB.protectedBlanks,
1967
- })
1968
- ) {
1969
- return subst;
1974
+ // Only use alpha-equivalence as a binding-free fast path when both quoted
1975
+ // formulas are variable-free after substitution. If unbound variables remain,
1976
+ // they may be shared with the outer rule and must unify/bind structurally.
1977
+ if (!graphTriplesContainVars(a.triples) && !graphTriplesContainVars(b.triples)) {
1978
+ const protectedNamesA = collectProtectedNamesForTerm(aRaw, subst);
1979
+ const protectedNamesB = collectProtectedNamesForTerm(bRaw, subst);
1980
+ if (
1981
+ alphaEqGraphTriples(a.triples, b.triples, {
1982
+ protectedVarsA: protectedNamesA.protectedVars,
1983
+ protectedVarsB: protectedNamesB.protectedVars,
1984
+ protectedBlanksA: protectedNamesA.protectedBlanks,
1985
+ protectedBlanksB: protectedNamesB.protectedBlanks,
1986
+ })
1987
+ ) {
1988
+ return subst;
1989
+ }
1970
1990
  }
1971
1991
  return unifyGraphTriples(a.triples, b.triples, subst);
1972
1992
  }
@@ -2347,17 +2367,22 @@ function proveGoals(goals, subst, facts, backRules, depth, visited, varGen, maxR
2347
2367
 
2348
2368
  // Graphs
2349
2369
  if (a instanceof GraphTerm && b instanceof GraphTerm) {
2350
- const protectedNamesA = collectProtectedNamesForTerm(aRaw, substMut);
2351
- const protectedNamesB = collectProtectedNamesForTerm(bRaw, substMut);
2352
- if (
2353
- alphaEqGraphTriples(a.triples, b.triples, {
2354
- protectedVarsA: protectedNamesA.protectedVars,
2355
- protectedVarsB: protectedNamesB.protectedVars,
2356
- protectedBlanksA: protectedNamesA.protectedBlanks,
2357
- protectedBlanksB: protectedNamesB.protectedBlanks,
2358
- })
2359
- ) {
2360
- return true;
2370
+ // Only use alpha-equivalence as a binding-free fast path when both quoted
2371
+ // formulas are variable-free after substitution. If unbound variables remain,
2372
+ // they may be shared with the outer rule and must unify/bind structurally.
2373
+ if (!graphTriplesContainVars(a.triples) && !graphTriplesContainVars(b.triples)) {
2374
+ const protectedNamesA = collectProtectedNamesForTerm(aRaw, substMut);
2375
+ const protectedNamesB = collectProtectedNamesForTerm(bRaw, substMut);
2376
+ if (
2377
+ alphaEqGraphTriples(a.triples, b.triples, {
2378
+ protectedVarsA: protectedNamesA.protectedVars,
2379
+ protectedVarsB: protectedNamesB.protectedVars,
2380
+ protectedBlanksA: protectedNamesA.protectedBlanks,
2381
+ protectedBlanksB: protectedNamesB.protectedBlanks,
2382
+ })
2383
+ ) {
2384
+ return true;
2385
+ }
2361
2386
  }
2362
2387
  const merged = unifyGraphTriples(a.triples, b.triples, substMut);
2363
2388
  if (merged === null) return false;
package/lib/rules.js CHANGED
@@ -40,15 +40,35 @@ function liftBlankRuleVars(premise, conclusion) {
40
40
  return t;
41
41
  }
42
42
 
43
- function convertTerm(t) {
43
+ function convertQuotedPatternTerm(t) {
44
44
  if (t instanceof Blank) return blankToVar(t.label);
45
- if (t instanceof ListTerm) return new ListTerm(t.elems.map(convertTerm));
46
- if (t instanceof OpenListTerm) return new OpenListTerm(t.prefix.map(convertTerm), t.tailVar);
47
- if (t instanceof GraphTerm) return copyQuotedTerm(t);
45
+ if (t instanceof ListTerm) return new ListTerm(t.elems.map(convertQuotedPatternTerm));
46
+ if (t instanceof OpenListTerm) return new OpenListTerm(t.prefix.map(convertQuotedPatternTerm), t.tailVar);
47
+ if (t instanceof GraphTerm) {
48
+ const triples = t.triples.map(
49
+ (tr) =>
50
+ new Triple(convertQuotedPatternTerm(tr.s), convertQuotedPatternTerm(tr.p), convertQuotedPatternTerm(tr.o)),
51
+ );
52
+ return copyQuotedGraphMetadata(t, new GraphTerm(triples));
53
+ }
54
+ return t;
55
+ }
56
+
57
+ function convertTerm(t, allowDirectQuotedPattern = false) {
58
+ if (t instanceof Blank) return blankToVar(t.label);
59
+ if (t instanceof ListTerm) return new ListTerm(t.elems.map((e) => convertTerm(e, false)));
60
+ if (t instanceof OpenListTerm)
61
+ return new OpenListTerm(
62
+ t.prefix.map((e) => convertTerm(e, false)),
63
+ t.tailVar,
64
+ );
65
+ if (t instanceof GraphTerm) return allowDirectQuotedPattern ? convertQuotedPatternTerm(t) : copyQuotedTerm(t);
48
66
  return t;
49
67
  }
50
68
 
51
- const newPremise = premise.map((tr) => new Triple(convertTerm(tr.s), convertTerm(tr.p), convertTerm(tr.o)));
69
+ const newPremise = premise.map(
70
+ (tr) => new Triple(convertTerm(tr.s, true), convertTerm(tr.p, true), convertTerm(tr.o, true)),
71
+ );
52
72
  return [newPremise, conclusion];
53
73
  }
54
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyeling",
3
- "version": "1.22.12",
3
+ "version": "1.22.14",
4
4
  "description": "A minimal Notation3 (N3) reasoner in JavaScript.",
5
5
  "main": "./index.js",
6
6
  "keywords": [
package/test/api.test.js CHANGED
@@ -2278,6 +2278,34 @@ _:b a ex:Person ; ex:name "B" .
2278
2278
  {
2279
2279
  :test :is true .
2280
2280
  }.
2281
+ `,
2282
+ expect: [/^:test\s+:is\s+true\s*\./m],
2283
+ },
2284
+ {
2285
+ name: 'regression: quoted-formula backward-rule heads must bind shared variables for later goals',
2286
+ opt: { proofComments: false },
2287
+ input: `@prefix : <http://example.org/> .
2288
+ @prefix log: <http://www.w3.org/2000/10/swap/log#> .
2289
+
2290
+ { :a :b :c } a :Statement .
2291
+
2292
+ { ?A :has { ?S ?P ?O } }
2293
+ <=
2294
+ {
2295
+ ?A log:includes { ?S ?P ?O }.
2296
+ }.
2297
+
2298
+ {
2299
+ ?A a :Statement .
2300
+ ?A :has { ?S ?P ?O }.
2301
+ ?S log:rawType log:Other.
2302
+ ?P log:rawType log:Other.
2303
+ ?O log:rawType log:Other.
2304
+ }
2305
+ =>
2306
+ {
2307
+ :test :is true .
2308
+ }.
2281
2309
  `,
2282
2310
  expect: [/^:test\s+:is\s+true\s*\./m],
2283
2311
  },