eyeleng 1.0.11 → 1.0.12

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/README.md CHANGED
@@ -76,6 +76,8 @@ then the body matches with `?parent = :alice` and `?child = :bob`, and the head
76
76
 
77
77
  Negation is handled by stratified evaluation: rules are grouped into dependency layers, and recursion through negation is rejected so the result stays deterministic.
78
78
 
79
+ Recursive rules that create new terms through `SET`/`BIND` or head blank nodes run in relaxed mode by default. Relaxed mode can derive useful finite closures, but termination is not guaranteed; use `--max-iterations` as a safety valve. `--strict` rejects these recursive term-generating cycles at analysis time. Head blank nodes are deterministically skolemized per rule and solution mapping, so the same firing reuses the same witness node instead of creating a fresh one each pass.
80
+
79
81
  ## Language surface
80
82
 
81
83
  SRL supports the practical rule features used by the SHACL 1.2 Rules tests and examples:
@@ -90,7 +92,7 @@ DATA {
90
92
 
91
93
  RULE { ?x :grade ?grade } WHERE {
92
94
  ?x :score ?score .
93
- FILTER(?score >= 5) .
95
+ FILTER(?score >= 5)
94
96
  BIND(concat("pass-", str(?score)) AS ?grade)
95
97
  }
96
98
 
@@ -202,7 +204,7 @@ Important options:
202
204
  --trace print derivation trace to stderr, or include it in JSON
203
205
  --stats print iteration and triple counts to stderr
204
206
  --check parse and analyze only; do not run rules
205
- --strict treat static warnings as errors
207
+ --strict treat static warnings as errors, including recursive term generation
206
208
  --deps print rule dependency edges during --check
207
209
  --query TEXT run a raw SRL body pattern over the closure
208
210
  --query-file FILE read a raw SRL body pattern from a file
@@ -4048,8 +4048,8 @@
4048
4048
  "src/engine.js": function (require, module, exports) {
4049
4049
  'use strict';
4050
4050
 
4051
- const { TripleStore, bindingKey, instantiateTriple } = require('./store.js');
4052
- const { tripleKey, termEquals } = require('./term.js');
4051
+ const { TripleStore, bindingKey } = require('./store.js');
4052
+ const { tripleKey, termKey, termEquals, blankNode, tripleTerm } = require('./term.js');
4053
4053
  const { evalExpression, booleanValue, asTerm } = require('./builtins.js');
4054
4054
  const { analyze } = require('./analyze.js');
4055
4055
 
@@ -4069,7 +4069,7 @@
4069
4069
  runOnce: !!rule.runOnce,
4070
4070
  }));
4071
4071
 
4072
- const analysis = options.analysis || analyze(program);
4072
+ const analysis = options.analysis || analyze(program, options);
4073
4073
  if (analysis.errors && analysis.errors.length > 0 && !options.ignoreAnalysisErrors) {
4074
4074
  throw new Error(`Analysis failed: ${analysis.errors.map((error) => error.message).join('; ')}`);
4075
4075
  }
@@ -4080,6 +4080,9 @@
4080
4080
  layerIndexes,
4081
4081
  analysis.dependency ? analysis.dependency.edges : [],
4082
4082
  );
