eyeling 1.11.9 → 1.11.10

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
@@ -365,11 +365,23 @@ A substitution is a plain JS object:
365
365
  { X: Term, Y: Term, ... }
366
366
  ```
367
367
 
368
- When applying substitutions, Eyeling follows chains:
368
+ When applying substitutions, Eyeling follows **chains**:
369
369
 
370
370
  * if `X → Var(Y)` and `Y → Iri(...)`, applying to `X` yields the IRI.
371
371
 
372
- This matters because unification can bind variables to variables; it’s normal in logic programming, and you want `applySubst` to “chase the link” until it reaches a stable term.
372
+ Chains arise naturally during unification (e.g. when variables unify with other variables) and during rule firing. Eyeling treats substitutions as *persistent maps*: when a new binding is added, the engine makes a shallow copy and records only the new binding.
373
+
374
+ Implementation details (and why they matter):
375
+
376
+ * **`applySubstTerm` is the only “chain chaser”.** It follows `Var → Term` links until it reaches a stable term.
377
+ * Unification’s occurs-check prevents most cycles, but `applySubstTerm` still defends against accidental cyclic chains.
378
+ * The cycle guard is written to avoid allocating a `Set` in the common case (short chains).
379
+ * **Structural sharing is deliberate.** Applying a substitution often changes nothing:
380
+ * `applySubstTerm` returns the original term when it is unaffected.
381
+ * list/open-list/graph terms are only rebuilt if at least one component changes (lazy copy-on-change).
382
+ * `applySubstTriple` returns the original `Triple` when `s/p/o` are unchanged.
383
+
384
+ These “no-op returns” are one of the biggest practical performance wins in the engine: backward chaining and forward rule instantiation apply substitutions constantly, so avoiding allocations reduces GC pressure without changing semantics.
373
385
 
374
386
  ### 6.4 Unification: the core operation
375
387
 
@@ -480,6 +492,9 @@ for delta in deltas:
480
492
  composed = composeSubst(currentSubst, delta)
481
493
  ```
482
494
 
495
+ **Implementation note (performance):** `composeSubst` has a fast path — if `delta` is empty (or only repeats bindings already present in `currentSubst`), it returns the original substitution object instead of cloning. This makes constraint-style builtins that often yield `{}` much cheaper, but it does **not** change the search-space: a vacuous solution can still amplify later branching.
496
+
497
+
483
498
  So built-ins behave like relations that can generate zero, one, or many possible bindings. A list generator might yield many deltas; a numeric test yields zero or one.
484
499
 
485
500
  #### 8.3.1 Builtin deferral and “vacuous” solutions
