eyemind 0.1.26 → 0.1.27

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
@@ -16,9 +16,9 @@ JavaScript library, and a browser application, with no dependencies.
16
16
  - **[Specification](https://eyereasoner.github.io/eyemind/SPEC)**: the
17
17
  language, its canonical text, the proof format, and the C1–C8 checks.
18
18
  - **[Examples](https://eyereasoner.github.io/eyemind/examples/)**:
19
- 93 runnable programs, each with a card deck and saved conclusions, proofs
19
+ 94 runnable programs, each with a card deck and saved conclusions, proofs
20
20
  and check reports.
21
- - **[Conformance suite](conformance/)**: 372 cases that test any
21
+ - **[Conformance suite](conformance/)**: 384 cases that test any
22
22
  implementation of the specification through its command line.
23
23
 
24
24
  ```js
package/SPEC.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ```text
4
4
  Title: The eyemind Rule Language and Proof Format
5
- Version: eyemind 0.1.26
5
+ Version: eyemind 0.1.27
6
6
  Status: Informational
7
7
  Author: Jos De Roo, KNoWS office of IDLab, Ghent University - imec
8
8
  Repository: https://github.com/eyereasoner/eyemind
@@ -22,7 +22,7 @@ command line.
22
22
  ## Status of This Memo
23
23
 
24
24
  This document is not an Internet Standards Track specification. It describes
25
- the language and formats as implemented by eyemind 0.1.26, for readers who want
25
+ the language and formats as implemented by eyemind 0.1.27, for readers who want
26
26
  to write programs, produce or consume proofs and reports, or implement a
27
27
  compatible reasoner or checker. Where this document and the implementation
28
28
  disagree, that is a defect in one of them.
@@ -166,7 +166,14 @@ A premise is a goal or an array of goals, their conjunction (Section 5.1). A
166
166
  body goal that is a conjunction is split into its conjuncts; the clause body
167
167
  is the resulting sequence of goals.
168
168
 
169
- Clauses are numbered from 1 in the order they are stated. Each clause records
169
+ `implies` and `implied_by` take exactly two arguments. Each call also returns
170
+ a rule value; when that value is embedded in another statement of the same
171
+ program build, the call contributes a template instead of a standalone clause
172
+ (Section 4.6). An ignored or unembedded return value keeps its standalone
173
+ clause.
174
+
175
+ Clauses are numbered from 1 in the order they are stated, omitting embedded
176
+ rule calls. Each clause records
170
177
  the line of the program on which the statement stating it begins; the facts
171
178
  of `facts_from` record the line of that call.
172
179
 
@@ -262,13 +269,29 @@ name MAY have different ranks.
262
269
 
263
270
  ### 4.6 Inline rule templates
264
271
 
265
- `forward(Body, Head)` and `backward(Head, Body)` construct rule templates.
266
- They MUST occur as top-level conjuncts of an `implies` conclusion. The
272
+ Nested `implies(Body, Head)` and `implied_by(Head, Body)` calls construct rule
273
+ templates. A rule value in a conclusion MUST occur as a top-level conjunct of
274
+ an `implies` conclusion; in a body, `implies` and `implied_by` are inspection
275
+ goals (Section 4.7). The
267
276
  conclusion MAY combine ordinary heads and several templates. Each template's
268
277
  body and head MAY themselves be conjunctions, written as arrays. A backward
269
278
  template with several heads defines one backward clause per head, sharing
270
279
  the same activation and body. Empty and open lists are not goals or heads.
271
280
 
281
+ JavaScript evaluates inner calls first. When a statement embeds the rule
282
+ value returned by an earlier call in the same program build, the earlier
283
+ call's standalone registration MUST be removed before clause numbering and
284
+ validation. This applies through arrays, helpers and stored values. A value
285
+ MAY be reused in several generators; none of those uses restores its
286
+ standalone registration. Separate program builds collect their registrations
287
+ independently, even when a rule value is passed between them. Anonymous
288
+ variables are named within each enclosing clause, so anonymous variables of
289
+ the template cannot capture unrelated anonymous variables of the generator.
290
+
291
+ The pure constructors `forward(Body, Head)` and `backward(Head, Body)` also
292
+ construct templates with the same lowering and scope. They do not themselves
293
+ register clauses.
294
+
272
295
  A template variable is **captured** when it also occurs in the generating
273
296
  clause's body or in an ordinary sibling head. The other variables are local
274
297
  to that template: they are universally quantified within the generated rule,
@@ -293,7 +316,7 @@ are numbered consecutively after them and inherit the generator's source line.
293
316
  Capture metadata belongs to each appended clause, including one whose head
294
317
  is subsequently lowered again.
295
318
 
296
- For example, `implies(cat(X), forward(dog(Y), test('yes')))` as the only
319
+ For example, `implies(cat(X), implies(dog(Y), test('yes')))` as the only
297
320
  stated clause becomes these two clauses:
298
321
 
299
322
  ```js
@@ -309,8 +332,41 @@ activation dependencies participate in Section 4.5, including clauses whose
309
332
  generators never fire. A closed dependency cycle MUST therefore be rejected
310
333
  even if its template would remain inactive.
311
334
 
335
+ ### 4.7 Source rule inspection
336
+
337
+ As goals, `implies(Body, Head)` and `implied_by(Head, Body)` match ordinary
338
+ forward and backward source rules respectively. The fixed catalogue contains
339
+ only original `implies` and `implied_by` clauses whose heads contain no rule
340
+ templates and whose bodies contain no inspection goals, including inside
341
+ controls. Facts, queries, contradictions, template generators and appended
342
+ clauses are excluded. Inspecting rules therefore cannot inspect themselves.
343
+
344
+ The catalogue keeps source order. A rule's body and forward head conjunctions
345
+ are terms `and(...)`, as normalized goals are, and inspection arguments are
346
+ goal positions. Each attempt freshens the whole rule with one variable map:
347
+ shared variables stay shared, and different attempts are independent. Matching
348
+ can instantiate these variables. Inspection reads the rule's structure; it
349
+ does not solve its body or establish that its head holds.
350
+
351
+ Free variables supplied by a successful inspection are scoped universal
352
+ pattern variables. Forward conclusions preserve them, including through
353
+ backward derivations and copied collection results. Derived patterns are
354
+ identified up to variable renaming and freshened, together with their evidence,
355
+ at every use. Unrelated unbound variables keep Section 7.2's witness behavior.
356
+ When inspection variables remain free in a conclusion or its captured
357
+ template context, that firing MUST NOT introduce head-only existential
358
+ witnesses: it MUST fail with an error requiring the inspection variables to
359
+ be bound first. Section 11.9 rejects certificates that bypass this boundary.
360
+
361
+ Inspection uses a fixed source catalogue, so its own results do not depend on
362
+ forward rounds. Other goals using derived patterns still participate in
363
+ stratification. Negation and collection retain their explicit obligations.
364
+
312
365
  ## 5. Goals, Controls and Primitives
313
366
 
367
+ The program-dependent source inspection goals `implies/2` and `implied_by/2`
368
+ are defined in Section 4.7.
369
+
314
370
  ### 5.1 Controls
315
371
 
316
372
  | Control | Term | Meaning |
@@ -455,7 +511,8 @@ concluded:
455
511
  shared. A variable that a value of the solution brought in, rather than a
456
512
  head variable, is named after its own variable, followed by `1`, `2`, ...
457
513
  when that name is taken. A rule that keeps meeting new activations does
458
- not terminate (Section 7.3). Derived facts are therefore ground. A
514
+ not terminate (Section 7.3). Derived facts other than scoped inspection
515
+ patterns (Section 4.7) are therefore ground. A
459
516
  checker verifies the Skolem terms of a proof (C8, Section 11.9);
460
517
  - a head `'true'` reports the instance of the rule's body, as a conjunction,
461
518
  once per distinct instance (by identity of canonical text);
@@ -473,6 +530,12 @@ witnesses of different captured contexts even when a head does not mention
473
530
  those values. An empty capture list still contributes one element, `[]`.
474
531
  Original clauses, including generators, use the ordinary witness convention.
475
532
 
533
+ Scoped inspection variables of Section 4.7 are preserved instead of receiving
534
+ witnesses; derived conclusions may therefore be universal patterns. Each use
535
+ renames a pattern and its evidence apart. Such patterns are suppressed when
536
+ an alpha-equivalent pattern was already derived. Other derived facts keep
537
+ their ordinary ground identity convention.
538
+
476
539
  ### 7.3 Bounds
477
540
 
478
541
  A run is bounded by `max_depth`, the depth of nested rule bodies (default
@@ -658,6 +721,7 @@ order. `By` is one of:
658
721
  | `By` | Meaning | `Bindings` | `Uses` |
659
722
  | --- | --- | --- | --- |
660
723
  | `clause(N)` | `Goal` is an instance of a head of clause `N`, a fact or a rule, whose body instance is `Uses`. | the clause's variables | the body instance, empty for a fact |
724
+ | `source(N)` | `Goal` is an instance of the inspectable source rule `N` as `implies(Body, Head)` or `implied_by(Head, Body)`. | empty | empty |
661
725
  | `'builtin'` | `Goal` is a primitive that holds. | empty | empty |
662
726
  | `'control'` | `Goal` is `call`, `once` or a disjunction, solved by `Uses`. | empty | the solved goals |
663
727
  | `'absent'` | `Goal` is a negation taken on trust. | empty | empty |
@@ -700,6 +764,14 @@ that afterwards the head is identical to `Goal` and each body goal to its
700
764
  use. A step's own terms MUST NOT need further instantiation to match: a
701
765
  source clause `same(X, X)` cannot justify `same('a', 'b')`.
702
766
 
767
+ For a `source(N)` step, `N` MUST identify an eligible source rule of Section
768
+ 4.7. Bindings and uses MUST both be empty. The rule's inspection term,
769
+ freshened with one variable map, MUST unify with `Goal` and resolve exactly
770
+ to it without instantiating the step's own terms. This checks variable
771
+ sharing as well as the rule's direction and structure. The source rule is
772
+ read as data; its body is not derived by the checker. These steps count as
773
+ verified C1 steps and cite the usual source clause display.
774
+
703
775
  ### 11.3 C2 Well-foundedness
704
776
 
705
777
  No step MAY depend on itself through the uses of steps.
@@ -768,6 +840,10 @@ bound to `skolem(N, Name, Args)`, where:
768
840
  the heads, that are ground and contain no Skolem term `skolem(N, _, Args)`:
769
841
  the values that do contain one hold a witness of this firing.
770
842
 
843
+ When a forward clause has any such variables, its full head under the step's
844
+ substitution MUST be ground. A generic inspection pattern cannot share one
845
+ existential witness across all its instances (Section 4.7).
846
+
771
847
  For an appended forward clause of Section 4.6, prepend the capture list,
772
848
  instantiated under the step's substitution, as one element of `Args`, as
773
849
  required by Section 7.2. The checker MUST compute this from the source
@@ -782,7 +858,7 @@ covered by C8.
782
858
 
783
859
  | Condition | Covered |
784
860
  | --- | --- |
785
- | C1 resolution | `clause` steps that passed C1 |
861
+ | C1 resolution | `clause` and `source` steps that passed C1 |
786
862
  | C2 well_founded | steps |
787
863
  | C3 justification | steps |
788
864
  | C4 coverage | claims plus uses |
@@ -792,7 +868,8 @@ covered by C8.
792
868
  | C8 witness_integrity | `clause` steps of forward rules with witnesses that passed C8 |
793
869
 
794
870
  A check also counts the **steps** it read, the **claims**, the `clause` steps
795
- **verified** by C1, the `'builtin'` steps **recomputed** by C5, the
871
+ **verified** by C1, the `source` inspection steps also **verified** by C1,
872
+ the `'builtin'` steps **recomputed** by C5, the
796
873
  `'control'` steps **composed** by C5, and the `'absent'` and `'collected'`
797
874
  steps **trusted**.
798
875
 
@@ -846,7 +923,7 @@ it differently. eyemind's details are, by condition:
846
923
 
847
924
  | Condition | Details |
848
925
  | --- | --- |
849
- | C1 | `clause display differs from source`, `unknown clause ...`, `not an instance of source clause N: ...` |
926
+ | C1 | `clause display differs from source`, `unknown clause ...`, `not an instance of source clause N: ...`, `not an instance of inspectable source rule N: ...` |
850
927
  | C2 | `cyclic derivation at ...` |
851
928
  | C3 | the reader's error, `step bindings must be an object and uses an array`, `duplicate justification for ...`, `invalid builtin justification`, `trusted boundaries cannot have bindings or uses`, `unknown justification ...` |
852
929
  | C4 | `unjustified claim ...`, `unjustified use ...` |
@@ -957,7 +1034,7 @@ conclusions and proof documents in the canonical text, and checks every proof
957
1034
  it produces as Section 11 describes. A conforming **checker** implements
958
1035
  Sections 9, 11 and 12 and does not depend on a reasoner. For the same
959
1036
  program, a conforming reasoner and checker MUST produce the same
960
- conclusions, proof documents and reports as eyemind 0.1.26, byte for byte,
1037
+ conclusions, proof documents and reports as eyemind 0.1.27, byte for byte,
961
1038
  except for comments and the `Detail` texts of failures (Section 12), the internal names of
962
1039
  variables where Section 3 says so, and where the host's floating-point
963
1040
  library functions (`exp`, `log`, the trigonometric and hyperbolic functions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyemind",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "A dependency-free JavaScript rule language with forward and backward reasoning and checkable proofs.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/engine.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { PRIMITIVE_KEYS, primitive } from './builtins.js';
3
3
  import { freshClause, matchHead, renaming, text, variant } from './common.js';
4
4
  import { readGoal } from './goals.js';
5
+ import { INSPECTION_KEYS } from './inspection.js';
5
6
  import { checkRendered, clauseDisplay, renderProof } from './proof.js';
6
7
  import { CONTROL_KEYS, Program } from './program.js';
7
8
  import { isActivation } from './templates.js';
@@ -19,7 +20,7 @@ export const COLLECTED = 'collected';
19
20
 
20
21
  // One proof step: a goal, how it was justified, and the steps it used.
21
22
  export class Node {
22
- constructor(goal, by, bindings = NO_NODES, children = NO_NODES, collectedUses = null, final = false) {
23
+ constructor(goal, by, bindings = NO_NODES, children = NO_NODES, collectedUses = null, final = false, scoped = NO_NODES) {
23
24
  this.goal = goal;
24
25
  // A derived fact's node is resolved once and for all when the fact is
25
26
  // derived, so whatever uses the fact shares the node, derivation and all.
@@ -30,6 +31,8 @@ export class Node {
30
31
  // A collection has no children in a proof, so its node keeps the clauses
31
32
  // its answers used.
32
33
  this.collectedUses = collectedUses;
34
+ this.scoped = scoped;
35
+ this.generic = false;
33
36
  }
34
37
  }
35
38
 
@@ -92,7 +95,7 @@ class Point {
92
95
  }
93
96
 
94
97
  count() {
95
- if (this.kind === 'branch') return this.alternatives.length;
98
+ if (this.kind === 'branch' || this.kind === 'inspection') return this.alternatives.length;
96
99
  return this.stop - this.start + this.clauses.length;
97
100
  }
98
101
  }
@@ -104,7 +107,50 @@ const SPLICE = 'splice';
104
107
  function resolveOne(node, env) {
105
108
  return new Node(copyResolved(node.goal, env), node.by,
106
109
  node.bindings.map(([name, value]) => [name, copyResolved(value, env)]),
107
- new Array(node.children.length).fill(null), node.collectedUses);
110
+ new Array(node.children.length).fill(null), node.collectedUses, false,
111
+ node.scoped.map((term) => copyResolved(term, env)));
112
+ }
113
+
114
+ // An inspected source rule supplies universal variables. Follow the recorded
115
+ // evidence so ordinary fresh variables still receive existential witnesses.
116
+ function scopedVariables(nodes, env) {
117
+ const found = new Map();
118
+ const seen = new Set();
119
+ const pending = [...nodes];
120
+ while (pending.length) {
121
+ const node = pending.pop();
122
+ if (seen.has(node)) continue;
123
+ seen.add(node);
124
+ if (isTerm(node.by, 'source', 1)) variables(copyResolved(node.goal, env), found);
125
+ for (const term of node.scoped) variables(copyResolved(term, env), found);
126
+ pending.push(...node.children);
127
+ }
128
+ return found;
129
+ }
130
+
131
+ // A universal derived pattern is renamed together with its evidence at each
132
+ // use. Renaming just its head would disconnect the certificate's variables.
133
+ function freshNode(node, suffix) {
134
+ const names = new Map();
135
+ const copies = new Map();
136
+ const make = (source) => new Node(freshTerm(source.goal, suffix, names, true), source.by,
137
+ source.bindings.map(([name, value]) => [name, freshTerm(value, suffix, names, true)]),
138
+ [], source.collectedUses, false, source.scoped.map((term) => freshTerm(term, suffix, names, true)));
139
+ const root = make(node);
140
+ copies.set(node, root);
141
+ const pending = [node];
142
+ while (pending.length) {
143
+ const source = pending.pop();
144
+ const target = copies.get(source);
145
+ for (const child of source.children) {
146
+ if (!copies.has(child)) {
147
+ copies.set(child, make(child));
148
+ pending.push(child);
149
+ }
150
+ target.children.push(copies.get(child));
151
+ }
152
+ }
153
+ return root;
108
154
  }
109
155
 
110
156
  // Resolve a proof forest with an explicit stack: a derivation chain is as deep
@@ -189,7 +235,8 @@ export function clausesBehindBoundaries(program, roots) {
189
235
  const goalKey = pending.pop();
190
236
  if (seen.has(goalKey)) continue;
191
237
  seen.add(goalKey);
192
- for (const clause of byKey.get(goalKey) || []) {
238
+ const candidates = INSPECTION_KEYS.has(goalKey) ? program.inspections.get(goalKey) : byKey.get(goalKey) || [];
239
+ for (const clause of candidates) {
193
240
  reached.add(clause.id);
194
241
  for (const goal of clause.body) visit(goal);
195
242
  }
@@ -213,7 +260,7 @@ export class Solver {
213
260
  constructor(program, options) {
214
261
  this.program = program;
215
262
  this.options = options;
216
- this.recording = Boolean(options.proof);
263
+ this.recording = Boolean(options.proof || program.inspectionEnabled);
217
264
  this.serial = 0;
218
265
  this.facts = new Map();
219
266
  // Derived facts indexed by argument: for each name and arity, one map
@@ -221,6 +268,8 @@ export class Solver {
221
268
  // of the facts with that value there.
222
269
  this.factIndex = new Map();
223
270
  this.factKeys = new Set(program.groundFactKeys);
271
+ this.genericKeys = new Set();
272
+ this.genericPredicates = new Set();
224
273
  this.derived = [];
225
274
  this.reported = new Map();
226
275
  this.stats = { inferences: 0, rounds: 0, derived: 0 };
@@ -364,11 +413,18 @@ export class Solver {
364
413
  }
365
414
  if (goalKey === 'findall/3') {
366
415
  const items = [];
416
+ const scoped = [];
367
417
  const uses = recording ? new Set() : null;
368
418
  const mark = env.mark();
369
419
  for (const [answerEnv, nodes] of this.solve([goal.args[1]], env, frame.depth + 1)) {
370
420
  this.serial++;
371
- items.push(freshTerm(copyResolved(goal.args[0], answerEnv), `collection${this.serial}`));
421
+ const names = new Map();
422
+ items.push(freshTerm(copyResolved(goal.args[0], answerEnv), `collection${this.serial}`, names));
423
+ if (this.program.inspectionEnabled) {
424
+ for (const name of scopedVariables(nodes, answerEnv).keys()) {
425
+ if (names.has(name)) scoped.push(names.get(name));
426
+ }
427
+ }
372
428
  if (recording) clausesUsed(nodes, uses);
373
429
  }
374
430
  env.undo(mark);
@@ -377,10 +433,12 @@ export class Solver {
377
433
  return null;
378
434
  }
379
435
  if (!recording) return this.advance(frame, null);
380
- return this.advance(frame, new Node(goal, COLLECTED, NO_NODES, NO_NODES, uses));
436
+ return this.advance(frame, new Node(goal, COLLECTED, NO_NODES, NO_NODES, uses, false, scoped));
381
437
  }
382
438
  let point;
383
- if (goalKey === 'or/2') {
439
+ if (INSPECTION_KEYS.has(goalKey)) {
440
+ point = new Point('inspection', frame, env.mark(), goal, { alternatives: this.program.inspections.get(goalKey) });
441
+ } else if (goalKey === 'or/2') {
384
442
  point = new Point('branch', frame, env.mark(), goal, { alternatives: goal.args });
385
443
  } else if (goalKey === 'call/1' || goalKey === 'once/1') {
386
444
  const cut = goalKey === 'once/1' ? choices.length : null;
@@ -410,6 +468,7 @@ export class Solver {
410
468
  }
411
469
 
412
470
  addFact(goalKey, node) {
471
+ if (node.generic) this.genericPredicates.add(goalKey);
413
472
  let facts = this.facts.get(goalKey);
414
473
  if (facts === undefined) {
415
474
  facts = [];
@@ -437,6 +496,7 @@ export class Solver {
437
496
  // The indexes of the derived facts a goal could match, in order, when an
438
497
  // argument of the goal narrows them down; null to try them all.
439
498
  factOrder(goal, env, goalKey) {
499
+ if (this.genericPredicates.has(goalKey)) return null;
440
500
  const positions = this.factIndex.get(goalKey);
441
501
  if (positions === undefined) return null;
442
502
  let best = null;
@@ -477,11 +537,21 @@ export class Solver {
477
537
  return this.childFrame(point.frame, [point.alternatives[position]],
478
538
  this.controlPending(point.goal, null), point.frame.depth + 1);
479
539
  }
540
+ if (point.kind === 'inspection') {
541
+ const source = point.alternatives[position];
542
+ this.serial++;
543
+ if (unify(point.goal, freshTerm(source.term, this.serial, new Map(), true), env)) {
544
+ return this.advance(point.frame, recording ? new Node(point.goal, new Struct('source', [BigInt(source.id)])) : null);
545
+ }
546
+ env.undo(point.mark);
547
+ continue;
548
+ }
480
549
  const inWindow = point.stop - point.start;
481
550
  if (position < inWindow) {
482
551
  let index = point.start + position;
483
552
  if (point.order !== null) index = point.order[index];
484
- const fact = point.facts[index];
553
+ let fact = point.facts[index];
554
+ if (fact.generic) fact = freshNode(fact, ++this.serial);
485
555
  if (unify(point.goal, fact.goal, env)) {
486
556
  if (windowed) this.chosen[point.frame.index] = [0, index];
487
557
  return this.advance(point.frame, fact);
@@ -542,7 +612,7 @@ export class Solver {
542
612
  break;
543
613
  }
544
614
  const goalKey = key(goal);
545
- if (PRIMITIVE_KEYS.has(goalKey) || goalKey === 'not/1' || goalKey === 'findall/3') {
615
+ if (PRIMITIVE_KEYS.has(goalKey) || INSPECTION_KEYS.has(goalKey) || goalKey === 'not/1' || goalKey === 'findall/3') {
546
616
  keys.push(null);
547
617
  } else if (CONTROL_KEYS.has(goalKey) || (this.program.groups.get(goalKey) || []).some((item) => item.body.length)) {
548
618
  keys = null;
@@ -630,6 +700,19 @@ export class Solver {
630
700
  // between the heads of one conclusion.
631
701
  const resolved = copyResolved(head, answerEnv);
632
702
  const unresolved = variables(resolved);
703
+ if (this.program.inspectionEnabled) {
704
+ const scoped = scopedVariables(nodes, answerEnv);
705
+ const captured = clause.captures === null ? null : copyResolved(body[0].args[1], answerEnv);
706
+ const relevant = [...unresolved.keys(), ...(captured === null ? [] : variables(captured).keys())];
707
+ if (relevant.some((name) => scoped.has(name))) {
708
+ const inBody = new Set();
709
+ for (const goal of clause.body) for (const name of variables(goal).keys()) inBody.add(name);
710
+ if ([...variables(clause.head).keys()].some((name) => !inBody.has(name))) {
711
+ throw new EyemindError('bind inspected rule variables before deriving existential witnesses');
712
+ }
713
+ }
714
+ for (const name of scoped.keys()) unresolved.delete(name);
715
+ }
633
716
  if (unresolved.size) {
634
717
  // A generated rule also depends on its captures when its head omits
635
718
  // them; otherwise two enabled instances could share a witness.
@@ -678,7 +761,7 @@ export class Solver {
678
761
  for (const [conclusions, children, bindings, claim] of this.activations(clause)) {
679
762
  for (const conclusion of conclusions) {
680
763
  if (conclusion === 'true') {
681
- const claimId = text(claim);
764
+ const claimId = this.program.inspectionEnabled ? variant(claim) : text(claim);
682
765
  if (!this.reported.has(claimId)) this.reported.set(claimId, [claim, children, clause.id]);
683
766
  continue;
684
767
  }
@@ -688,9 +771,11 @@ export class Solver {
688
771
  this.haltCode = 65;
689
772
  return;
690
773
  }
691
- const conclusionId = identity(conclusion);
692
- if (this.factKeys.has(conclusionId)) continue;
693
- this.factKeys.add(conclusionId);
774
+ node.generic = this.program.inspectionEnabled && !isGround(conclusion);
775
+ const conclusionId = node.generic ? variant(conclusion) : identity(conclusion);
776
+ const factKeys = node.generic ? this.genericKeys : this.factKeys;
777
+ if (factKeys.has(conclusionId)) continue;
778
+ factKeys.add(conclusionId);
694
779
  this.addFact(key(conclusion), node);
695
780
  this.derived.push(node);
696
781
  this.stats.derived++;
package/src/goals.js CHANGED
@@ -25,7 +25,7 @@ export function normalizeGoal(term) {
25
25
  if ((name === 'and' || name === 'or') && args.length === 2) {
26
26
  return rebuild(term, args.map(normalizeGoal));
27
27
  }
28
- if ((name === 'forward' || name === 'backward') && args.length === 2) {
28
+ if (['implies', 'implied_by', 'forward', 'backward'].includes(name) && args.length === 2) {
29
29
  return rebuild(term, args.map(normalizeGoal));
30
30
  }
31
31
  if ((name === 'not' || name === 'call' || name === 'once') && args.length === 1) {
@@ -0,0 +1,17 @@
1
+ // Rule inspection reads a fixed source catalogue, not the run's rule index.
2
+ import { Struct, conjunction, isTerm } from './terms.js';
3
+
4
+ export const INSPECTION_KEYS = new Set(['implies/2', 'implied_by/2']);
5
+ export const isInspection = (term) => isTerm(term, 'implies', 2) || isTerm(term, 'implied_by', 2);
6
+
7
+ export function hasInspection(goal) {
8
+ if (isInspection(goal)) return true;
9
+ if (isTerm(goal, 'and', 2) || isTerm(goal, 'or', 2)) return goal.args.some(hasInspection);
10
+ if (isTerm(goal, 'not', 1) || isTerm(goal, 'call', 1) || isTerm(goal, 'once', 1)) return hasInspection(goal.args[0]);
11
+ return isTerm(goal, 'findall', 3) && hasInspection(goal.args[1]);
12
+ }
13
+
14
+ export function inspectionTerm(clause) {
15
+ const body = conjunction(clause.body);
16
+ return new Struct(clause.forward ? 'implies' : 'implied_by', clause.forward ? [body, clause.head] : [clause.head, body]);
17
+ }
package/src/lang.js CHANGED
@@ -10,8 +10,9 @@
10
10
  // Atoms are strings, integers are numbers with an integral value or bigints,
11
11
  // floats are other numbers, lists are arrays, and [H, ...T] is a list with
12
12
  // head H and tail T. An array in a goal position is the conjunction of its
13
- // goals. Each call states one clause, in source order; loadText runs the
14
- // script and collects them into a Program.
13
+ // goals. Rule calls embedded in another statement are templates; other
14
+ // statements contribute clauses in source order. loadText runs the script
15
+ // and collects them into a Program.
15
16
  //
16
17
  // A name the program does not define is supplied as it runs: a name
17
18
  // beginning with an uppercase letter or _ is the variable of that name, and
@@ -148,10 +149,13 @@ class Builder {
148
149
  constructor(locate) {
149
150
  this.sources = [];
150
151
  this.locate = locate;
152
+ this.rules = new WeakMap();
153
+ this.embedded = new Set();
151
154
  }
152
155
 
153
156
  add(kind, head, body, line = null) {
154
157
  if (line === null) line = statedLine !== null ? statedLine : this.locate();
158
+ const pending = [head, ...body];
155
159
  const counter = new Anonymous(head, body);
156
160
  // In the order the statement writes them: a forward rule, implies(),
157
161
  // writes its premise first.
@@ -171,7 +175,35 @@ class Builder {
171
175
  }
172
176
  if (goal === EMPTY) throw new EyemindError('an empty array is not a goal');
173
177
  }
174
- this.sources.push(new Source(kind, head, body, line));
178
+ // JavaScript evaluates an inner rule first. Only a valid enclosing
179
+ // statement consumes it, using its value before anonymous renaming.
180
+ while (pending.length) {
181
+ const term = pending.pop();
182
+ if (!(term instanceof Struct)) continue;
183
+ const source = this.rules.get(term);
184
+ if (source !== undefined) this.embedded.add(source);
185
+ pending.push(...term.args);
186
+ }
187
+ const source = new Source(kind, head, body, line);
188
+ this.sources.push(source);
189
+ return source;
190
+ }
191
+
192
+ rule(name, args) {
193
+ if (args.length !== 2) throw new EyemindError(`${name} takes 2 arguments, got ${args.length}`);
194
+ const forward = name === 'implies';
195
+ const head = forward ? goalOf(args[1]) : toTerm(args[0]);
196
+ const premise = goalOf(args[forward ? 0 : 1]);
197
+ // Keep the returned value's anonymous variables untouched: each enclosing
198
+ // clause names its own occurrences, including those inside templates.
199
+ const value = new Struct(name, forward ? [premise, head] : [normalizeGoal(head), premise]);
200
+ const source = this.add(forward ? 'forward' : 'backward', head, [premise]);
201
+ this.rules.set(value, source);
202
+ return value;
203
+ }
204
+
205
+ program() {
206
+ return new Program(this.sources.filter((source) => !this.embedded.has(source)));
175
207
  }
176
208
  }
177
209
 
@@ -254,8 +286,8 @@ function exportsFor(io) {
254
286
  fact: (...terms) => {
255
287
  for (const term of terms) builder.add('fact', toTerm(term), []);
256
288
  },
257
- implies: (premise, conclusion) => builder.add('forward', goalOf(conclusion), [goalOf(premise)]),
258
- implied_by: (conclusion, premise) => builder.add('backward', toTerm(conclusion), [goalOf(premise)]),
289
+ implies: (...args) => active().rule('implies', args),
290
+ implied_by: (...args) => active().rule('implied_by', args),
259
291
  forward: (...args) => {
260
292
  if (args.length !== 2) throw new EyemindError(`forward takes 2 arguments, got ${args.length}`);
261
293
  return new Struct('forward', args.map(goalOf));
@@ -686,7 +718,7 @@ function execute(source, filename, builder, io) {
686
718
  export function loadText(source, filename = '<program>', io = null) {
687
719
  const builder = new Builder(() => null);
688
720
  execute(String(source).replace(/^#![^\n]*/, ''), filename, builder, io);
689
- return new Program(builder.sources);
721
+ return builder.program();
690
722
  }
691
723
 
692
724
  // One Program from several sources, given as [text, filename] pairs, run in
@@ -696,7 +728,7 @@ export function loadSources(sources, io = null) {
696
728
  for (const [source, filename] of sources) {
697
729
  execute(String(source).replace(/^#![^\n]*/, ''), filename, builder, io);
698
730
  }
699
- return new Program(builder.sources);
731
+ return builder.program();
700
732
  }
701
733
 
702
734
  // A Program from a function that states clauses when called: a function of a
@@ -706,7 +738,7 @@ export function build(statements) {
706
738
  const outer = builders.length ? builders[builders.length - 1] : null;
707
739
  const builder = new Builder(outer ? outer.locate : () => null);
708
740
  within(builder, () => statements(exportsFor(null)));
709
- return new Program(builder.sources);
741
+ return builder.program();
710
742
  }
711
743
 
712
744
  export { FloatLiteral, PREDICATE };
package/src/program.js CHANGED
@@ -1,13 +1,14 @@
1
1
  // A program: its clauses, validated, indexed and stratified.
2
2
  import { PRIMITIVE_KEYS } from './builtins.js';
3
- import { ACTIVATION_KEY, expandTemplates } from './templates.js';
3
+ import { ACTIVATION_KEY, containsTemplate, expandTemplates } from './templates.js';
4
+ import { hasInspection, inspectionTerm } from './inspection.js';
4
5
  import {
5
6
  Env, EyemindError, Struct, Var, deref, flattenConjunction, freshTerm, identity, isCallable,
6
7
  isGround, isTerm, key, keyArity, unify,
7
8
  } from './terms.js';
8
9
 
9
10
  export const CONTROL_KEYS = new Set(['and/2', 'or/2', 'not/1', 'call/1', 'once/1', 'findall/3']);
10
- export const RESERVED_KEYS = new Set(['step/4', 'clause/2', ACTIVATION_KEY, 'forward/2', 'backward/2']);
11
+ export const RESERVED_KEYS = new Set(['step/4', 'clause/2', ACTIVATION_KEY, 'implies/2', 'implied_by/2', 'forward/2', 'backward/2']);
11
12
 
12
13
  // The statements that state a forward rule, and how an error names them.
13
14
  const FORWARD_KINDS = { forward: 'a forward rule', query: 'query()', contradiction: 'contradiction()' };
@@ -131,6 +132,8 @@ export class Program {
131
132
  // clauses: many groups of a large program are never asked for, and a
132
133
  // program made to leave one clause out is often run only briefly.
133
134
  this.indexes = new Map();
135
+ this.inspectionEnabled = this.clauses.some((clause) => clause.body.some(hasInspection));
136
+ this.inspectionCatalogue = null;
134
137
  // Ground source facts are compared against forward conclusions by
135
138
  // identity, so work those out once here.
136
139
  this.groundFactKeys = new Set();
@@ -140,6 +143,31 @@ export class Program {
140
143
  this.strata = stratify(this.clauses, stratifying);
141
144
  }
142
145
 
146
+ prepareInspections() {
147
+ if (this.inspectionCatalogue !== null) return;
148
+ this.inspectionCatalogue = new Map([['implies/2', []], ['implied_by/2', []]]);
149
+ this.inspectionSources = new Map();
150
+ for (const clause of this.clauses) {
151
+ const source = this.sources[clause.id - 1];
152
+ if (!source || !['forward', 'backward'].includes(source.kind) ||
153
+ containsTemplate(source.head) || source.body.some(hasInspection)) continue;
154
+ const term = inspectionTerm(clause);
155
+ const entry = { id: clause.id, term, body: [] };
156
+ this.inspectionCatalogue.get(key(term)).push(entry);
157
+ this.inspectionSources.set(clause.id, entry);
158
+ }
159
+ }
160
+
161
+ get inspections() {
162
+ this.prepareInspections();
163
+ return this.inspectionCatalogue;
164
+ }
165
+
166
+ get inspectedById() {
167
+ this.prepareInspections();
168
+ return this.inspectionSources;
169
+ }
170
+
143
171
  candidates(goal, env, goalKey) {
144
172
  const clauses = this.groups.get(goalKey);
145
173
  if (clauses === undefined) return [];
package/src/proof.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import { PRIMITIVE_KEYS, primitive } from './builtins.js';
12
12
  import { freshClause, text } from './common.js';
13
13
  import { readGoal } from './goals.js';
14
+ import { INSPECTION_KEYS, isInspection } from './inspection.js';
14
15
  import { CONTROL_KEYS, Program } from './program.js';
15
16
  import { readTerms } from './reader.js';
16
17
  import { isActivation } from './templates.js';
@@ -320,6 +321,15 @@ function checkSteps(program, steps, allowTrusted, fail, spellings, name) {
320
321
  }
321
322
  if (isTerm(by, 'clause', 1)) {
322
323
  if (checkResolution(program, step, fail, tally)) tally.verified++;
324
+ } else if (isTerm(by, 'source', 1)) {
325
+ const clause = clauseNamed(program, by.args[0]);
326
+ const inspected = clause === null ? null : program.inspectedById.get(clause.id);
327
+ const env = new Env();
328
+ const pattern = inspected ? freshTerm(inspected.term, 'inspection') : null;
329
+ if (!inspected || bindings.length || uses.length || !isInspection(goal) ||
330
+ !unify(pattern, goal, env) || !resolvesTo(pattern, env, goal)) {
331
+ fail.add('C1', `not an instance of inspectable source rule ${text(by.args[0])}: ${text(goal)}`, goal);
332
+ } else tally.verified++;
323
333
  } else if (by === BUILTIN_BY) {
324
334
  if (bindings.length || uses.length || !isCallable(goal) || !PRIMITIVE_KEYS.has(key(goal))) {
325
335
  fail.add('C3', 'invalid builtin justification', goal);
@@ -493,6 +503,9 @@ function witnessNames(clause) {
493
503
  function checkWitnesses(clause, head, names, env) {
494
504
  const witnessed = witnessNames(clause);
495
505
  if (!witnessed.length) return null;
506
+ // An existential firing needs bound inspection values. A generic rule
507
+ // pattern cannot silently share one witness across all its instances.
508
+ if (!isGround(copyResolved(head, env))) return false;
496
509
  let firing = null;
497
510
  for (const name of witnessed) {
498
511
  const value = copyResolved(names.get(name), env);
@@ -564,6 +577,9 @@ function checkBoundaries(program, steps, boundaries, fail) {
564
577
  return [];
565
578
  }
566
579
  if (CONTROL_KEYS.has(goalKey)) return null;
580
+ if (INSPECTION_KEYS.has(goalKey)) {
581
+ return program.inspections.get(goalKey).map((entry) => freshTerm(entry.term, `evidence${nextSerial()}`));
582
+ }
567
583
  const facts = (program.groups.get(goalKey) || [])
568
584
  .filter((clause) => !clause.body.length && !clause.forward)
569
585
  .map((clause) => freshTerm(clause.head, `evidence${nextSerial()}`));
package/src/templates.js CHANGED
@@ -1,15 +1,17 @@
1
1
  // Inline rule templates become statically visible clauses whose first
2
2
  // premise records the generating activation. No rule is installed at run time.
3
3
  import { normalizeGoal } from './goals.js';
4
+ import { isInspection } from './inspection.js';
4
5
  import {
5
6
  EMPTY, EyemindError, Struct, Var, conjunction, flattenConjunction, isCallable, isTerm, listFromItems, variables,
6
7
  } from './terms.js';
7
8
 
8
9
  export const ACTIVATION_KEY = 'activated/2';
9
10
  export const isActivation = (term) => isTerm(term, 'activated', 2);
10
- const isTemplate = (term) => isTerm(term, 'forward', 2) || isTerm(term, 'backward', 2);
11
+ const isTemplate = (term) => term instanceof Struct && term.args.length === 2 &&
12
+ ['implies', 'implied_by', 'forward', 'backward'].includes(term.name);
11
13
 
12
- function containsTemplate(term) {
14
+ export function containsTemplate(term) {
13
15
  const pending = [term];
14
16
  while (pending.length) {
15
17
  const item = pending.pop();
@@ -19,31 +21,40 @@ function containsTemplate(term) {
19
21
  return false;
20
22
  }
21
23
 
22
- function checkGoal(goal, line) {
23
- if (goal instanceof Var) throw new EyemindError(`line ${line}: generated rule bodies require statically named calls`);
24
- if (!isCallable(goal) || goal === EMPTY || isTerm(goal, '.', 2)) {
24
+ function checkGoal(goal, line, generated = false) {
25
+ if (generated && goal instanceof Var) throw new EyemindError(`line ${line}: generated rule bodies require statically named calls`);
26
+ if (generated && (!isCallable(goal) || goal === EMPTY || isTerm(goal, '.', 2))) {
25
27
  throw new EyemindError(`line ${line}: a generated rule goal must be an atom or a compound, not a number or list`);
26
28
  }
27
29
  if (!(goal instanceof Struct)) return;
30
+ if (isInspection(goal)) return;
28
31
  if (isTemplate(goal)) throw new EyemindError(`line ${line}: rule templates belong in an implies() conclusion`);
29
32
  if (isTerm(goal, 'and', 2) || isTerm(goal, 'or', 2)) {
30
- for (const part of goal.args) checkGoal(part, line);
33
+ for (const part of goal.args) checkGoal(part, line, generated);
31
34
  } else if (['not', 'call', 'once'].includes(goal.name) && goal.args.length === 1) {
32
- checkGoal(goal.args[0], line);
33
- } else if (isTerm(goal, 'findall', 3)) checkGoal(goal.args[1], line);
35
+ checkGoal(goal.args[0], line, generated);
36
+ } else if (isTerm(goal, 'findall', 3)) {
37
+ if (containsTemplate(goal.args[0]) || containsTemplate(goal.args[2])) {
38
+ throw new EyemindError(`line ${line}: rule values in a body must be inspection goals`);
39
+ }
40
+ checkGoal(goal.args[1], line, generated);
41
+ } else if (containsTemplate(goal)) {
42
+ throw new EyemindError(`line ${line}: rule templates must be top-level conjuncts of an implies() conclusion or inspection goals`);
43
+ }
34
44
  }
35
45
 
36
46
  export function expandTemplates(sources) {
37
47
  const expanded = [...sources];
38
48
  for (let index = 0; index < expanded.length; index++) {
39
49
  const source = expanded[index];
50
+ for (const goal of source.body) checkGoal(goal, source.line, source.captures !== undefined);
40
51
  const heads = flattenConjunction(source.head);
41
52
  for (const head of heads) {
42
53
  if (isActivation(head)) throw new EyemindError(`line ${source.line}: activated/2 is reserved for generated rules`);
43
54
  }
44
55
  const templates = heads.filter(isTemplate);
45
56
  if (!templates.length) {
46
- if (containsTemplate(source.head) || source.body.some(containsTemplate)) {
57
+ if (containsTemplate(source.head)) {
47
58
  throw new EyemindError(`line ${source.line}: rule templates must be top-level conjuncts of an implies() conclusion`);
48
59
  }
49
60
  continue;
@@ -51,9 +62,6 @@ export function expandTemplates(sources) {
51
62
  if (source.kind !== 'forward') {
52
63
  throw new EyemindError(`line ${source.line}: rule templates belong in an implies() conclusion`);
53
64
  }
54
- if (source.body.some(containsTemplate)) {
55
- throw new EyemindError(`line ${source.line}: rule templates belong in an implies() conclusion`);
56
- }
57
65
  // Variables occurring outside a template are captured. Other variables
58
66
  // belong to that generated rule and are freshened by ordinary resolution.
59
67
  const outer = new Map();
@@ -64,7 +72,7 @@ export function expandTemplates(sources) {
64
72
  if (containsTemplate(head)) throw new EyemindError(`line ${source.line}: a rule template cannot be nested inside an ordinary term`);
65
73
  return head;
66
74
  }
67
- const forward = head.name === 'forward';
75
+ const forward = head.name === 'implies' || head.name === 'forward';
68
76
  const premise = normalizeGoal(head.args[forward ? 0 : 1]);
69
77
  const conclusion = normalizeGoal(head.args[forward ? 1 : 0]);
70
78
  for (const item of flattenConjunction(conclusion)) {
@@ -73,7 +81,7 @@ export function expandTemplates(sources) {
73
81
  }
74
82
  }
75
83
  const body = flattenConjunction(premise);
76
- for (const goal of body) checkGoal(goal, source.line);
84
+ for (const goal of body) checkGoal(goal, source.line, true);
77
85
  const captured = [...variables(head).values()].filter((variable) => outer.has(variable.name));
78
86
  const activation = new Struct('activated', [BigInt(expanded.length + 1), listFromItems(captured)]);
79
87
  const conclusions = forward ? [conclusion] : flattenConjunction(conclusion);
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // The release this checkout is; ./release keeps it in step with package.json.
2
- export const VERSION = '0.1.26';
2
+ export const VERSION = '0.1.27';