4083
+ const relaxedRecursiveRunOnce = options.relaxedRecursion === false
4084
+ ? new Set()
4085
+ : recursiveTermGenerationRuleIndexes(analysis);
4083
4086
  const baseContext = {
4084
4087
  ...evalOptions,
4085
4088
  maxIterations,
@@ -4095,8 +4098,8 @@
4095
4098
 
4096
4099
  for (let layerIndex = 0; layerIndex < layerIndexes.length; layerIndex += 1) {
4097
4100
  const layer = layerIndexes[layerIndex];
4098
- const ordinary = layer.filter((ruleIndex) => !program.rules[ruleIndex].runOnce);
4099
- const runOnce = layer.filter((ruleIndex) => program.rules[ruleIndex].runOnce);
4101
+ const ordinary = layer.filter((ruleIndex) => !program.rules[ruleIndex].runOnce || relaxedRecursiveRunOnce.has(ruleIndex));
4102
+ const runOnce = layer.filter((ruleIndex) => program.rules[ruleIndex].runOnce && !relaxedRecursiveRunOnce.has(ruleIndex));
4100
4103
 
4101
4104
  if (runOnce.length > 0) {
4102
4105
  iterations += 1;
@@ -4196,6 +4199,7 @@
4196
4199
  let added = 0;
4197
4200
  const dedupeBindings = rule.body.some((clause) => clause.type === 'path');
4198
4201
  const seenBindings = dedupeBindings ? new Set() : null;
4202
+ const headBlankLabels = collectHeadBlankLabels(rule.head);
4199
4203
 
4200
4204
  const bodyBindings = rule.body.length === 1 && rule.body[0].type === 'triple'
4201
4205
  ? store.match(rule.body[0].triple, {})
@@ -4210,8 +4214,10 @@
4210
4214
  applications += 1;
4211
4215
  context.perRule[ruleIndex].applications += 1;
4212
4216
 
4217
+ const headBlankMap = headBlankLabels.size > 0 ? new Map() : null;
4218
+ const skolemKey = headBlankMap ? skolemizationKey(ruleIndex, binding) : null;
4213
4219
  for (const head of rule.head) {
4214
- const triple = instantiateTriple(head, binding);
4220
+ const triple = instantiateHeadTriple(head, binding, headBlankLabels, headBlankMap, skolemKey);
4215
4221
  if (!triple) continue;
4216
4222
  if (store.add(triple)) {
4217
4223
  added += 1;
@@ -4233,6 +4239,94 @@
4233
4239
  return { applications, added };
4234
4240
  }
4235
4241
 
4242
+ function recursiveTermGenerationRuleIndexes(analysis) {
4243
+ const out = new Set();
4244
+ if (!analysis || !analysis.dependency || !analysis.diagnostics) return out;
4245
+ const byName = new Map((analysis.dependency.rules || []).map((rule) => [rule.name, rule.index]));
4246
+ for (const diagnostic of analysis.diagnostics) {
4247
+ if (diagnostic.code !== 'recursive-assignment-rule') continue;
4248
+ if (byName.has(diagnostic.rule)) out.add(byName.get(diagnostic.rule));
4249
+ }
4250
+ return out;
4251
+ }
4252
+
4253
+ function instantiateHeadTriple(pattern, binding, headBlankLabels, headBlankMap, skolemKey) {
4254
+ const s = instantiateHeadTerm(pattern.s, binding, headBlankLabels, headBlankMap, skolemKey);
4255
+ const p = instantiateHeadTerm(pattern.p, binding, headBlankLabels, headBlankMap, skolemKey);
4256
+ const o = instantiateHeadTerm(pattern.o, binding, headBlankLabels, headBlankMap, skolemKey);
4257
+ if (!s || !p || !o) return null;
4258
+ if (p.type !== 'iri') return null;
4259
+ return { s, p, o };
4260
+ }
4261
+
4262
+ function instantiateHeadTerm(term, binding, headBlankLabels, headBlankMap, skolemKey) {
4263
+ if (term.type === 'var') return binding[term.value] || null;
4264
+ if (term.type === 'blank' && headBlankLabels.has(term.value)) {
4265
+ let label = headBlankMap.get(term.value);
4266
+ if (!label) {
4267
+ label = `sk_${deterministicSkolemIdFromKey(`${skolemKey}|${term.value}`).replace(/-/g, '_')}`;
4268
+ headBlankMap.set(term.value, label);
4269
+ }
4270
+ return blankNode(label);
4271
+ }
4272
+ if (term.type === 'triple') {
4273
+ const s = instantiateHeadTerm(term.s, binding, headBlankLabels, headBlankMap, skolemKey);
4274
+ const p = instantiateHeadTerm(term.p, binding, headBlankLabels, headBlankMap, skolemKey);
4275
+ const o = instantiateHeadTerm(term.o, binding, headBlankLabels, headBlankMap, skolemKey);
4276
+ if (!s || !p || !o) return null;
4277
+ return tripleTerm(s, p, o);
4278
+ }
4279
+ return term;
4280
+ }
4281
+
4282
+ function collectHeadBlankLabels(head) {
4283
+ const labels = new Set();
4284
+ for (const triple of head || []) {
4285
+ collectBlankLabelsFromTerm(triple.s, labels);
4286
+ collectBlankLabelsFromTerm(triple.p, labels);
4287
+ collectBlankLabelsFromTerm(triple.o, labels);
4288
+ }
4289
+ return labels;
4290
+ }
4291
+
4292
+ function collectBlankLabelsFromTerm(term, labels) {
4293
+ if (!term) return;
4294
+ if (term.type === 'blank') {
4295
+ labels.add(term.value);
4296
+ return;
4297
+ }
4298
+ if (term.type === 'triple') {
4299
+ collectBlankLabelsFromTerm(term.s, labels);
4300
+ collectBlankLabelsFromTerm(term.p, labels);
4301
+ collectBlankLabelsFromTerm(term.o, labels);
4302
+ }
4303
+ }
4304
+
4305
+ function skolemizationKey(ruleIndex, binding) {
4306
+ let out = `rule:${ruleIndex}`;
4307
+ for (const name of Object.keys(binding).sort()) {
4308
+ const value = binding[name];
4309
+ out += `|${name}=${value ? termKey(value) : 'unbound'}`;
4310
+ }
4311
+ return out;
4312
+ }
4313
+
4314
+ function deterministicSkolemIdFromKey(key) {
4315
+ let h1 = 0x811c9dc5;
4316
+ let h2 = 0x811c9dc5;
4317
+ let h3 = 0x811c9dc5;
4318
+ let h4 = 0x811c9dc5;
4319
+ for (let i = 0; i < key.length; i += 1) {
4320
+ const c = key.charCodeAt(i);
4321
+ h1 ^= c; h1 = Math.imul(h1, 0x01000193) >>> 0;
4322
+ h2 ^= c + 1; h2 = Math.imul(h2, 0x01000193) >>> 0;
4323
+ h3 ^= c + 2; h3 = Math.imul(h3, 0x01000193) >>> 0;
4324
+ h4 ^= c + 3; h4 = Math.imul(h4, 0x01000193) >>> 0;
4325
+ }
4326
+ return [h1, h2, h3, h4].map((h) => h.toString(16).padStart(8, '0')).join('');
4327
+ }
4328
+
4329
+
4236
4330
  function evaluateBody(clauses, store, initialBinding = {}, options = {}) {
4237
4331
  const bindings = [];
4238
4332
  const seen = new Set();
@@ -4589,8 +4683,8 @@
4589
4683
  function analyze(program, options = {}) {
4590
4684
  const diagnostics = [];
4591
4685
  const dependency = dependencyGraph(program, options);
4592
- const hasRunOnceRules = program.rules.some((rule) => rule.runOnce);
4593
- const recursiveIndexes = hasRunOnceRules ? recursiveRuleIndexes(dependency) : new Set();
4686
+ const hasTermGeneratingRules = dependency.rules.some((rule) => rule.createsTerms);
4687
+ const recursiveIndexes = hasTermGeneratingRules ? recursiveRuleIndexes(dependency) : new Set();
4594
4688
 
4595
4689
  program.rules.forEach((rule, index) => {
4596
4690
  const name = ruleName(rule, index);
@@ -4622,12 +4716,13 @@
4622
4716
 
4623
4717
  diagnostics.push(...sequentialWellFormednessDiagnostics(rule.body, name, program.prefixes || {}));
4624
4718
 
4625
- if (rule.runOnce && recursiveIndexes.has(index)) {
4719
+ const depRule = dependency.rules[index] || {};
4720
+ if (depRule.createsTerms && recursiveIndexes.has(index)) {
4626
4721
  diagnostics.push({
4627
4722
  code: 'recursive-assignment-rule',
4628
4723
  severity: 'warning',
4629
4724
  rule: name,
4630
- message: `${displayRuleName(name, program.prefixes || {})} is a run-once rule in a recursive dependency cycle`,
4725
+ message: `${displayRuleName(name, program.prefixes || {})} creates terms in a recursive dependency cycle; relaxed mode allows this but termination is not guaranteed (use --strict to reject it)`,
4631
4726
  });
4632
4727
  }
4633
4728
 
@@ -4674,34 +4769,47 @@
4674
4769
  negativePredicates: new Set(negativePatterns.flatMap((triple) => predicateIRIs(triple))),
4675
4770
  runOnce: !!rule.runOnce,
4676
4771
  hasAssignment: ruleHasAssignment(rule, options),
4772
+ hasTermGeneratingAssignment: ruleHasTermGeneratingAssignment(rule, options),
4677
4773
  headHasBlankNode: ruleHeadHasBlankNode(rule),
4774
+ createsTerms: ruleCreatesTerms(rule, options),
4678
4775
  };
4679
4776
  });
4680
4777
 
4681
4778
  const edgeMap = new Map();
4682
- function addEdge(from, to, negative, predicate) {
4779
+ function addEdge(from, to, kind, predicate) {
4683
4780
  const label = predicate || '*';
4684
4781
  const key = `${from.index}->${to.index}:${label}`;
4782
+ const negated = kind === 'negated';
4783
+ const termGeneration = kind === 'term-generation';
4685
4784
  const existing = edgeMap.get(key);
4686
4785
  if (existing) {
4687
- existing.negative = existing.negative || negative;
4786
+ existing.negated = existing.negated || negated;
4787
+ existing.termGeneration = existing.termGeneration || termGeneration;
4788
+ existing.negative = existing.negated || existing.termGeneration;
4688
4789
  return;
4689
4790
  }
4690
- edgeMap.set(key, { from: from.index, to: to.index, negative, predicate });
4791
+ edgeMap.set(key, {
4792
+ from: from.index,
4793
+ to: to.index,
4794
+ negative: negated || termGeneration,
4795
+ negated,
4796
+ termGeneration,
4797
+ predicate,
4798
+ });
4691
4799
  }
4692
4800
 
4693
4801
  const headIndex = buildHeadTemplateIndex(rules);
4694
4802
 
4695
4803
  for (const from of rules) {
4696
- const forceClosed = from.hasAssignment || from.headHasBlankNode;
4804
+ const forceClosed = from.createsTerms;
4697
4805
  for (const pattern of from.positivePatterns) {
4698
4806
  for (const candidate of candidateHeadTemplates(headIndex, pattern)) {
4699
- if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], forceClosed, dependencyPredicateLabel(pattern));
4807
+ if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], forceClosed ? 'term-generation' : 'positive', dependencyPredicateLabel(pattern));
4700
4808
  }
4701
4809
  }
4702
4810
  for (const pattern of from.negativePatterns) {
4703
4811
  for (const candidate of candidateHeadTemplates(headIndex, pattern)) {
4704
- if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], true, dependencyPredicateLabel(pattern));
4812
+ if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], 'negated', dependencyPredicateLabel(pattern));
4705
4813
  }
4706
4814
  }
4707
4815
  }
@@ -4717,7 +4825,7 @@
4717
4825
  const unstratifiedCycles = [];
4718
4826
  const seen = new Set();
4719
4827
  for (const edge of edges) {
4720
- if (!edge.negative) continue;
4828
+ if (!edge.negated) continue;
4721
4829
  if (edge.from === edge.to && rules[edge.from].runOnce && !rules[edge.from].headHasBlankNode) continue;
4722
4830
  if (componentOf.get(edge.from) !== componentOf.get(edge.to)) continue;
4723
4831
  const component = components[componentOf.get(edge.from)];
@@ -4740,7 +4848,10 @@
4740
4848
  positivePredicates: Array.from(rule.positivePredicates),
4741
4849
  negativePredicates: Array.from(rule.negativePredicates),
4742
4850
  runOnce: rule.runOnce,
4851
+ hasAssignment: rule.hasAssignment,
4852
+ hasTermGeneratingAssignment: rule.hasTermGeneratingAssignment,
4743
4853
  headHasBlankNode: rule.headHasBlankNode,
4854
+ createsTerms: rule.createsTerms,
4744
4855
  })),
4745
4856
  edges,
4746
4857
  components: components.map((component) => component.map((ruleIndex) => rules[ruleIndex].name)),
@@ -4910,7 +5021,14 @@
4910
5021
  function recursiveRuleIndexes(dependency) {
4911
5022
  const out = new Set();
4912
5023
  const ruleByName = new Map(dependency.rules.map((rule) => [rule.name, rule]));
4913
- const ruleByIndex = new Map(dependency.rules.map((rule) => [rule.index, rule]));
5024
+ const componentOf = new Map();
5025
+
5026
+ dependency.components.forEach((component, componentIndex) => {
5027
+ for (const name of component) {
5028
+ const rule = ruleByName.get(name);
5029
+ if (rule) componentOf.set(rule.index, componentIndex);
5030
+ }
5031
+ });
4914
5032
 
4915
5033
  for (const component of dependency.components) {
4916
5034
  if (component.length <= 1) continue;
@@ -4921,9 +5039,9 @@
4921
5039
  }
4922
5040
 
4923
5041
  for (const edge of dependency.edges) {
4924
- const rule = ruleByIndex.get(edge.from);
4925
- if (edge.from === edge.to && edge.negative && rule && rule.runOnce && !rule.headHasBlankNode) continue;
4926
- out.add(edge.from);
5042
+ const rule = (dependency.rules || []).find((candidate) => candidate.index === edge.from);
5043
+ if (edge.from === edge.to && edge.negated && rule && rule.runOnce && !rule.headHasBlankNode) continue;
5044
+ if (edge.from === edge.to || componentOf.get(edge.from) === componentOf.get(edge.to)) out.add(edge.from);
4927
5045
  }
4928
5046
  return out;
4929
5047
  }