@@ -579,6 +594,8 @@ But it does something subtle and important: it caches skolemization per (rule fi
579
594
 
580
595
  The “firing instance” is keyed by a deterministic string derived from the instantiated body (“firingKey”). This stabilizes the closure and prevents “existential churn.”
581
596
 
597
+ **Implementation note (performance):** the firing-instance key is computed in a hot loop, so `firingKey(...)` builds a compact string via concatenation rather than `JSON.stringify`. If you change what counts as a distinct “firing instance”, update the key format and the skolem cache together.
598
+
582
599
  Implementation: deterministic Skolem IDs live in `lib/skolem.js`; the per-firing cache and head-blank rewriting are implemented in `lib/engine.js`.
583
600
 
584
601
  ### 9.4 Inference fuses: `{ ... } => false`
@@ -1878,4 +1895,3 @@ Logic & reasoning background (Wikipedia):
1878
1895
  - [https://en.wikipedia.org/wiki/Prolog](https://en.wikipedia.org/wiki/Prolog)
1879
1896
  - [https://en.wikipedia.org/wiki/Datalog](https://en.wikipedia.org/wiki/Datalog)
1880
1897
  - [https://en.wikipedia.org/wiki/Skolem_normal_form](https://en.wikipedia.org/wiki/Skolem_normal_form)
1881
-
package/eyeling.js CHANGED
@@ -5226,64 +5226,123 @@ function skolemIriFromGroundTerm(t) {
5226
5226
  function applySubstTerm(t, s) {
5227
5227
  // Common case: variable
5228
5228
  if (t instanceof Var) {
5229
- // Fast path: unbound variable → no change
5230
5229
  const first = s[t.name];
5231
- if (first === undefined) {
5232
- return t;
5233
- }
5230
+ if (first === undefined) return t;
5234
5231
 
5235
5232
  // Follow chains X -> Y -> ... until we hit a non-var or a cycle.
5233
+ // Avoid allocating a Set in the common case (short chains).
5236
5234
  let cur = first;
5237
- const seen = new Set([t.name]);
5235
+ let seen0 = t.name;
5236
+ let seen1 = null;
5237
+ let seen2 = null;
5238
+ let seenSet = null;
5239
+ let steps = 0;
5240
+
5238
5241
  while (cur instanceof Var) {
5239
5242
  const name = cur.name;
5240
- if (seen.has(name)) break; // cycle
5241
- seen.add(name);
5243
+
5244
+ // Cycle check
5245
+ if (name === seen0 || name === seen1 || name === seen2 || (seenSet && seenSet.has(name))) {
5246
+ return cur;
5247
+ }
5248
+
5249
+ if (steps == 0) {
5250
+ seen1 = name;
5251
+ } else if (steps == 1) {
5252
+ seen2 = name;
5253
+ } else if (steps == 2) {
5254
+ seenSet = new Set([seen0, seen1, seen2]);
5255
+ seenSet.add(name);
5256
+ } else if (seenSet) {
5257
+ seenSet.add(name);
5258
+ }
5259
+
5242
5260
  const nxt = s[name];
5243
- if (!nxt) break;
5261
+ if (nxt === undefined) break;
5244
5262
  cur = nxt;
5245
- }
5246
5263
 
5247
- if (cur instanceof Var) {
5248
- // Still a var: keep it as is (no need to clone)
5249
- return cur;
5264
+ steps += 1;
5265
+ // Safety guard against pathological substitutions
5266
+ if (steps > 1024) break;
5250
5267
  }
5251
- // Bound to a non-var term: apply substitution recursively in case it
5252
- // contains variables inside.
5268
+
5269
+ if (cur instanceof Var) return cur;
5270
+ // Bound to a non-var term: apply substitution recursively in case it contains variables inside.
5253
5271
  return applySubstTerm(cur, s);
5254
5272
  }
5255
5273
 
5256
5274
  // Non-variable terms
5257
5275
  if (t instanceof ListTerm) {
5258
- return new ListTerm(t.elems.map((e) => applySubstTerm(e, s)));
5276
+ const xs = t.elems;
5277
+ let out = null;
5278
+ for (let i = 0; i < xs.length; i++) {
5279
+ const v = applySubstTerm(xs[i], s);
5280
+ if (out) {
5281
+ out.push(v);
5282
+ } else if (v !== xs[i]) {
5283
+ out = xs.slice(0, i);
5284
+ out.push(v);
5285
+ }
5286
+ }
5287
+ return out ? new ListTerm(out) : t;
5259
5288
  }
5260
5289
 
5261
5290
  if (t instanceof OpenListTerm) {
5262
- const newPrefix = t.prefix.map((e) => applySubstTerm(e, s));
5263
- const tailTerm = s[t.tailVar];
5264
- if (tailTerm !== undefined) {
5265
- const tailApplied = applySubstTerm(tailTerm, s);
5266
- if (tailApplied instanceof ListTerm) {
5267
- return new ListTerm(newPrefix.concat(tailApplied.elems));
5268
- } else if (tailApplied instanceof OpenListTerm) {
5269
- return new OpenListTerm(newPrefix.concat(tailApplied.prefix), tailApplied.tailVar);
5270
- } else {
5271
- return new OpenListTerm(newPrefix, t.tailVar);
5291
+ const xs = t.prefix;
5292
+ let newPrefix = null;
5293
+ for (let i = 0; i < xs.length; i++) {
5294
+ const v = applySubstTerm(xs[i], s);
5295
+ if (newPrefix) {
5296
+ newPrefix.push(v);
5297
+ } else if (v !== xs[i]) {
5298
+ newPrefix = xs.slice(0, i);
5299
+ newPrefix.push(v);
5272
5300
  }
5301
+ }
5302
+ const prefixApplied = newPrefix || xs;
5303
+
5304
+ const tailTerm = s[t.tailVar];
5305
+ if (tailTerm === undefined) {
5306
+ return prefixApplied === xs ? t : new OpenListTerm(prefixApplied, t.tailVar);
5307
+ }
5308
+
5309
+ const tailApplied = applySubstTerm(tailTerm, s);
5310
+ if (tailApplied instanceof ListTerm) {
5311
+ if (prefixApplied.length === 0) return tailApplied;
5312
+ return new ListTerm(prefixApplied.concat(tailApplied.elems));
5313
+ } else if (tailApplied instanceof OpenListTerm) {
5314
+ if (prefixApplied.length === 0) return tailApplied;
5315
+ return new OpenListTerm(prefixApplied.concat(tailApplied.prefix), tailApplied.tailVar);
5273
5316
  } else {
5274
- return new OpenListTerm(newPrefix, t.tailVar);
5317
+ // Non-list tail binding: keep as open list (matches existing behavior).
5318
+ return prefixApplied === xs ? t : new OpenListTerm(prefixApplied, t.tailVar);
5275
5319
  }
5276
5320
  }
5277
5321
 
5278
5322
  if (t instanceof GraphTerm) {
5279
- return new GraphTerm(t.triples.map((tr) => applySubstTriple(tr, s)));
5323
+ const xs = t.triples;
5324
+ let out = null;
5325
+ for (let i = 0; i < xs.length; i++) {
5326
+ const v = applySubstTriple(xs[i], s);
5327
+ if (out) {
5328
+ out.push(v);
5329
+ } else if (v !== xs[i]) {
5330
+ out = xs.slice(0, i);
5331
+ out.push(v);
5332
+ }
5333
+ }
5334
+ return out ? new GraphTerm(out) : t;
5280
5335
  }
5281
5336
 
5282
5337
  return t;
5283
5338
  }
5284
5339
 
5285
5340
  function applySubstTriple(tr, s) {
5286
- return new Triple(applySubstTerm(tr.s, s), applySubstTerm(tr.p, s), applySubstTerm(tr.o, s));
5341
+ const s2 = applySubstTerm(tr.s, s);
5342
+ const p2 = applySubstTerm(tr.p, s);
5343
+ const o2 = applySubstTerm(tr.o, s);
5344
+ if (s2 === tr.s && p2 === tr.p && o2 === tr.o) return tr;
5345
+ return new Triple(s2, p2, o2);
5287
5346
  }
5288
5347
 
5289
5348
  function iriValue(t) {
@@ -5292,7 +5351,7 @@ function iriValue(t) {
5292
5351
 
5293
5352
  function unifyOpenWithList(prefix, tailv, ys, subst) {
5294
5353
  if (ys.length < prefix.length) return null;
5295
- let s2 = { ...subst };
5354
+ let s2 = subst;
5296
5355
  for (let i = 0; i < prefix.length; i++) {
5297
5356
  s2 = unifyTerm(prefix[i], ys[i], s2);
5298
5357
  if (s2 === null) return null;
@@ -5307,7 +5366,7 @@ function unifyGraphTriples(xs, ys, subst) {
5307
5366
  if (xs.length !== ys.length) return null;
5308
5367
 
5309
5368
  // Fast path: exact same sequence.
5310
- if (triplesListEqual(xs, ys)) return { ...subst };
5369
+ if (triplesListEqual(xs, ys)) return subst;
5311
5370
 
5312
5371
  // Backtracking match (order-insensitive), *threading* the substitution through.
5313
5372
  const used = new Array(ys.length).fill(false);
@@ -5334,7 +5393,7 @@ function unifyGraphTriples(xs, ys, subst) {
5334
5393
  return null;
5335
5394
  }
5336
5395
 
5337
- return step(0, { ...subst }); // IMPORTANT: start from the incoming subst
5396
+ return step(0, subst); // IMPORTANT: start from the incoming subst
5338
5397
  }
5339
5398
 
5340
5399
  function unifyTerm(a, b, subst) {
@@ -5361,7 +5420,7 @@ function unifyTermWithOptions(a, b, subst, opts) {
5361
5420
  if (a instanceof Var) {
5362
5421
  const v = a.name;
5363
5422
  const t = b;
5364
- if (t instanceof Var && t.name === v) return { ...subst };
5423
+ if (t instanceof Var && t.name === v) return subst;
5365
5424
  if (containsVarTerm(t, v)) return null;
5366
5425
  const s2 = { ...subst };
5367
5426
  s2[v] = t;
@@ -5372,20 +5431,20 @@ function unifyTermWithOptions(a, b, subst, opts) {
5372
5431
  }
5373
5432
 
5374
5433
  // Exact matches
5375
- if (a instanceof Iri && b instanceof Iri && a.value === b.value) return { ...subst };
5376
- if (a instanceof Literal && b instanceof Literal && a.value === b.value) return { ...subst };
5377
- if (a instanceof Blank && b instanceof Blank && a.label === b.label) return { ...subst };
5434
+ if (a instanceof Iri && b instanceof Iri && a.value === b.value) return subst;
5435
+ if (a instanceof Literal && b instanceof Literal && a.value === b.value) return subst;
5436
+ if (a instanceof Blank && b instanceof Blank && a.label === b.label) return subst;
5378
5437
 
5379
5438
  // Plain string vs xsd:string equivalence
5380
5439
  if (a instanceof Literal && b instanceof Literal) {
5381
- if (literalsEquivalentAsXsdString(a.value, b.value)) return { ...subst };
5440
+ if (literalsEquivalentAsXsdString(a.value, b.value)) return subst;
5382
5441
  }
5383
5442
 
5384
5443
  // Boolean-value equivalence (ONLY for normal unifyTerm)
5385
5444
  if (opts.boolValueEq && a instanceof Literal && b instanceof Literal) {
5386
5445
  const ai = parseBooleanLiteralInfo(a);
5387
5446
  const bi = parseBooleanLiteralInfo(b);
5388
- if (ai && bi && ai.value === bi.value) return { ...subst };
5447
+ if (ai && bi && ai.value === bi.value) return subst;
5389
5448
  }
5390
5449
 
5391
5450
  // Numeric-value match:
@@ -5397,11 +5456,11 @@ function unifyTermWithOptions(a, b, subst, opts) {
5397
5456
  if (ai && bi) {
5398
5457
  if (ai.dt === bi.dt) {
5399
5458
  if (ai.kind === 'bigint' && bi.kind === 'bigint') {
5400
- if (ai.value === bi.value) return { ...subst };
5459
+ if (ai.value === bi.value) return subst;
5401
5460
  } else {
5402
5461
  const an = ai.kind === 'bigint' ? Number(ai.value) : ai.value;
5403
5462
  const bn = bi.kind === 'bigint' ? Number(bi.value) : bi.value;
5404
- if (!Number.isNaN(an) && !Number.isNaN(bn) && an === bn) return { ...subst };
5463
+ if (!Number.isNaN(an) && !Number.isNaN(bn) && an === bn) return subst;
5405
5464
  }
5406
5465
  }
5407
5466
 
@@ -5414,7 +5473,7 @@ function unifyTermWithOptions(a, b, subst, opts) {
5414
5473
  const dec = parseXsdDecimalToBigIntScale(decInfo.lexStr);
5415
5474
  if (dec) {
5416
5475
  const scaledInt = intInfo.value * pow10n(dec.scale);
5417
- if (scaledInt === dec.num) return { ...subst };
5476
+ if (scaledInt === dec.num) return subst;
5418
5477
  }
5419
5478
  }
5420
5479
  }
@@ -5432,7 +5491,7 @@ function unifyTermWithOptions(a, b, subst, opts) {
5432
5491
  // Open list vs open list
5433
5492
  if (a instanceof OpenListTerm && b instanceof OpenListTerm) {
5434
5493
  if (a.tailVar !== b.tailVar || a.prefix.length !== b.prefix.length) return null;
5435
- let s2 = { ...subst };
5494
+ let s2 = subst;
5436
5495
  for (let i = 0; i < a.prefix.length; i++) {
5437
5496
  s2 = unifyTermWithOptions(a.prefix[i], b.prefix[i], s2, opts);
5438
5497
  if (s2 === null) return null;
@@ -5443,7 +5502,7 @@ function unifyTermWithOptions(a, b, subst, opts) {
5443
5502
  // List terms
5444
5503
  if (a instanceof ListTerm && b instanceof ListTerm) {
5445
5504
  if (a.elems.length !== b.elems.length) return null;
5446
- let s2 = { ...subst };
5505
+ let s2 = subst;
5447
5506
  for (let i = 0; i < a.elems.length; i++) {
5448
5507
  s2 = unifyTermWithOptions(a.elems[i], b.elems[i], s2, opts);
5449
5508
  if (s2 === null) return null;
@@ -5453,7 +5512,7 @@ function unifyTermWithOptions(a, b, subst, opts) {
5453
5512
 
5454
5513
  // Graphs
5455
5514
  if (a instanceof GraphTerm && b instanceof GraphTerm) {
5456
- if (alphaEqGraphTriples(a.triples, b.triples)) return { ...subst };
5515
+ if (alphaEqGraphTriples(a.triples, b.triples)) return subst;
5457
5516
  return unifyGraphTriples(a.triples, b.triples, subst);
5458
5517
  }
5459
5518
 
@@ -5473,18 +5532,25 @@ function unifyTriple(pat, fact, subst) {
5473
5532
  }
5474
5533
 
5475
5534
  function composeSubst(outer, delta) {
5476
- if (!delta || Object.keys(delta).length === 0) {
5477
- return { ...outer };
5478
- }
5479
- const out = { ...outer };
5480
- for (const [k, v] of Object.entries(delta)) {
5481
- if (Object.prototype.hasOwnProperty.call(out, k)) {
5482
- if (!termsEqual(out[k], v)) return null;
5483
- } else {
5484
- out[k] = v;
5535
+ if (!delta) return outer;
5536
+
5537
+ // Fast path: avoid copying `outer` when `delta` is empty or only repeats existing bindings.
5538
+ let out = null;
5539
+
5540
+ for (const k in delta) {
5541
+ if (!Object.prototype.hasOwnProperty.call(delta, k)) continue;
5542
+ const v = delta[k];
5543
+
5544
+ if (Object.prototype.hasOwnProperty.call(outer, k)) {
5545
+ if (!termsEqual(outer[k], v)) return null;
5546
+ continue;
5485
5547
  }
5548
+
5549
+ if (!out) out = { ...outer };
5550
+ out[k] = v;
5486
5551
  }
5487
- return out;
5552
+
5553
+ return out || outer;
5488
5554
  }
5489
5555
 
5490
5556
 
@@ -5863,11 +5929,14 @@ function forwardChain(facts, forwardRules, backRules, onDerived /* optional */)
5863
5929
 
5864
5930
  function firingKey(ruleIndex, instantiatedPremises) {
5865
5931
  // Deterministic key derived from the instantiated body (ground per substitution).
5866
- const parts = [];
5867
- for (const tr of instantiatedPremises) {
5868
- parts.push(JSON.stringify([skolemKeyFromTerm(tr.s), skolemKeyFromTerm(tr.p), skolemKeyFromTerm(tr.o)]));
5932
+ // Avoid repeated JSON.stringify of arrays-of-strings (hot path).
5933
+ let out = `R${ruleIndex}|`;
5934
+ for (let i = 0; i < instantiatedPremises.length; i++) {
5935
+ const tr = instantiatedPremises[i];
5936
+ if (i) out += '\n';
5937
+ out += skolemKeyFromTerm(tr.s) + ' ' + skolemKeyFromTerm(tr.p) + ' ' + skolemKeyFromTerm(tr.o);
5869
5938
  }
5870
- return `R${ruleIndex}|` + parts.join('\\n');
5939
+ return out;
5871
5940
  }
5872
5941
 
5873
5942
  // Make rules visible to introspection builtins
package/lib/engine.js CHANGED
@@ -859,64 +859,123 @@ function skolemIriFromGroundTerm(t) {
859
859
  function applySubstTerm(t, s) {
860
860
  // Common case: variable
861
861
  if (t instanceof Var) {
862
- // Fast path: unbound variable → no change
863
862
  const first = s[t.name];
864
- if (first === undefined) {
865
- return t;
866
- }
863
+ if (first === undefined) return t;
867
864
 
868
865
  // Follow chains X -> Y -> ... until we hit a non-var or a cycle.
866
+ // Avoid allocating a Set in the common case (short chains).
869
867
  let cur = first;
870
- const seen = new Set([t.name]);
868
+ let seen0 = t.name;
869
+ let seen1 = null;
870
+ let seen2 = null;
871
+ let seenSet = null;
872
+ let steps = 0;
873
+
871
874
  while (cur instanceof Var) {
872
875
  const name = cur.name;
873
- if (seen.has(name)) break; // cycle
874
- seen.add(name);
876
+
877
+ // Cycle check
878
+ if (name === seen0 || name === seen1 || name === seen2 || (seenSet && seenSet.has(name))) {
879
+ return cur;
880
+ }
881
+
882
+ if (steps == 0) {
883
+ seen1 = name;
884
+ } else if (steps == 1) {
885
+ seen2 = name;
886
+ } else if (steps == 2) {
887
+ seenSet = new Set([seen0, seen1, seen2]);
888
+ seenSet.add(name);
889
+ } else if (seenSet) {
890
+ seenSet.add(name);
891
+ }
892
+
875
893
  const nxt = s[name];
876
- if (!nxt) break;
894
+ if (nxt === undefined) break;
877
895
  cur = nxt;
878
- }
879
896
 
880
- if (cur instanceof Var) {
881
- // Still a var: keep it as is (no need to clone)
882
- return cur;
897
+ steps += 1;
898
+ // Safety guard against pathological substitutions
899
+ if (steps > 1024) break;
883
900
  }
884
- // Bound to a non-var term: apply substitution recursively in case it
885
- // contains variables inside.
901
+
902
+ if (cur instanceof Var) return cur;
903
+ // Bound to a non-var term: apply substitution recursively in case it contains variables inside.
886
904
  return applySubstTerm(cur, s);
887
905
  }
888
906
 
889
907
  // Non-variable terms
890
908
  if (t instanceof ListTerm) {
891
- return new ListTerm(t.elems.map((e) => applySubstTerm(e, s)));
909
+ const xs = t.elems;
910
+ let out = null;
911
+ for (let i = 0; i < xs.length; i++) {
912
+ const v = applySubstTerm(xs[i], s);
913
+ if (out) {
914
+ out.push(v);
915
+ } else if (v !== xs[i]) {
916
+ out = xs.slice(0, i);
917
+ out.push(v);
918
+ }
919
+ }
920
+ return out ? new ListTerm(out) : t;
892
921
  }
893
922
 
894
923
  if (t instanceof OpenListTerm) {
895
- const newPrefix = t.prefix.map((e) => applySubstTerm(e, s));
896
- const tailTerm = s[t.tailVar];
897
- if (tailTerm !== undefined) {
898
- const tailApplied = applySubstTerm(tailTerm, s);
899
- if (tailApplied instanceof ListTerm) {
900
- return new ListTerm(newPrefix.concat(tailApplied.elems));
901
- } else if (tailApplied instanceof OpenListTerm) {
902
- return new OpenListTerm(newPrefix.concat(tailApplied.prefix), tailApplied.tailVar);
903
- } else {
904
- return new OpenListTerm(newPrefix, t.tailVar);
924
+ const xs = t.prefix;
925
+ let newPrefix = null;
926
+ for (let i = 0; i < xs.length; i++) {
927
+ const v = applySubstTerm(xs[i], s);
928
+ if (newPrefix) {
929
+ newPrefix.push(v);
930
+ } else if (v !== xs[i]) {
931
+ newPrefix = xs.slice(0, i);
932
+ newPrefix.push(v);
905
933
  }
934
+ }
935
+ const prefixApplied = newPrefix || xs;
936
+
937
+ const tailTerm = s[t.tailVar];
938
+ if (tailTerm === undefined) {
939
+ return prefixApplied === xs ? t : new OpenListTerm(prefixApplied, t.tailVar);
940
+ }
941
+
942
+ const tailApplied = applySubstTerm(tailTerm, s);
943
+ if (tailApplied instanceof ListTerm) {
944
+ if (prefixApplied.length === 0) return tailApplied;
945
+ return new ListTerm(prefixApplied.concat(tailApplied.elems));
946
+ } else if (tailApplied instanceof OpenListTerm) {
947
+ if (prefixApplied.length === 0) return tailApplied;
948
+ return new OpenListTerm(prefixApplied.concat(tailApplied.prefix), tailApplied.tailVar);
906
949
  } else {
907
- return new OpenListTerm(newPrefix, t.tailVar);
950
+ // Non-list tail binding: keep as open list (matches existing behavior).
951
+ return prefixApplied === xs ? t : new OpenListTerm(prefixApplied, t.tailVar);
908
952
  }
909
953
  }
910
954
 
911
955
  if (t instanceof GraphTerm) {
912
- return new GraphTerm(t.triples.map((tr) => applySubstTriple(tr, s)));
956
+ const xs = t.triples;
957
+ let out = null;
958
+ for (let i = 0; i < xs.length; i++) {
959
+ const v = applySubstTriple(xs[i], s);
960
+ if (out) {
961
+ out.push(v);
962
+ } else if (v !== xs[i]) {
963
+ out = xs.slice(0, i);
964
+ out.push(v);
965
+ }
966
+ }
967
+ return out ? new GraphTerm(out) : t;
913
968
  }
914
969
 
915
970
  return t;
916
971
  }
917
972
 
918
973
  function applySubstTriple(tr, s) {
919
- return new Triple(applySubstTerm(tr.s, s), applySubstTerm(tr.p, s), applySubstTerm(tr.o, s));
974
+ const s2 = applySubstTerm(tr.s, s);
975
+ const p2 = applySubstTerm(tr.p, s);
976
+ const o2 = applySubstTerm(tr.o, s);
977
+ if (s2 === tr.s && p2 === tr.p && o2 === tr.o) return tr;
978
+ return new Triple(s2, p2, o2);
920
979
  }
921
980
 
922
981
  function iriValue(t) {
@@ -925,7 +984,7 @@ function iriValue(t) {
925
984
 
926
985
  function unifyOpenWithList(prefix, tailv, ys, subst) {
927
986
  if (ys.length < prefix.length) return null;
928
- let s2 = { ...subst };
987
+ let s2 = subst;
929
988
  for (let i = 0; i < prefix.length; i++) {
930
989
  s2 = unifyTerm(prefix[i], ys[i], s2);
931
990
  if (s2 === null) return null;
@@ -940,7 +999,7 @@ function unifyGraphTriples(xs, ys, subst) {
940
999
  if (xs.length !== ys.length) return null;
941
1000
 
942
1001
  // Fast path: exact same sequence.
943
- if (triplesListEqual(xs, ys)) return { ...subst };
1002
+ if (triplesListEqual(xs, ys)) return subst;
944
1003
 
945
1004
  // Backtracking match (order-insensitive), *threading* the substitution through.
946
1005
  const used = new Array(ys.length).fill(false);
@@ -967,7 +1026,7 @@ function unifyGraphTriples(xs, ys, subst) {
967
1026
  return null;
968
1027
  }
969
1028
 
970
- return step(0, { ...subst }); // IMPORTANT: start from the incoming subst
1029
+ return step(0, subst); // IMPORTANT: start from the incoming subst
971
1030
  }
972
1031
 
973
1032
  function unifyTerm(a, b, subst) {
@@ -994,7 +1053,7 @@ function unifyTermWithOptions(a, b, subst, opts) {
994
1053
  if (a instanceof Var) {
995
1054
  const v = a.name;
996
1055
  const t = b;
997
- if (t instanceof Var && t.name === v) return { ...subst };
1056
+ if (t instanceof Var && t.name === v) return subst;
998
1057
  if (containsVarTerm(t, v)) return null;
999
1058
  const s2 = { ...subst };
1000
1059
  s2[v] = t;
@@ -1005,20 +1064,20 @@ function unifyTermWithOptions(a, b, subst, opts) {
1005
1064
  }
1006
1065
 
1007
1066
  // Exact matches
1008
- if (a instanceof Iri && b instanceof Iri && a.value === b.value) return { ...subst };
1009
- if (a instanceof Literal && b instanceof Literal && a.value === b.value) return { ...subst };
1010
- if (a instanceof Blank && b instanceof Blank && a.label === b.label) return { ...subst };
1067
+ if (a instanceof Iri && b instanceof Iri && a.value === b.value) return subst;
1068
+ if (a instanceof Literal && b instanceof Literal && a.value === b.value) return subst;
1069
+ if (a instanceof Blank && b instanceof Blank && a.label === b.label) return subst;
1011
1070
 
1012
1071
  // Plain string vs xsd:string equivalence
1013
1072
  if (a instanceof Literal && b instanceof Literal) {
1014
- if (literalsEquivalentAsXsdString(a.value, b.value)) return { ...subst };
1073
+ if (literalsEquivalentAsXsdString(a.value, b.value)) return subst;
1015
1074
  }
1016
1075
 
1017
1076
  // Boolean-value equivalence (ONLY for normal unifyTerm)
1018
1077
  if (opts.boolValueEq && a instanceof Literal && b instanceof Literal) {
1019
1078
  const ai = parseBooleanLiteralInfo(a);
1020
1079
  const bi = parseBooleanLiteralInfo(b);
1021
- if (ai && bi && ai.value === bi.value) return { ...subst };
1080
+ if (ai && bi && ai.value === bi.value) return subst;
1022
1081
  }
1023
1082
 
1024
1083
  // Numeric-value match:
@@ -1030,11 +1089,11 @@ function unifyTermWithOptions(a, b, subst, opts) {
1030
1089
  if (ai && bi) {
1031
1090
  if (ai.dt === bi.dt) {
1032
1091
  if (ai.kind === 'bigint' && bi.kind === 'bigint') {
1033
- if (ai.value === bi.value) return { ...subst };
1092
+ if (ai.value === bi.value) return subst;
1034
1093
  } else {
1035
1094
  const an = ai.kind === 'bigint' ? Number(ai.value) : ai.value;
1036
1095
  const bn = bi.kind === 'bigint' ? Number(bi.value) : bi.value;
1037
- if (!Number.isNaN(an) && !Number.isNaN(bn) && an === bn) return { ...subst };
1096
+ if (!Number.isNaN(an) && !Number.isNaN(bn) && an === bn) return subst;
1038
1097
  }
1039
1098
  }
1040
1099
 
@@ -1047,7 +1106,7 @@ function unifyTermWithOptions(a, b, subst, opts) {
1047
1106
  const dec = parseXsdDecimalToBigIntScale(decInfo.lexStr);
1048
1107
  if (dec) {
1049
1108
  const scaledInt = intInfo.value * pow10n(dec.scale);
1050
- if (scaledInt === dec.num) return { ...subst };
1109
+ if (scaledInt === dec.num) return subst;
1051
1110
  }
1052
1111
  }
1053
1112
  }
@@ -1065,7 +1124,7 @@ function unifyTermWithOptions(a, b, subst, opts) {
1065
1124
  // Open list vs open list
1066
1125
  if (a instanceof OpenListTerm && b instanceof OpenListTerm) {
1067
1126
  if (a.tailVar !== b.tailVar || a.prefix.length !== b.prefix.length) return null;
1068
- let s2 = { ...subst };
1127
+ let s2 = subst;
1069
1128
  for (let i = 0; i < a.prefix.length; i++) {
1070
1129
  s2 = unifyTermWithOptions(a.prefix[i], b.prefix[i], s2, opts);
1071
1130
  if (s2 === null) return null;
@@ -1076,7 +1135,7 @@ function unifyTermWithOptions(a, b, subst, opts) {
1076
1135
  // List terms
1077
1136
  if (a instanceof ListTerm && b instanceof ListTerm) {
1078
1137
  if (a.elems.length !== b.elems.length) return null;
1079
- let s2 = { ...subst };
1138
+ let s2 = subst;
1080
1139
  for (let i = 0; i < a.elems.length; i++) {
1081
1140
  s2 = unifyTermWithOptions(a.elems[i], b.elems[i], s2, opts);
1082
1141
  if (s2 === null) return null;
@@ -1086,7 +1145,7 @@ function unifyTermWithOptions(a, b, subst, opts) {
1086
1145
 
1087
1146
  // Graphs
1088
1147
  if (a instanceof GraphTerm && b instanceof GraphTerm) {
1089
- if (alphaEqGraphTriples(a.triples, b.triples)) return { ...subst };
1148
+ if (alphaEqGraphTriples(a.triples, b.triples)) return subst;
1090
1149
  return unifyGraphTriples(a.triples, b.triples, subst);
1091
1150
  }
1092
1151
 
@@ -1106,18 +1165,25 @@ function unifyTriple(pat, fact, subst) {
1106
1165
  }
1107
1166
 
1108
1167
  function composeSubst(outer, delta) {
1109
- if (!delta || Object.keys(delta).length === 0) {
1110
- return { ...outer };
1111
- }
1112
- const out = { ...outer };
1113
- for (const [k, v] of Object.entries(delta)) {
1114
- if (Object.prototype.hasOwnProperty.call(out, k)) {
1115
- if (!termsEqual(out[k], v)) return null;
1116
- } else {
1117
- out[k] = v;
1168
+ if (!delta) return outer;
1169
+
1170
+ // Fast path: avoid copying `outer` when `delta` is empty or only repeats existing bindings.
1171
+ let out = null;
1172
+
1173
+ for (const k in delta) {
1174
+ if (!Object.prototype.hasOwnProperty.call(delta, k)) continue;
1175
+ const v = delta[k];
1176
+
1177
+ if (Object.prototype.hasOwnProperty.call(outer, k)) {
1178
+ if (!termsEqual(outer[k], v)) return null;
1179
+ continue;
1118
1180
  }
1181
+
1182
+ if (!out) out = { ...outer };
1183
+ out[k] = v;
1119
1184
  }
1120
- return out;
1185
+
1186
+ return out || outer;
1121
1187
  }
1122
1188
 
1123
1189
 
@@ -1496,11 +1562,14 @@ function forwardChain(facts, forwardRules, backRules, onDerived /* optional */)
1496
1562
 
1497
1563
  function firingKey(ruleIndex, instantiatedPremises) {
1498
1564
  // Deterministic key derived from the instantiated body (ground per substitution).
1499
- const parts = [];
1500
- for (const tr of instantiatedPremises) {
1501
- parts.push(JSON.stringify([skolemKeyFromTerm(tr.s), skolemKeyFromTerm(tr.p), skolemKeyFromTerm(tr.o)]));
1565
+ // Avoid repeated JSON.stringify of arrays-of-strings (hot path).
1566
+ let out = `R${ruleIndex}|`;
1567
+ for (let i = 0; i < instantiatedPremises.length; i++) {
1568
+ const tr = instantiatedPremises[i];
1569
+ if (i) out += '\n';
1570
+ out += skolemKeyFromTerm(tr.s) + ' ' + skolemKeyFromTerm(tr.p) + ' ' + skolemKeyFromTerm(tr.o);
1502
1571
  }
1503
- return `R${ruleIndex}|` + parts.join('\\n');
1572
+ return out;
1504
1573
  }
1505
1574
 
1506
1575
  // Make rules visible to introspection builtins
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyeling",
3
- "version": "1.11.9",
3
+ "version": "1.11.10",
4
4
  "description": "A minimal Notation3 (N3) reasoner in JavaScript.",
5
5
  "main": "./index.js",
6
6
  "keywords": [