@@ -5196,7 +5314,26 @@
5196
5314
  }
5197
5315
 
5198
5316
  function ruleHasAssignment(rule, options = {}) {
5199
- return !!options.shacl12Conformance && (rule.body || []).some((clause) => clause.type === 'set' || clause.type === 'bind');
5317
+ return (rule.body || []).some((clause) => clause.type === 'set' || clause.type === 'bind');
5318
+ }
5319
+
5320
+ function ruleHasTermGeneratingAssignment(rule, options = {}) {
5321
+ return (rule.body || []).some((clause) => (clause.type === 'set' || clause.type === 'bind') && assignmentMayCreateNewTerm(clause.expr));
5322
+ }
5323
+
5324
+ function ruleCreatesTerms(rule, options = {}) {
5325
+ return ruleHeadHasBlankNode(rule) || ruleHasTermGeneratingAssignment(rule, options);
5326
+ }
5327
+
5328
+ function assignmentMayCreateNewTerm(expr) {
5329
+ // Simple aliases and constants are not term generators: they select from a
5330
+ // fixed term or from already-bound terms. Expressions that compute values
5331
+ // through operators or functions may create an unbounded sequence when used
5332
+ // recursively, e.g. SET(?v1 := ?v + 1), so they remain part of the strict
5333
+ // recursive-new-terms check.
5334
+ if (!expr) return false;
5335
+ if (expr.type === 'var' || expr.type === 'term' || expr.type === 'literal') return false;
5336
+ return true;
5200
5337
  }
5201
5338
 
5202
5339
  function ruleHeadHasBlankNode(rule) {
@@ -7,6 +7,6 @@ DATA {
7
7
 
8
8
  RULE { ?x :grade ?grade } WHERE {
9
9
  ?x :score ?score .
10
- FILTER(?score >= 5) .
10
+ FILTER(?score >= 5)
11
11
  SET(?grade := concat("pass-", str(?score)))
12
12
  }
@@ -15,6 +15,6 @@ RULE {
15
15
  } WHERE {
16
16
  ?person :name ?name ;
17
17
  :age ?age .
18
- FILTER(datatype(?age) = xsd:integer && ?age >= 18 && lang(?name) = "en") .
18
+ FILTER(datatype(?age) = xsd:integer && ?age >= 18 && lang(?name) = "en")
19
19
  SET(?slug := REPLACE(LCASE(STR(?name)), " ", "-"))
20
20
  }
@@ -1,3 +1,3 @@
1
- _:bsh a :BritishShortHair .
2
- _:cat a :Cat .
1
+ _:sk_319b8a2f709c2e2189803dc77b3d3e85 a :BritishShortHair .
2
+ _:sk_bb32b39908778273d11a8e49e72b9547 a :Cat .
3
3
  :test :is true .
@@ -96485,7 +96485,7 @@ WHERE {
96485
96485
  ?query a :RouteQuery ;
96486
96486
  :source ?source .
96487
96487
  ?source nepo:hasOutboundRouteTo ?next .
96488
- FILTER(!sameTerm(?source, ?next)) .
96488
+ FILTER(!sameTerm(?source, ?next))
96489
96489
  ?source rdfs:label ?sourceName .
96490
96490
  ?next rdfs:label ?nextName .
96491
96491
  SET(?pathKey := CONCAT("|", STR(?source), "|", STR(?next), "|"))
@@ -96514,12 +96514,12 @@ WHERE {
96514
96514
  :routeLabel ?routeLabel .
96515
96515
  ?query :destination ?destination ;
96516
96516
  :maxStopOvers ?maxStopOvers .
96517
- FILTER(!sameTerm(?airport, ?destination)) .
96518
- FILTER(?stopovers < ?maxStopOvers) .
96517
+ FILTER(!sameTerm(?airport, ?destination))
96518
+ FILTER(?stopovers < ?maxStopOvers)
96519
96519
  ?airport nepo:hasOutboundRouteTo ?next .
96520
- FILTER(!sameTerm(?airport, ?next)) .
96520
+ FILTER(!sameTerm(?airport, ?next))
96521
96521
  SET(?nextNeedle := CONCAT("|", STR(?next), "|"))
96522
- FILTER(!CONTAINS(?pathKey, ?nextNeedle)) .
96522
+ FILTER(!CONTAINS(?pathKey, ?nextNeedle))
96523
96523
  ?next rdfs:label ?nextName .
96524
96524
  SET(?nextStopovers := ?stopovers + 1)
96525
96525
  SET(?nextPathKey := CONCAT(?pathKey, STR(?next), "|"))
package/eyeleng.js CHANGED
@@ -40,7 +40,7 @@
40
40
  const VERSION = readPackageVersion();
41
41
 
42
42
  function help() {
43
- return `eyeleng ${VERSION}\n\nA dependency-free JavaScript implementation experiment for the SHACL 1.2 Rules draft, including SRL and RDF Rules syntax front-ends.\n\nUsage:\n eyeleng [options] [file ...]\n\nOptions:\n --all Print the full closure, including input facts\n --json Print JSON instead of compact triples/bindings\n --trace Print derivation trace to stderr, or include it in JSON\n --stats Print iteration and triple counts to stderr\n --check Parse and analyze only; do not run rules\n --strict Treat static warnings as errors\n --deps Print rule dependency edges during --check\n --query TEXT Run a raw SRL body pattern over the closure\n --query-file FILE Read a raw SRL body pattern from a file\n --max-iterations N Stop after N fixpoint iterations within a recursive layer\n --no-imports Parse IMPORTS/owl:imports but do not load imported rule sets\n --rdf-messages Parse input as an RDF Message Log\n --stream-messages Replay RDF Message Log envelopes\n --include-message-facts Include payload facts while parsing RDF Message Logs\n --syntax MODE Use srl, rdf, or auto syntax detection (default auto)\n --ruleset TERM In RDF syntax, run only the selected srl:RuleSet\n --version Print version\n -h, --help Print this help\n\nWith no file arguments, eyeleng reads from stdin.\n`;
43
+ return `eyeleng ${VERSION}\n\nA dependency-free JavaScript implementation experiment for the SHACL 1.2 Rules draft, including SRL and RDF Rules syntax front-ends.\n\nUsage:\n eyeleng [options] [file ...]\n\nOptions:\n --all Print the full closure, including input facts\n --json Print JSON instead of compact triples/bindings\n --trace Print derivation trace to stderr, or include it in JSON\n --stats Print iteration and triple counts to stderr\n --check Parse and analyze only; do not run rules\n --strict Treat static warnings as errors, including recursive term generation\n --deps Print rule dependency edges during --check\n --query TEXT Run a raw SRL body pattern over the closure\n --query-file FILE Read a raw SRL body pattern from a file\n --max-iterations N Stop after N fixpoint iterations within a recursive layer\n --no-imports Parse IMPORTS/owl:imports but do not load imported rule sets\n --rdf-messages Parse input as an RDF Message Log\n --stream-messages Replay RDF Message Log envelopes\n --include-message-facts Include payload facts while parsing RDF Message Logs\n --syntax MODE Use srl, rdf, or auto syntax detection (default auto)\n --ruleset TERM In RDF syntax, run only the selected srl:RuleSet\n --version Print version\n -h, --help Print this help\n\nWith no file arguments, eyeleng reads from stdin.\n`;
44
44
  }
45
45
 
46
46
  function parseArgs(argv) {
@@ -148,7 +148,7 @@
148
148
  for (const edge of edges) {
149
149
  const from = formatRuleName(analysis.dependency.rules[edge.from].name, prefixes);
150
150
  const to = formatRuleName(analysis.dependency.rules[edge.to].name, prefixes);
151
- const kind = edge.negative ? 'NOT' : 'uses';
151
+ const kind = edge.negated ? 'NOT' : (edge.termGeneration ? 'generates' : 'uses');
152
152
  stderr.write(`eyeleng: deps: ${from} --${kind} ${edge.predicate ? compactIRI(edge.predicate, prefixes) : '*'}--> ${to}\n`);
153
153
  }
154
154
  if (analysis.dependency.layers && analysis.dependency.layers.length > 0) {
@@ -4278,8 +4278,8 @@
4278
4278
  "src/engine.js": function (require, module, exports) {
4279
4279
  'use strict';
4280
4280
 
4281
- const { TripleStore, bindingKey, instantiateTriple } = require('./store.js');
4282
- const { tripleKey, termEquals } = require('./term.js');
4281
+ const { TripleStore, bindingKey } = require('./store.js');
4282
+ const { tripleKey, termKey, termEquals, blankNode, tripleTerm } = require('./term.js');
4283
4283
  const { evalExpression, booleanValue, asTerm } = require('./builtins.js');
4284
4284
  const { analyze } = require('./analyze.js');
4285
4285
 
@@ -4299,7 +4299,7 @@
4299
4299
  runOnce: !!rule.runOnce,
4300
4300
  }));
4301
4301
 
4302
- const analysis = options.analysis || analyze(program);
4302
+ const analysis = options.analysis || analyze(program, options);
4303
4303
  if (analysis.errors && analysis.errors.length > 0 && !options.ignoreAnalysisErrors) {
4304
4304
  throw new Error(`Analysis failed: ${analysis.errors.map((error) => error.message).join('; ')}`);
4305
4305
  }
@@ -4310,6 +4310,9 @@
4310
4310
  layerIndexes,
4311
4311
  analysis.dependency ? analysis.dependency.edges : [],
4312
4312
  );
4313
+ const relaxedRecursiveRunOnce = options.relaxedRecursion === false
4314
+ ? new Set()
4315
+ : recursiveTermGenerationRuleIndexes(analysis);
4313
4316
  const baseContext = {
4314
4317
  ...evalOptions,
4315
4318
  maxIterations,
@@ -4325,8 +4328,8 @@
4325
4328
 
4326
4329
  for (let layerIndex = 0; layerIndex < layerIndexes.length; layerIndex += 1) {
4327
4330
  const layer = layerIndexes[layerIndex];
4328
- const ordinary = layer.filter((ruleIndex) => !program.rules[ruleIndex].runOnce);
4329
- const runOnce = layer.filter((ruleIndex) => program.rules[ruleIndex].runOnce);
4331
+ const ordinary = layer.filter((ruleIndex) => !program.rules[ruleIndex].runOnce || relaxedRecursiveRunOnce.has(ruleIndex));
4332
+ const runOnce = layer.filter((ruleIndex) => program.rules[ruleIndex].runOnce && !relaxedRecursiveRunOnce.has(ruleIndex));
4330
4333
 
4331
4334
  if (runOnce.length > 0) {
4332
4335
  iterations += 1;
@@ -4426,6 +4429,7 @@
4426
4429
  let added = 0;
4427
4430
  const dedupeBindings = rule.body.some((clause) => clause.type === 'path');
4428
4431
  const seenBindings = dedupeBindings ? new Set() : null;
4432
+ const headBlankLabels = collectHeadBlankLabels(rule.head);
4429
4433
 
4430
4434
  const bodyBindings = rule.body.length === 1 && rule.body[0].type === 'triple'
4431
4435
  ? store.match(rule.body[0].triple, {})
@@ -4440,8 +4444,10 @@
4440
4444
  applications += 1;
4441
4445
  context.perRule[ruleIndex].applications += 1;
4442
4446
 
4447
+ const headBlankMap = headBlankLabels.size > 0 ? new Map() : null;
4448
+ const skolemKey = headBlankMap ? skolemizationKey(ruleIndex, binding) : null;
4443
4449
  for (const head of rule.head) {
4444
- const triple = instantiateTriple(head, binding);
4450
+ const triple = instantiateHeadTriple(head, binding, headBlankLabels, headBlankMap, skolemKey);
4445
4451
  if (!triple) continue;
4446
4452
  if (store.add(triple)) {
4447
4453
  added += 1;
@@ -4463,6 +4469,94 @@
4463
4469
  return { applications, added };
4464
4470
  }
4465
4471
 
4472
+ function recursiveTermGenerationRuleIndexes(analysis) {
4473
+ const out = new Set();
4474
+ if (!analysis || !analysis.dependency || !analysis.diagnostics) return out;
4475
+ const byName = new Map((analysis.dependency.rules || []).map((rule) => [rule.name, rule.index]));
4476
+ for (const diagnostic of analysis.diagnostics) {
4477
+ if (diagnostic.code !== 'recursive-assignment-rule') continue;
4478
+ if (byName.has(diagnostic.rule)) out.add(byName.get(diagnostic.rule));
4479
+ }
4480
+ return out;
4481
+ }
4482
+
4483
+ function instantiateHeadTriple(pattern, binding, headBlankLabels, headBlankMap, skolemKey) {
4484
+ const s = instantiateHeadTerm(pattern.s, binding, headBlankLabels, headBlankMap, skolemKey);
4485
+ const p = instantiateHeadTerm(pattern.p, binding, headBlankLabels, headBlankMap, skolemKey);
4486
+ const o = instantiateHeadTerm(pattern.o, binding, headBlankLabels, headBlankMap, skolemKey);
4487
+ if (!s || !p || !o) return null;
4488
+ if (p.type !== 'iri') return null;
4489
+ return { s, p, o };
4490
+ }
4491
+
4492
+ function instantiateHeadTerm(term, binding, headBlankLabels, headBlankMap, skolemKey) {
4493
+ if (term.type === 'var') return binding[term.value] || null;
4494
+ if (term.type === 'blank' && headBlankLabels.has(term.value)) {
4495
+ let label = headBlankMap.get(term.value);
4496
+ if (!label) {
4497
+ label = `sk_${deterministicSkolemIdFromKey(`${skolemKey}|${term.value}`).replace(/-/g, '_')}`;
4498
+ headBlankMap.set(term.value, label);
4499
+ }
4500
+ return blankNode(label);
4501
+ }
4502
+ if (term.type === 'triple') {
4503
+ const s = instantiateHeadTerm(term.s, binding, headBlankLabels, headBlankMap, skolemKey);
4504
+ const p = instantiateHeadTerm(term.p, binding, headBlankLabels, headBlankMap, skolemKey);
4505
+ const o = instantiateHeadTerm(term.o, binding, headBlankLabels, headBlankMap, skolemKey);
4506
+ if (!s || !p || !o) return null;
4507
+ return tripleTerm(s, p, o);
4508
+ }
4509
+ return term;
4510
+ }
4511
+
4512
+ function collectHeadBlankLabels(head) {
4513
+ const labels = new Set();
4514
+ for (const triple of head || []) {
4515
+ collectBlankLabelsFromTerm(triple.s, labels);
4516
+ collectBlankLabelsFromTerm(triple.p, labels);
4517
+ collectBlankLabelsFromTerm(triple.o, labels);
4518
+ }
4519
+ return labels;
4520
+ }
4521
+
4522
+ function collectBlankLabelsFromTerm(term, labels) {
4523
+ if (!term) return;
4524
+ if (term.type === 'blank') {
4525
+ labels.add(term.value);
4526
+ return;
4527
+ }
4528
+ if (term.type === 'triple') {
4529
+ collectBlankLabelsFromTerm(term.s, labels);
4530
+ collectBlankLabelsFromTerm(term.p, labels);
4531
+ collectBlankLabelsFromTerm(term.o, labels);
4532
+ }
4533
+ }
4534
+
4535
+ function skolemizationKey(ruleIndex, binding) {
4536
+ let out = `rule:${ruleIndex}`;
4537
+ for (const name of Object.keys(binding).sort()) {
4538
+ const value = binding[name];
4539
+ out += `|${name}=${value ? termKey(value) : 'unbound'}`;
4540
+ }
4541
+ return out;
4542
+ }
4543
+
4544
+ function deterministicSkolemIdFromKey(key) {
4545
+ let h1 = 0x811c9dc5;
4546
+ let h2 = 0x811c9dc5;
4547
+ let h3 = 0x811c9dc5;
4548
+ let h4 = 0x811c9dc5;
4549
+ for (let i = 0; i < key.length; i += 1) {
4550
+ const c = key.charCodeAt(i);
4551
+ h1 ^= c; h1 = Math.imul(h1, 0x01000193) >>> 0;
4552
+ h2 ^= c + 1; h2 = Math.imul(h2, 0x01000193) >>> 0;
4553
+ h3 ^= c + 2; h3 = Math.imul(h3, 0x01000193) >>> 0;
4554
+ h4 ^= c + 3; h4 = Math.imul(h4, 0x01000193) >>> 0;
4555
+ }
4556
+ return [h1, h2, h3, h4].map((h) => h.toString(16).padStart(8, '0')).join('');
4557
+ }
4558
+
4559
+
4466
4560
  function evaluateBody(clauses, store, initialBinding = {}, options = {}) {
4467
4561
  const bindings = [];
4468
4562
  const seen = new Set();
@@ -4819,8 +4913,8 @@
4819
4913
  function analyze(program, options = {}) {
4820
4914
  const diagnostics = [];
4821
4915
  const dependency = dependencyGraph(program, options);
4822
- const hasRunOnceRules = program.rules.some((rule) => rule.runOnce);
4823
- const recursiveIndexes = hasRunOnceRules ? recursiveRuleIndexes(dependency) : new Set();
4916
+ const hasTermGeneratingRules = dependency.rules.some((rule) => rule.createsTerms);
4917
+ const recursiveIndexes = hasTermGeneratingRules ? recursiveRuleIndexes(dependency) : new Set();
4824
4918
 
4825
4919
  program.rules.forEach((rule, index) => {
4826
4920
  const name = ruleName(rule, index);
@@ -4852,12 +4946,13 @@
4852
4946
 
4853
4947
  diagnostics.push(...sequentialWellFormednessDiagnostics(rule.body, name, program.prefixes || {}));
4854
4948
 
4855
- if (rule.runOnce && recursiveIndexes.has(index)) {
4949
+ const depRule = dependency.rules[index] || {};
4950
+ if (depRule.createsTerms && recursiveIndexes.has(index)) {
4856
4951
  diagnostics.push({
4857
4952
  code: 'recursive-assignment-rule',
4858
4953
  severity: 'warning',
4859
4954
  rule: name,
4860
- message: `${displayRuleName(name, program.prefixes || {})} is a run-once rule in a recursive dependency cycle`,
4955
+ message: `${displayRuleName(name, program.prefixes || {})} creates terms in a recursive dependency cycle; relaxed mode allows this but termination is not guaranteed (use --strict to reject it)`,
4861
4956
  });
4862
4957
  }
4863
4958
 
@@ -4904,34 +4999,47 @@
4904
4999
  negativePredicates: new Set(negativePatterns.flatMap((triple) => predicateIRIs(triple))),
4905
5000
  runOnce: !!rule.runOnce,
4906
5001
  hasAssignment: ruleHasAssignment(rule, options),
5002
+ hasTermGeneratingAssignment: ruleHasTermGeneratingAssignment(rule, options),
4907
5003
  headHasBlankNode: ruleHeadHasBlankNode(rule),
5004
+ createsTerms: ruleCreatesTerms(rule, options),
4908
5005
  };
4909
5006
  });
4910
5007
 
4911
5008
  const edgeMap = new Map();
4912
- function addEdge(from, to, negative, predicate) {
5009
+ function addEdge(from, to, kind, predicate) {
4913
5010
  const label = predicate || '*';
4914
5011
  const key = `${from.index}->${to.index}:${label}`;
5012
+ const negated = kind === 'negated';
5013
+ const termGeneration = kind === 'term-generation';
4915
5014
  const existing = edgeMap.get(key);
4916
5015
  if (existing) {
4917
- existing.negative = existing.negative || negative;
5016
+ existing.negated = existing.negated || negated;
5017
+ existing.termGeneration = existing.termGeneration || termGeneration;
5018
+ existing.negative = existing.negated || existing.termGeneration;
4918
5019
  return;
4919
5020
  }
4920
- edgeMap.set(key, { from: from.index, to: to.index, negative, predicate });
5021
+ edgeMap.set(key, {
5022
+ from: from.index,
5023
+ to: to.index,
5024
+ negative: negated || termGeneration,
5025
+ negated,
5026
+ termGeneration,
5027
+ predicate,
5028
+ });
4921
5029
  }
4922
5030
 
4923
5031
  const headIndex = buildHeadTemplateIndex(rules);
4924
5032
 
4925
5033
  for (const from of rules) {
4926
- const forceClosed = from.hasAssignment || from.headHasBlankNode;
5034
+ const forceClosed = from.createsTerms;
4927
5035
  for (const pattern of from.positivePatterns) {
4928
5036
  for (const candidate of candidateHeadTemplates(headIndex, pattern)) {
4929
- if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], forceClosed, dependencyPredicateLabel(pattern));
5037
+ if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], forceClosed ? 'term-generation' : 'positive', dependencyPredicateLabel(pattern));
4930
5038
  }
4931
5039
  }
4932
5040
  for (const pattern of from.negativePatterns) {
4933
5041
  for (const candidate of candidateHeadTemplates(headIndex, pattern)) {
4934
- if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], true, dependencyPredicateLabel(pattern));
5042
+ if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], 'negated', dependencyPredicateLabel(pattern));
4935
5043
  }
4936
5044
  }
4937
5045
  }
@@ -4947,7 +5055,7 @@
4947
5055
  const unstratifiedCycles = [];
4948
5056
  const seen = new Set();
4949
5057
  for (const edge of edges) {
4950
- if (!edge.negative) continue;
5058
+ if (!edge.negated) continue;
4951
5059
  if (edge.from === edge.to && rules[edge.from].runOnce && !rules[edge.from].headHasBlankNode) continue;
4952
5060
  if (componentOf.get(edge.from) !== componentOf.get(edge.to)) continue;
4953
5061
  const component = components[componentOf.get(edge.from)];
@@ -4970,7 +5078,10 @@
4970
5078
  positivePredicates: Array.from(rule.positivePredicates),
4971
5079
  negativePredicates: Array.from(rule.negativePredicates),
4972
5080
  runOnce: rule.runOnce,
5081
+ hasAssignment: rule.hasAssignment,
5082
+ hasTermGeneratingAssignment: rule.hasTermGeneratingAssignment,
4973
5083
  headHasBlankNode: rule.headHasBlankNode,
5084
+ createsTerms: rule.createsTerms,
4974
5085
  })),
4975
5086
  edges,
4976
5087
  components: components.map((component) => component.map((ruleIndex) => rules[ruleIndex].name)),
@@ -5140,7 +5251,14 @@
5140
5251
  function recursiveRuleIndexes(dependency) {
5141
5252
  const out = new Set();
5142
5253
  const ruleByName = new Map(dependency.rules.map((rule) => [rule.name, rule]));
5143
- const ruleByIndex = new Map(dependency.rules.map((rule) => [rule.index, rule]));
5254
+ const componentOf = new Map();
5255
+
5256
+ dependency.components.forEach((component, componentIndex) => {
5257
+ for (const name of component) {
5258
+ const rule = ruleByName.get(name);
5259
+ if (rule) componentOf.set(rule.index, componentIndex);
5260
+ }
5261
+ });
5144
5262
 
5145
5263
  for (const component of dependency.components) {
5146
5264
  if (component.length <= 1) continue;
@@ -5151,9 +5269,9 @@
5151
5269
  }
5152
5270
 
5153
5271
  for (const edge of dependency.edges) {
5154
- const rule = ruleByIndex.get(edge.from);
5155
- if (edge.from === edge.to && edge.negative && rule && rule.runOnce && !rule.headHasBlankNode) continue;
5156
- out.add(edge.from);
5272
+ const rule = (dependency.rules || []).find((candidate) => candidate.index === edge.from);
5273
+ if (edge.from === edge.to && edge.negated && rule && rule.runOnce && !rule.headHasBlankNode) continue;
5274
+ if (edge.from === edge.to || componentOf.get(edge.from) === componentOf.get(edge.to)) out.add(edge.from);
5157
5275
  }
5158
5276
  return out;
5159
5277
  }
@@ -5426,7 +5544,26 @@
5426
5544
  }
5427
5545
 
5428
5546
  function ruleHasAssignment(rule, options = {}) {
5429
- return !!options.shacl12Conformance && (rule.body || []).some((clause) => clause.type === 'set' || clause.type === 'bind');
5547
+ return (rule.body || []).some((clause) => clause.type === 'set' || clause.type === 'bind');
5548
+ }
5549
+
5550
+ function ruleHasTermGeneratingAssignment(rule, options = {}) {
5551
+ return (rule.body || []).some((clause) => (clause.type === 'set' || clause.type === 'bind') && assignmentMayCreateNewTerm(clause.expr));
5552
+ }
5553
+
5554
+ function ruleCreatesTerms(rule, options = {}) {
5555
+ return ruleHeadHasBlankNode(rule) || ruleHasTermGeneratingAssignment(rule, options);
5556
+ }
5557
+
5558
+ function assignmentMayCreateNewTerm(expr) {
5559
+ // Simple aliases and constants are not term generators: they select from a
5560
+ // fixed term or from already-bound terms. Expressions that compute values
5561
+ // through operators or functions may create an unbounded sequence when used
5562
+ // recursively, e.g. SET(?v1 := ?v + 1), so they remain part of the strict
5563
+ // recursive-new-terms check.
5564
+ if (!expr) return false;
5565
+ if (expr.type === 'var' || expr.type === 'term' || expr.type === 'literal') return false;
5566
+ return true;
5430
5567
  }
5431
5568
 
5432
5569
  function ruleHeadHasBlankNode(rule) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyeleng",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "The EYE Logic Engine: a JavaScript implementation of SHACL Rules, including SRL and RDF Rules syntax front-ends.",
5
5
  "keywords": [
6
6
  "SRL",
package/src/analyze.js CHANGED
@@ -6,8 +6,8 @@ const { tripleHasBlankNode } = require('./assignments.js');
6
6
  function analyze(program, options = {}) {
7
7
  const diagnostics = [];
8
8
  const dependency = dependencyGraph(program, options);
9
- const hasRunOnceRules = program.rules.some((rule) => rule.runOnce);
10
- const recursiveIndexes = hasRunOnceRules ? recursiveRuleIndexes(dependency) : new Set();
9
+ const hasTermGeneratingRules = dependency.rules.some((rule) => rule.createsTerms);
10
+ const recursiveIndexes = hasTermGeneratingRules ? recursiveRuleIndexes(dependency) : new Set();
11
11
 
12
12
  program.rules.forEach((rule, index) => {
13
13
  const name = ruleName(rule, index);
@@ -39,12 +39,13 @@ function analyze(program, options = {}) {
39
39
 
40
40
  diagnostics.push(...sequentialWellFormednessDiagnostics(rule.body, name, program.prefixes || {}));
41
41
 
42
- if (rule.runOnce && recursiveIndexes.has(index)) {
42
+ const depRule = dependency.rules[index] || {};
43
+ if (depRule.createsTerms && recursiveIndexes.has(index)) {
43
44
  diagnostics.push({
44
45
  code: 'recursive-assignment-rule',
45
46
  severity: 'warning',
46
47
  rule: name,
47
- message: `${displayRuleName(name, program.prefixes || {})} is a run-once rule in a recursive dependency cycle`,
48
+ message: `${displayRuleName(name, program.prefixes || {})} creates terms in a recursive dependency cycle; relaxed mode allows this but termination is not guaranteed (use --strict to reject it)`,
48
49
  });
49
50
  }
50
51
 
@@ -91,34 +92,47 @@ function dependencyGraph(program, options = {}) {
91
92
  negativePredicates: new Set(negativePatterns.flatMap((triple) => predicateIRIs(triple))),
92
93
  runOnce: !!rule.runOnce,
93
94
  hasAssignment: ruleHasAssignment(rule, options),
95
+ hasTermGeneratingAssignment: ruleHasTermGeneratingAssignment(rule, options),
94
96
  headHasBlankNode: ruleHeadHasBlankNode(rule),
97
+ createsTerms: ruleCreatesTerms(rule, options),
95
98
  };
96
99
  });
97
100
 
98
101
  const edgeMap = new Map();
99
- function addEdge(from, to, negative, predicate) {
102
+ function addEdge(from, to, kind, predicate) {
100
103
  const label = predicate || '*';
101
104
  const key = `${from.index}->${to.index}:${label}`;
105
+ const negated = kind === 'negated';
106
+ const termGeneration = kind === 'term-generation';
102
107
  const existing = edgeMap.get(key);
103
108
  if (existing) {
104
- existing.negative = existing.negative || negative;
109
+ existing.negated = existing.negated || negated;
110
+ existing.termGeneration = existing.termGeneration || termGeneration;
111
+ existing.negative = existing.negated || existing.termGeneration;
105
112
  return;
106
113
  }
107
- edgeMap.set(key, { from: from.index, to: to.index, negative, predicate });
114
+ edgeMap.set(key, {
115
+ from: from.index,
116
+ to: to.index,
117
+ negative: negated || termGeneration,
118
+ negated,
119
+ termGeneration,
120
+ predicate,
121
+ });
108
122
  }
109
123
 
110
124
  const headIndex = buildHeadTemplateIndex(rules);
111
125
 
112
126
  for (const from of rules) {
113
- const forceClosed = from.hasAssignment || from.headHasBlankNode;
127
+ const forceClosed = from.createsTerms;
114
128
  for (const pattern of from.positivePatterns) {
115
129
  for (const candidate of candidateHeadTemplates(headIndex, pattern)) {
116
- if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], forceClosed, dependencyPredicateLabel(pattern));
130
+ if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], forceClosed ? 'term-generation' : 'positive', dependencyPredicateLabel(pattern));
117
131
  }
118
132
  }
119
133
  for (const pattern of from.negativePatterns) {
120
134
  for (const candidate of candidateHeadTemplates(headIndex, pattern)) {
121
- if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], true, dependencyPredicateLabel(pattern));
135
+ if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], 'negated', dependencyPredicateLabel(pattern));
122
136
  }
123
137
  }
124
138
  }
@@ -134,7 +148,7 @@ function dependencyGraph(program, options = {}) {
134
148
  const unstratifiedCycles = [];
135
149
  const seen = new Set();
136
150
  for (const edge of edges) {
137
- if (!edge.negative) continue;
151
+ if (!edge.negated) continue;
138
152
  if (edge.from === edge.to && rules[edge.from].runOnce && !rules[edge.from].headHasBlankNode) continue;
139
153
  if (componentOf.get(edge.from) !== componentOf.get(edge.to)) continue;
140
154
  const component = components[componentOf.get(edge.from)];
@@ -157,7 +171,10 @@ function dependencyGraph(program, options = {}) {
157
171
  positivePredicates: Array.from(rule.positivePredicates),
158
172
  negativePredicates: Array.from(rule.negativePredicates),
159
173
  runOnce: rule.runOnce,
174
+ hasAssignment: rule.hasAssignment,
175
+ hasTermGeneratingAssignment: rule.hasTermGeneratingAssignment,
160
176
  headHasBlankNode: rule.headHasBlankNode,
177
+ createsTerms: rule.createsTerms,
161
178
  })),
162
179
  edges,
163
180
  components: components.map((component) => component.map((ruleIndex) => rules[ruleIndex].name)),
@@ -327,7 +344,14 @@ function componentMin(component) {
327
344
  function recursiveRuleIndexes(dependency) {
328
345
  const out = new Set();
329
346
  const ruleByName = new Map(dependency.rules.map((rule) => [rule.name, rule]));
330
- const ruleByIndex = new Map(dependency.rules.map((rule) => [rule.index, rule]));
347
+ const componentOf = new Map();
348
+
349
+ dependency.components.forEach((component, componentIndex) => {
350
+ for (const name of component) {
351
+ const rule = ruleByName.get(name);
352
+ if (rule) componentOf.set(rule.index, componentIndex);
353
+ }
354
+ });
331
355
 
332
356
  for (const component of dependency.components) {
333
357
  if (component.length <= 1) continue;
@@ -338,9 +362,9 @@ function recursiveRuleIndexes(dependency) {
338
362
  }
339
363
 
340
364
  for (const edge of dependency.edges) {
341
- const rule = ruleByIndex.get(edge.from);
342
- if (edge.from === edge.to && edge.negative && rule && rule.runOnce && !rule.headHasBlankNode) continue;
343
- out.add(edge.from);
365
+ const rule = (dependency.rules || []).find((candidate) => candidate.index === edge.from);
366
+ if (edge.from === edge.to && edge.negated && rule && rule.runOnce && !rule.headHasBlankNode) continue;
367
+ if (edge.from === edge.to || componentOf.get(edge.from) === componentOf.get(edge.to)) out.add(edge.from);
344
368
  }
345
369
  return out;
346
370
  }
@@ -613,7 +637,26 @@ function substituteAssignedConstant(term, constants) {
613
637
  }
614
638
 
615
639
  function ruleHasAssignment(rule, options = {}) {
616
- return !!options.shacl12Conformance && (rule.body || []).some((clause) => clause.type === 'set' || clause.type === 'bind');
640
+ return (rule.body || []).some((clause) => clause.type === 'set' || clause.type === 'bind');
641
+ }
642
+
643
+ function ruleHasTermGeneratingAssignment(rule, options = {}) {
644
+ return (rule.body || []).some((clause) => (clause.type === 'set' || clause.type === 'bind') && assignmentMayCreateNewTerm(clause.expr));
645
+ }
646
+
647
+ function ruleCreatesTerms(rule, options = {}) {
648
+ return ruleHeadHasBlankNode(rule) || ruleHasTermGeneratingAssignment(rule, options);
649
+ }
650
+
651
+ function assignmentMayCreateNewTerm(expr) {
652
+ // Simple aliases and constants are not term generators: they select from a
653
+ // fixed term or from already-bound terms. Expressions that compute values
654
+ // through operators or functions may create an unbounded sequence when used
655
+ // recursively, e.g. SET(?v1 := ?v + 1), so they remain part of the strict
656
+ // recursive-new-terms check.
657
+ if (!expr) return false;
658
+ if (expr.type === 'var' || expr.type === 'term' || expr.type === 'literal') return false;
659
+ return true;
617
660
  }
618
661
 
619
662
  function ruleHeadHasBlankNode(rule) {
package/src/cli.js CHANGED
@@ -34,7 +34,7 @@ function readPackageVersion() {
34
34
  const VERSION = readPackageVersion();
35
35
 
36
36
  function help() {
37
- return `eyeleng ${VERSION}\n\nA dependency-free JavaScript implementation experiment for the SHACL 1.2 Rules draft, including SRL and RDF Rules syntax front-ends.\n\nUsage:\n eyeleng [options] [file ...]\n\nOptions:\n --all Print the full closure, including input facts\n --json Print JSON instead of compact triples/bindings\n --trace Print derivation trace to stderr, or include it in JSON\n --stats Print iteration and triple counts to stderr\n --check Parse and analyze only; do not run rules\n --strict Treat static warnings as errors\n --deps Print rule dependency edges during --check\n --query TEXT Run a raw SRL body pattern over the closure\n --query-file FILE Read a raw SRL body pattern from a file\n --max-iterations N Stop after N fixpoint iterations within a recursive layer\n --no-imports Parse IMPORTS/owl:imports but do not load imported rule sets\n --rdf-messages Parse input as an RDF Message Log\n --stream-messages Replay RDF Message Log envelopes\n --include-message-facts Include payload facts while parsing RDF Message Logs\n --syntax MODE Use srl, rdf, or auto syntax detection (default auto)\n --ruleset TERM In RDF syntax, run only the selected srl:RuleSet\n --version Print version\n -h, --help Print this help\n\nWith no file arguments, eyeleng reads from stdin.\n`;
37
+ return `eyeleng ${VERSION}\n\nA dependency-free JavaScript implementation experiment for the SHACL 1.2 Rules draft, including SRL and RDF Rules syntax front-ends.\n\nUsage:\n eyeleng [options] [file ...]\n\nOptions:\n --all Print the full closure, including input facts\n --json Print JSON instead of compact triples/bindings\n --trace Print derivation trace to stderr, or include it in JSON\n --stats Print iteration and triple counts to stderr\n --check Parse and analyze only; do not run rules\n --strict Treat static warnings as errors, including recursive term generation\n --deps Print rule dependency edges during --check\n --query TEXT Run a raw SRL body pattern over the closure\n --query-file FILE Read a raw SRL body pattern from a file\n --max-iterations N Stop after N fixpoint iterations within a recursive layer\n --no-imports Parse IMPORTS/owl:imports but do not load imported rule sets\n --rdf-messages Parse input as an RDF Message Log\n --stream-messages Replay RDF Message Log envelopes\n --include-message-facts Include payload facts while parsing RDF Message Logs\n --syntax MODE Use srl, rdf, or auto syntax detection (default auto)\n --ruleset TERM In RDF syntax, run only the selected srl:RuleSet\n --version Print version\n -h, --help Print this help\n\nWith no file arguments, eyeleng reads from stdin.\n`;
38
38
  }
39
39
 
40
40
  function parseArgs(argv) {
@@ -142,7 +142,7 @@ function printDependencies(analysis, prefixes, stderr) {
142
142
  for (const edge of edges) {
143
143
  const from = formatRuleName(analysis.dependency.rules[edge.from].name, prefixes);
144
144
  const to = formatRuleName(analysis.dependency.rules[edge.to].name, prefixes);
145
- const kind = edge.negative ? 'NOT' : 'uses';
145
+ const kind = edge.negated ? 'NOT' : (edge.termGeneration ? 'generates' : 'uses');
146
146
  stderr.write(`eyeleng: deps: ${from} --${kind} ${edge.predicate ? compactIRI(edge.predicate, prefixes) : '*'}--> ${to}\n`);
147
147
  }
148
148
  if (analysis.dependency.layers && analysis.dependency.layers.length > 0) {
package/src/engine.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
- const { TripleStore, bindingKey, instantiateTriple } = require('./store.js');
4
- const { tripleKey, termEquals } = require('./term.js');
3
+ const { TripleStore, bindingKey } = require('./store.js');
4
+ const { tripleKey, termKey, termEquals, blankNode, tripleTerm } = require('./term.js');
5
5
  const { evalExpression, booleanValue, asTerm } = require('./builtins.js');
6
6
  const { analyze } = require('./analyze.js');
7
7
 
@@ -21,7 +21,7 @@ function evaluate(program, options = {}) {
21
21
  runOnce: !!rule.runOnce,
22
22
  }));
23
23
 
24
- const analysis = options.analysis || analyze(program);
24
+ const analysis = options.analysis || analyze(program, options);
25
25
  if (analysis.errors && analysis.errors.length > 0 && !options.ignoreAnalysisErrors) {
26
26
  throw new Error(`Analysis failed: ${analysis.errors.map((error) => error.message).join('; ')}`);
27
27
  }
@@ -32,6 +32,9 @@ function evaluate(program, options = {}) {
32
32
  layerIndexes,
33
33
  analysis.dependency ? analysis.dependency.edges : [],
34
34
  );
35
+ const relaxedRecursiveRunOnce = options.relaxedRecursion === false
36
+ ? new Set()
37
+ : recursiveTermGenerationRuleIndexes(analysis);
35
38
  const baseContext = {
36
39
  ...evalOptions,
37
40
  maxIterations,
@@ -47,8 +50,8 @@ function evaluate(program, options = {}) {
47
50
 
48
51
  for (let layerIndex = 0; layerIndex < layerIndexes.length; layerIndex += 1) {
49
52
  const layer = layerIndexes[layerIndex];
50
- const ordinary = layer.filter((ruleIndex) => !program.rules[ruleIndex].runOnce);
51
- const runOnce = layer.filter((ruleIndex) => program.rules[ruleIndex].runOnce);
53
+ const ordinary = layer.filter((ruleIndex) => !program.rules[ruleIndex].runOnce || relaxedRecursiveRunOnce.has(ruleIndex));
54
+ const runOnce = layer.filter((ruleIndex) => program.rules[ruleIndex].runOnce && !relaxedRecursiveRunOnce.has(ruleIndex));
52
55
 
53
56
  if (runOnce.length > 0) {
54
57
  iterations += 1;
@@ -148,6 +151,7 @@ function applyRuleOnce(program, store, ruleIndex, context) {
148
151
  let added = 0;
149
152
  const dedupeBindings = rule.body.some((clause) => clause.type === 'path');
150
153
  const seenBindings = dedupeBindings ? new Set() : null;
154
+ const headBlankLabels = collectHeadBlankLabels(rule.head);
151
155
 
152
156
  const bodyBindings = rule.body.length === 1 && rule.body[0].type === 'triple'
153
157
  ? store.match(rule.body[0].triple, {})
@@ -162,8 +166,10 @@ function applyRuleOnce(program, store, ruleIndex, context) {
162
166
  applications += 1;
163
167
  context.perRule[ruleIndex].applications += 1;
164
168
 
169
+ const headBlankMap = headBlankLabels.size > 0 ? new Map() : null;
170
+ const skolemKey = headBlankMap ? skolemizationKey(ruleIndex, binding) : null;
165
171
  for (const head of rule.head) {
166
- const triple = instantiateTriple(head, binding);
172
+ const triple = instantiateHeadTriple(head, binding, headBlankLabels, headBlankMap, skolemKey);
167
173
  if (!triple) continue;
168
174
  if (store.add(triple)) {
169
175
  added += 1;
@@ -185,6 +191,94 @@ function applyRuleOnce(program, store, ruleIndex, context) {
185
191
  return { applications, added };
186
192
  }
187
193
 
194
+ function recursiveTermGenerationRuleIndexes(analysis) {
195
+ const out = new Set();
196
+ if (!analysis || !analysis.dependency || !analysis.diagnostics) return out;
197
+ const byName = new Map((analysis.dependency.rules || []).map((rule) => [rule.name, rule.index]));
198
+ for (const diagnostic of analysis.diagnostics) {
199
+ if (diagnostic.code !== 'recursive-assignment-rule') continue;
200
+ if (byName.has(diagnostic.rule)) out.add(byName.get(diagnostic.rule));
201
+ }
202
+ return out;
203
+ }
204
+
205
+ function instantiateHeadTriple(pattern, binding, headBlankLabels, headBlankMap, skolemKey) {
206
+ const s = instantiateHeadTerm(pattern.s, binding, headBlankLabels, headBlankMap, skolemKey);
207
+ const p = instantiateHeadTerm(pattern.p, binding, headBlankLabels, headBlankMap, skolemKey);
208
+ const o = instantiateHeadTerm(pattern.o, binding, headBlankLabels, headBlankMap, skolemKey);
209
+ if (!s || !p || !o) return null;
210
+ if (p.type !== 'iri') return null;
211
+ return { s, p, o };
212
+ }
213
+
214
+ function instantiateHeadTerm(term, binding, headBlankLabels, headBlankMap, skolemKey) {
215
+ if (term.type === 'var') return binding[term.value] || null;
216
+ if (term.type === 'blank' && headBlankLabels.has(term.value)) {
217
+ let label = headBlankMap.get(term.value);
218
+ if (!label) {
219
+ label = `sk_${deterministicSkolemIdFromKey(`${skolemKey}|${term.value}`).replace(/-/g, '_')}`;
220
+ headBlankMap.set(term.value, label);
221
+ }
222
+ return blankNode(label);
223
+ }
224
+ if (term.type === 'triple') {
225
+ const s = instantiateHeadTerm(term.s, binding, headBlankLabels, headBlankMap, skolemKey);
226
+ const p = instantiateHeadTerm(term.p, binding, headBlankLabels, headBlankMap, skolemKey);
227
+ const o = instantiateHeadTerm(term.o, binding, headBlankLabels, headBlankMap, skolemKey);
228
+ if (!s || !p || !o) return null;
229
+ return tripleTerm(s, p, o);
230
+ }
231
+ return term;
232
+ }
233
+
234
+ function collectHeadBlankLabels(head) {
235
+ const labels = new Set();
236
+ for (const triple of head || []) {
237
+ collectBlankLabelsFromTerm(triple.s, labels);
238
+ collectBlankLabelsFromTerm(triple.p, labels);
239
+ collectBlankLabelsFromTerm(triple.o, labels);
240
+ }
241
+ return labels;
242
+ }
243
+
244
+ function collectBlankLabelsFromTerm(term, labels) {
245
+ if (!term) return;
246
+ if (term.type === 'blank') {
247
+ labels.add(term.value);
248
+ return;
249
+ }
250
+ if (term.type === 'triple') {
251
+ collectBlankLabelsFromTerm(term.s, labels);
252
+ collectBlankLabelsFromTerm(term.p, labels);
253
+ collectBlankLabelsFromTerm(term.o, labels);
254
+ }
255
+ }
256
+
257
+ function skolemizationKey(ruleIndex, binding) {
258
+ let out = `rule:${ruleIndex}`;
259
+ for (const name of Object.keys(binding).sort()) {
260
+ const value = binding[name];
261
+ out += `|${name}=${value ? termKey(value) : 'unbound'}`;
262
+ }
263
+ return out;
264
+ }
265
+
266
+ function deterministicSkolemIdFromKey(key) {
267
+ let h1 = 0x811c9dc5;
268
+ let h2 = 0x811c9dc5;
269
+ let h3 = 0x811c9dc5;
270
+ let h4 = 0x811c9dc5;
271
+ for (let i = 0; i < key.length; i += 1) {
272
+ const c = key.charCodeAt(i);
273
+ h1 ^= c; h1 = Math.imul(h1, 0x01000193) >>> 0;
274
+ h2 ^= c + 1; h2 = Math.imul(h2, 0x01000193) >>> 0;
275
+ h3 ^= c + 2; h3 = Math.imul(h3, 0x01000193) >>> 0;
276
+ h4 ^= c + 3; h4 = Math.imul(h4, 0x01000193) >>> 0;
277
+ }
278
+ return [h1, h2, h3, h4].map((h) => h.toString(16).padStart(8, '0')).join('');
279
+ }
280
+
281
+
188
282
  function evaluateBody(clauses, store, initialBinding = {}, options = {}) {
189
283
  const bindings = [];
190
284
  const seen = new Set();
@@ -139,6 +139,10 @@ async function runSyntaxOrWellformedTest(test, options = {}) {
139
139
  filename: test.actionUrl,
140
140
  baseIRI: test.actionUrl,
141
141
  shacl12Conformance: true,
142
+ // The W3C manifest defines the strict, guaranteed-terminating conformance profile.
143
+ // Eyeleng's relaxed mode remains available through the normal API/CLI, but
144
+ // negative well-formedness tests for recursive term generation must fail here.
145
+ strict: true,
142
146
  };
143
147
 
144
148
  if (test.type.includes('Syntax')) {
@@ -176,6 +180,7 @@ async function runEvalTest(test, options = {}) {
176
180
  filename: test.rulesetUrl,
177
181
  baseIRI: test.rulesetUrl,
178
182
  shacl12Conformance: true,
183
+ strict: true,
179
184
  };
180
185
  const compiled = eyeleng.compile(rulesSource, compileOptions);
181
186
  const program = { ...compiled.program, data: [...compiled.program.data, ...dataTriples] };
package/test/api.test.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const { test, main } = require('./harness.js').createHarness('API');
4
4
  const assert = require('node:assert/strict');
5
5
  const { parse, compile, run, runToString } = require('../src/index.js');
6
+ const { tripleKey } = require('../src/term.js');
6
7
 
7
8
  test('parse reads prefixes, data, and rules', () => {
8
9
  const program = parse(`
@@ -455,7 +456,70 @@ RULE { :s :p ?x } WHERE { :s :q ?x SET(?z := STR(?x)) }
455
456
  `, options));
456
457
  });
457
458
 
458
- main();
459
+
460
+ test('relaxed mode permits recursive deterministic assignments with max-iteration safety', () => {
461
+ const source = `
462
+ PREFIX : <http://example/>
463
+ DATA { :counter :value 0 . :limit :max 3 . }
464
+ RULE { :counter :value ?next } WHERE {
465
+ :counter :value ?value .
466
+ :limit :max ?max .
467
+ FILTER(?value < ?max)
468
+ SET(?next := ?value + 1)
469
+ }
470
+ `;
471
+ const compiled = compile(source, { shacl12Conformance: true, throwOnDiagnostics: false });
472
+ assert.equal(compiled.analysis.warnings[0].code, 'recursive-assignment-rule');
473
+ assert.throws(() => compile(source, { shacl12Conformance: true, strict: true }), /termination is not guaranteed/);
474
+ const output = runToString(source, { shacl12Conformance: true, maxIterations: 20 });
475
+ assert.match(output, /:counter :value 1 \./);
476
+ assert.match(output, /:counter :value 2 \./);
477
+ assert.match(output, /:counter :value 3 \./);
478
+ });
479
+
480
+ test('head blank nodes are deterministically skolemized with all universal bindings', () => {
481
+ const source = `
482
+ PREFIX : <http://example/>
483
+ DATA { :a :p :o . :b :p :o . }
484
+ RULE { [] :source ?s ; :value :o } WHERE { ?s :p :o }
485
+ `;
486
+ const first = run(source);
487
+ const second = run(source);
488
+ assert.deepEqual(first.inferred.map(tripleKey).sort(), second.inferred.map(tripleKey).sort());
489
+ const sourcePredicate = 'http://example/source';
490
+ const witnessSubjects = first.inferred
491
+ .filter((triple) => triple.p.type === 'iri' && triple.p.value === sourcePredicate)
492
+ .map((triple) => triple.s.value);
493
+ assert.equal(witnessSubjects.length, 2);
494
+ assert.equal(new Set(witnessSubjects).size, 2);
495
+ });
496
+
497
+
498
+ test('head blank skolemization is a standard function of all universals', () => {
499
+ const source = `
500
+ PREFIX : <http://example.org/#>
501
+ DATA { :s1 :p :o . :s2 :p :o . }
502
+ RULE { [] :witnessFor ?s } WHERE { ?s :p :o }
503
+ `;
504
+ const result = run(source, { maxIterations: 20, throwOnDiagnostics: false });
505
+ const witnessSubjects = result.inferred
506
+ .filter((triple) => triple.p.value === 'http://example.org/#witnessFor')
507
+ .map((triple) => triple.s.value);
508
+ assert.equal(witnessSubjects.length, 2);
509
+ assert.equal(new Set(witnessSubjects).size, 2);
510
+ });
511
+
512
+ test('recursive existential rules may not terminate with all-universal skolemization', () => {
513
+ const source = `
514
+ PREFIX : <http://example.org/#>
515
+ DATA { :s :p :o . }
516
+ RULE { [] :p :o } WHERE { ?s :p :o }
517
+ `;
518
+ assert.throws(
519
+ () => run(source, { maxIterations: 5, throwOnDiagnostics: false }),
520
+ /Reached maxIterations=5/,
521
+ );
522
+ });
459
523
 
460
524
  test('RDF Message Logs expose Eyeling-style envelopes and payload triples', () => {
461
525
  const messages = `VERSION "1.2-messages"
@@ -519,3 +583,20 @@ RULE { :test :annotation ?source } WHERE {
519
583
  assert.match(output, /:root :first 1 \./);
520
584
  assert.match(output, /:test :annotation :witness \./);
521
585
  });
586
+
587
+
588
+ test('strict conformance allows recursive constant BIND aliases', () => {
589
+ assert.doesNotThrow(() => compile(`
590
+ PREFIX : <http://example.org/#>
591
+ RULE { ?s ?p ?o } WHERE { ?s :p ?o . BIND(:p AS ?p) }
592
+ `, { shacl12Conformance: true, strict: true }));
593
+ });
594
+
595
+ test('strict conformance rejects recursive computed assignments', () => {
596
+ assert.throws(() => compile(`
597
+ PREFIX : <http://example.org/#>
598
+ RULE { ?x :p ?v1 } WHERE { ?x :p ?v . SET(?v1 := ?v + 1) }
599
+ `, { shacl12Conformance: true, strict: true }), /creates terms in a recursive dependency cycle/);
600
+ });
601
+
602
+ main();