eyelang 1.3.3 → 1.3.5
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 +1 -1
- package/docs/guide.md +2 -0
- package/docs/language-reference.md +21 -1
- package/index.d.ts +9 -0
- package/package.json +1 -1
- package/src/program.js +173 -0
- package/test/run-regression.mjs +60 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://doi.org/10.5281/zenodo.20761726)
|
|
5
5
|
|
|
6
6
|
Eyelang is a small logic programming language for rules, goals, answers, and proofs.
|
|
7
|
-
Its source syntax is Prolog-like Horn-clause syntax with
|
|
7
|
+
Its source syntax is Prolog-like Horn-clause syntax with deliberate eyelang choices, including `?x` variables for N3/SPARQL-style readability, explicit `table(path, 2).` declarations for tabled predicates, advisory `mode/3` declarations for host tooling, and stratified-negation diagnostics for portable `not/1` usage.
|
|
8
8
|
It grew out of logic-language experiments in the EYE/N3 reasoning tradition, but is packaged here as its own project.
|
|
9
9
|
|
|
10
10
|
## Install and run
|
package/docs/guide.md
CHANGED
|
@@ -577,6 +577,8 @@ semidet(edge, 2).
|
|
|
577
577
|
|
|
578
578
|
For large programs, keep helper predicates selective, bind arguments early, document intended calling patterns with `mode/3` when helpful, and declare focused output predicates with `materialize/2` when default output would otherwise solve broad helper goals.
|
|
579
579
|
|
|
580
|
+
When using `not/1` over user-defined predicates, keep the dependency graph stratified: negative dependencies should not participate in recursion. The JavaScript API exposes `program.stratifiedNegation`, `program.negationStratificationErrors`, and `program.assertStratifiedNegation()` so host tools can warn or reject programs that rely on unstratified negation. The diagnostic is lazy by default; use `{ analyzeNegation: true }` to compute it during parsing or `{ strictNegation: true }` to compute and reject unstratified programs.
|
|
581
|
+
|
|
580
582
|
## Implementation limits
|
|
581
583
|
|
|
582
584
|
Eyelang is intentionally smaller than ISO Prolog. It has no operators, zero-arity compound syntax, cut, modules, dynamic database updates, DCGs, or complete ISO library. Arity-zero data is always written and read back as an atom, such as `nil`, never `nil()`. Negation is negation-as-failure through `not/1`. Search is goal-directed and expected to be finite for the selected output goals. Output explanations are non-normative proof printouts and do not change answer semantics.
|
|
@@ -353,6 +353,26 @@ Arithmetic and string built-ins do not introduce a separate semantic universe. T
|
|
|
353
353
|
|
|
354
354
|
Negation-as-failure `not(?goal)` is especially operational: it succeeds when the current goal-directed search finds no solution for `?goal`. It is not classical negation and should not be read as adding negative facts to the Herbrand model. Programs using negation SHOULD keep the negated goal sufficiently ground and finite.
|
|
355
355
|
|
|
356
|
+
### 8.5 Stratified negation
|
|
357
|
+
|
|
358
|
+
Portable programs using user-defined predicates under `not/1` SHOULD be **stratified**. A program is stratified when no predicate depends negatively on itself, either directly or through a cycle of other predicate dependencies. In a stratified program, predicates can be assigned strata so that positive dependencies stay in the same or a lower stratum and negative dependencies point strictly to a lower stratum.
|
|
359
|
+
|
|
360
|
+
For example, this is stratified because `open/1` depends negatively on `closed/1`, but `closed/1` does not depend back on `open/1`:
|
|
361
|
+
|
|
362
|
+
```eyelang
|
|
363
|
+
closed(?x) :- blocked(?x).
|
|
364
|
+
open(?x) :- candidate(?x), not(closed(?x)).
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
This is not stratified because `p/1` and `q/1` form a cycle that contains a negative dependency:
|
|
368
|
+
|
|
369
|
+
```eyelang
|
|
370
|
+
p(?x) :- q(?x).
|
|
371
|
+
q(?x) :- not(p(?x)).
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
The JavaScript implementation records stratification metadata on `Program` instances: `stratifiedNegation`, `negationStratificationErrors`, `negationDependencies`, and per-group `negationStratum`. This diagnostic is computed lazily when one of those properties or helper methods is first read, or eagerly when parsing with `{ analyzeNegation: true }` or `{ strictNegation: true }`. Embedders that want to reject non-portable negation can parse with `{ strictNegation: true }` or call `program.assertStratifiedNegation()`.
|
|
375
|
+
|
|
356
376
|
## 9. Standard built-in predicates
|
|
357
377
|
|
|
358
378
|
This section specifies the **standard built-ins** of the Eyelang language. An implementation that claims support for this standard built-in profile MUST implement the predicates in this section with the meanings described here.
|
|
@@ -494,7 +514,7 @@ The first goal can yield `holds((name(alice, "Alice"), knows(alice, bob)), name(
|
|
|
494
514
|
|
|
495
515
|
| Built-in | Meaning |
|
|
496
516
|
|---|---|
|
|
497
|
-
| `not(?goal)` | Negation as failure. Succeeds when `?goal` has no solution. |
|
|
517
|
+
| `not(?goal)` | Negation as failure. Succeeds when `?goal` has no solution. Portable user-defined negation should be stratified. |
|
|
498
518
|
| `once(?goal)` | Succeeds with at most the first solution of `?goal`. |
|
|
499
519
|
| `forall(?generator, ?test)` | Succeeds when every solution of `?generator` also satisfies `?test`; succeeds vacuously when `?generator` has no solutions. |
|
|
500
520
|
|
package/index.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface EyelangRunOptions {
|
|
|
11
11
|
registry?: BuiltinRegistry;
|
|
12
12
|
sourceMetadata?: boolean;
|
|
13
13
|
markRecursive?: boolean;
|
|
14
|
+
strictNegation?: boolean;
|
|
15
|
+
analyzeNegation?: boolean;
|
|
14
16
|
[key: string]: unknown;
|
|
15
17
|
}
|
|
16
18
|
|
|
@@ -43,6 +45,7 @@ export interface EyelangPredicateGroup {
|
|
|
43
45
|
mode: string[] | null;
|
|
44
46
|
determinism: 'det' | 'semidet' | null;
|
|
45
47
|
recursive: boolean;
|
|
48
|
+
negationStratum: number | null;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
export type EyelangTerm = Term | { type: string; name: string; args?: EyelangTerm[]; arity?: number };
|
|
@@ -70,6 +73,9 @@ export class Program {
|
|
|
70
73
|
groups: Map<string, EyelangPredicateGroup>;
|
|
71
74
|
materializedGroups: Set<string>;
|
|
72
75
|
hasMaterialize: boolean;
|
|
76
|
+
negationDependencies: Array<{ from: string; to: string; negative: boolean }>;
|
|
77
|
+
negationStratificationErrors: Array<{ from: string; to: string }>;
|
|
78
|
+
stratifiedNegation: boolean;
|
|
73
79
|
static parse(source: string, options?: EyelangRunOptions): Program;
|
|
74
80
|
static parseSources(sources?: Array<string | EyelangSourcePart>, options?: EyelangRunOptions): Program;
|
|
75
81
|
makeGroup(name: string, arity: number): EyelangPredicateGroup;
|
|
@@ -77,6 +83,9 @@ export class Program {
|
|
|
77
83
|
findGroup(name: string, arity: number): EyelangPredicateGroup | null;
|
|
78
84
|
applyDeclarations(options?: EyelangRunOptions): void;
|
|
79
85
|
markRecursivePredicates(): void;
|
|
86
|
+
analyzeNegationStratification(): Array<{ from: string; to: string }>;
|
|
87
|
+
assertStratifiedNegation(): true;
|
|
88
|
+
isStratifiedNegation(): boolean;
|
|
80
89
|
hasMaterializeDeclarations(): boolean;
|
|
81
90
|
groupIsMaterialized(group: EyelangPredicateGroup): boolean;
|
|
82
91
|
groupHasRule(group: EyelangPredicateGroup): boolean;
|
package/package.json
CHANGED
package/src/program.js
CHANGED
|
@@ -14,6 +14,7 @@ export class Program {
|
|
|
14
14
|
clause.index = index;
|
|
15
15
|
this.indexClause(clause);
|
|
16
16
|
}
|
|
17
|
+
this._negationAnalysis = null;
|
|
17
18
|
this.applyDeclarations(options);
|
|
18
19
|
}
|
|
19
20
|
static parse(source, options = {}) {
|
|
@@ -43,6 +44,7 @@ export class Program {
|
|
|
43
44
|
mode: null,
|
|
44
45
|
determinism: null,
|
|
45
46
|
recursive: false,
|
|
47
|
+
negationStratum: null,
|
|
46
48
|
};
|
|
47
49
|
if (arity > 2) {
|
|
48
50
|
for (let left = 0; left < arity; left++) {
|
|
@@ -100,6 +102,8 @@ export class Program {
|
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
if (options.markRecursive !== false) this.markRecursivePredicates();
|
|
105
|
+
if (options.analyzeNegation === true || options.strictNegation === true) this.analyzeNegationStratification();
|
|
106
|
+
if (options.strictNegation === true) this.assertStratifiedNegation();
|
|
103
107
|
}
|
|
104
108
|
markRecursivePredicates() {
|
|
105
109
|
// Recursion is a group-level diagnostic hint. It is computed from predicate
|
|
@@ -136,6 +140,89 @@ export class Program {
|
|
|
136
140
|
group.recursive = recursive;
|
|
137
141
|
}
|
|
138
142
|
}
|
|
143
|
+
|
|
144
|
+
analyzeNegationStratification() {
|
|
145
|
+
// Stratified negation is a portability diagnostic. A program is stratified
|
|
146
|
+
// when no predicate depends negatively on itself, directly or indirectly.
|
|
147
|
+
const groups = [...this.groups.values()];
|
|
148
|
+
const groupKeys = new Map(groups.map((group) => [group, `${group.name}/${group.arity}`]));
|
|
149
|
+
const groupByKey = new Map(groups.map((group) => [`${group.name}/${group.arity}`, group]));
|
|
150
|
+
const indexByKey = new Map(groups.map((group, i) => [`${group.name}/${group.arity}`, i]));
|
|
151
|
+
const edges = [];
|
|
152
|
+
|
|
153
|
+
for (const group of groups) {
|
|
154
|
+
const from = groupKeys.get(group);
|
|
155
|
+
for (const clause of group.clauses) {
|
|
156
|
+
for (const goal of clause.body) {
|
|
157
|
+
for (const dep of collectGoalDependencies(goal, false)) {
|
|
158
|
+
if (!groupByKey.has(dep.key)) continue;
|
|
159
|
+
edges.push({ from, to: dep.key, negative: dep.negative });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const adjacency = groups.map(() => []);
|
|
166
|
+
for (const edge of edges) {
|
|
167
|
+
const from = indexByKey.get(edge.from);
|
|
168
|
+
const to = indexByKey.get(edge.to);
|
|
169
|
+
if (from == null || to == null) continue;
|
|
170
|
+
adjacency[from].push(to);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const sccs = stronglyConnectedComponents(adjacency);
|
|
174
|
+
const componentByIndex = new Map();
|
|
175
|
+
for (let component = 0; component < sccs.length; component++) {
|
|
176
|
+
for (const index of sccs[component]) componentByIndex.set(index, component);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const violations = [];
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
for (const edge of edges) {
|
|
182
|
+
if (!edge.negative) continue;
|
|
183
|
+
const from = indexByKey.get(edge.from);
|
|
184
|
+
const to = indexByKey.get(edge.to);
|
|
185
|
+
if (from == null || to == null) continue;
|
|
186
|
+
if (componentByIndex.get(from) !== componentByIndex.get(to)) continue;
|
|
187
|
+
const key = `${edge.from}->${edge.to}`;
|
|
188
|
+
if (seen.has(key)) continue;
|
|
189
|
+
seen.add(key);
|
|
190
|
+
violations.push({ from: edge.from, to: edge.to });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const strata = computeNegationStrata(groups, edges, indexByKey);
|
|
194
|
+
for (const group of groups) group.negationStratum = strata.get(groupKeys.get(group)) ?? null;
|
|
195
|
+
|
|
196
|
+
this._negationAnalysis = {
|
|
197
|
+
dependencies: edges,
|
|
198
|
+
errors: violations,
|
|
199
|
+
stratified: violations.length === 0,
|
|
200
|
+
};
|
|
201
|
+
return violations;
|
|
202
|
+
}
|
|
203
|
+
ensureNegationStratification() {
|
|
204
|
+
if (!this._negationAnalysis) this.analyzeNegationStratification();
|
|
205
|
+
return this._negationAnalysis;
|
|
206
|
+
}
|
|
207
|
+
get negationDependencies() {
|
|
208
|
+
return this.ensureNegationStratification().dependencies;
|
|
209
|
+
}
|
|
210
|
+
get negationStratificationErrors() {
|
|
211
|
+
return this.ensureNegationStratification().errors;
|
|
212
|
+
}
|
|
213
|
+
get stratifiedNegation() {
|
|
214
|
+
return this.ensureNegationStratification().stratified;
|
|
215
|
+
}
|
|
216
|
+
assertStratifiedNegation() {
|
|
217
|
+
const violations = this.ensureNegationStratification().errors;
|
|
218
|
+
if (violations.length === 0) return true;
|
|
219
|
+
const details = violations.map((edge) => `${edge.from} depends negatively on ${edge.to}`).join('; ');
|
|
220
|
+
throw new Error(`unstratified negation: ${details}`);
|
|
221
|
+
}
|
|
222
|
+
isStratifiedNegation() {
|
|
223
|
+
return this.ensureNegationStratification().stratified;
|
|
224
|
+
}
|
|
225
|
+
|
|
139
226
|
hasMaterializeDeclarations() {
|
|
140
227
|
return this.hasMaterialize;
|
|
141
228
|
}
|
|
@@ -177,6 +264,92 @@ export class Program {
|
|
|
177
264
|
}
|
|
178
265
|
|
|
179
266
|
|
|
267
|
+
|
|
268
|
+
function collectGoalDependencies(goal, negated) {
|
|
269
|
+
if (goal.type !== COMPOUND) return [];
|
|
270
|
+
if (goal.name === ',' && goal.arity === 2) {
|
|
271
|
+
return [
|
|
272
|
+
...collectGoalDependencies(goal.args[0], negated),
|
|
273
|
+
...collectGoalDependencies(goal.args[1], negated),
|
|
274
|
+
];
|
|
275
|
+
}
|
|
276
|
+
if (goal.name === 'not' && goal.arity === 1) {
|
|
277
|
+
return collectGoalDependencies(goal.args[0], !negated);
|
|
278
|
+
}
|
|
279
|
+
if (goal.name === 'once' && goal.arity === 1) {
|
|
280
|
+
return collectGoalDependencies(goal.args[0], negated);
|
|
281
|
+
}
|
|
282
|
+
if (goal.name === 'forall' && goal.arity === 2) {
|
|
283
|
+
return [
|
|
284
|
+
...collectGoalDependencies(goal.args[0], negated),
|
|
285
|
+
...collectGoalDependencies(goal.args[1], negated),
|
|
286
|
+
];
|
|
287
|
+
}
|
|
288
|
+
return [{ key: `${goal.name}/${goal.arity}`, negative: negated }];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function stronglyConnectedComponents(adjacency) {
|
|
292
|
+
let index = 0;
|
|
293
|
+
const stack = [];
|
|
294
|
+
const onStack = new Set();
|
|
295
|
+
const indexes = new Map();
|
|
296
|
+
const lowlinks = new Map();
|
|
297
|
+
const components = [];
|
|
298
|
+
|
|
299
|
+
function visit(v) {
|
|
300
|
+
indexes.set(v, index);
|
|
301
|
+
lowlinks.set(v, index);
|
|
302
|
+
index++;
|
|
303
|
+
stack.push(v);
|
|
304
|
+
onStack.add(v);
|
|
305
|
+
|
|
306
|
+
for (const w of adjacency[v]) {
|
|
307
|
+
if (!indexes.has(w)) {
|
|
308
|
+
visit(w);
|
|
309
|
+
lowlinks.set(v, Math.min(lowlinks.get(v), lowlinks.get(w)));
|
|
310
|
+
} else if (onStack.has(w)) {
|
|
311
|
+
lowlinks.set(v, Math.min(lowlinks.get(v), indexes.get(w)));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (lowlinks.get(v) === indexes.get(v)) {
|
|
316
|
+
const component = [];
|
|
317
|
+
while (true) {
|
|
318
|
+
const w = stack.pop();
|
|
319
|
+
onStack.delete(w);
|
|
320
|
+
component.push(w);
|
|
321
|
+
if (w === v) break;
|
|
322
|
+
}
|
|
323
|
+
components.push(component);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
for (let v = 0; v < adjacency.length; v++) {
|
|
328
|
+
if (!indexes.has(v)) visit(v);
|
|
329
|
+
}
|
|
330
|
+
return components;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function computeNegationStrata(groups, edges, indexByKey) {
|
|
334
|
+
const strata = new Map(groups.map((group) => [`${group.name}/${group.arity}`, 0]));
|
|
335
|
+
if (groups.length === 0) return strata;
|
|
336
|
+
|
|
337
|
+
for (let pass = 0; pass < groups.length; pass++) {
|
|
338
|
+
let changed = false;
|
|
339
|
+
for (const edge of edges) {
|
|
340
|
+
if (!indexByKey.has(edge.from) || !indexByKey.has(edge.to)) continue;
|
|
341
|
+
const fromStratum = strata.get(edge.from) ?? 0;
|
|
342
|
+
const required = (strata.get(edge.to) ?? 0) + (edge.negative ? 1 : 0);
|
|
343
|
+
if (fromStratum < required) {
|
|
344
|
+
strata.set(edge.from, required);
|
|
345
|
+
changed = true;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (!changed) return strata;
|
|
349
|
+
}
|
|
350
|
+
return new Map(groups.map((group) => [`${group.name}/${group.arity}`, null]));
|
|
351
|
+
}
|
|
352
|
+
|
|
180
353
|
function declarationIndicator(name, arity) {
|
|
181
354
|
if (name?.type !== ATOM || arity?.type !== 'number') return null;
|
|
182
355
|
if (!/^\d+$/.test(arity.name)) return null;
|
package/test/run-regression.mjs
CHANGED
|
@@ -396,6 +396,66 @@ function apiCases() {
|
|
|
396
396
|
assertEqual(group.arity, 2, 'group arity');
|
|
397
397
|
},
|
|
398
398
|
},
|
|
399
|
+
{
|
|
400
|
+
name: 'program keeps negation diagnostics lazy by default',
|
|
401
|
+
run: () => {
|
|
402
|
+
const program = Program.parse('p(a).\nq(?x) :- not(p(?x)).\n');
|
|
403
|
+
assertEqual(program._negationAnalysis, null, 'analysis starts lazy');
|
|
404
|
+
assertEqual(program.negationDependencies.length, 1, 'dependency count');
|
|
405
|
+
assertEqual(program._negationAnalysis !== null, true, 'analysis computed on demand');
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
name: 'analyzeNegation option computes diagnostics eagerly',
|
|
410
|
+
run: () => {
|
|
411
|
+
const program = Program.parse('p(a).\nq(?x) :- not(p(?x)).\n', { analyzeNegation: true });
|
|
412
|
+
assertEqual(program._negationAnalysis !== null, true, 'analysis computed eagerly');
|
|
413
|
+
assertEqual(program.stratifiedNegation, true, 'stratified negation');
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: 'program reports stratified negation metadata',
|
|
418
|
+
run: () => {
|
|
419
|
+
const program = Program.parse(`
|
|
420
|
+
materialize(open, 1).
|
|
421
|
+
candidate(a).
|
|
422
|
+
blocked(b).
|
|
423
|
+
closed(?x) :- blocked(?x).
|
|
424
|
+
open(?x) :- candidate(?x), not(closed(?x)).
|
|
425
|
+
`);
|
|
426
|
+
assertEqual(program.isStratifiedNegation(), true, 'stratified negation');
|
|
427
|
+
assertEqual(program.negationStratificationErrors.length, 0, 'stratification errors');
|
|
428
|
+
assertEqual(program.findGroup('closed', 1).negationStratum, 0, 'closed stratum');
|
|
429
|
+
assertEqual(program.findGroup('open', 1).negationStratum, 1, 'open stratum');
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
name: 'program detects unstratified negation cycles',
|
|
434
|
+
run: () => {
|
|
435
|
+
const program = Program.parse('p(?x) :- q(?x).\nq(?x) :- not(p(?x)).\n');
|
|
436
|
+
assertEqual(program.isStratifiedNegation(), false, 'unstratified negation');
|
|
437
|
+
assertEqual(program.negationStratificationErrors.length, 1, 'stratification error count');
|
|
438
|
+
assertEqual(program.negationStratificationErrors[0].from, 'q/1', 'error source');
|
|
439
|
+
assertEqual(program.negationStratificationErrors[0].to, 'p/1', 'error target');
|
|
440
|
+
let threw = false;
|
|
441
|
+
try { program.assertStratifiedNegation(); } catch (err) {
|
|
442
|
+
threw = true;
|
|
443
|
+
assertIncludes(err.message, 'unstratified negation', 'error message');
|
|
444
|
+
}
|
|
445
|
+
assertEqual(threw, true, 'assertion throws');
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
name: 'strictNegation option rejects unstratified programs',
|
|
450
|
+
run: () => {
|
|
451
|
+
let threw = false;
|
|
452
|
+
try { Program.parse('p(?x) :- not(p(?x)).\n', { strictNegation: true }); } catch (err) {
|
|
453
|
+
threw = true;
|
|
454
|
+
assertIncludes(err.message, 'p/1 depends negatively on p/1', 'error message');
|
|
455
|
+
}
|
|
456
|
+
assertEqual(threw, true, 'strict negation throws');
|
|
457
|
+
},
|
|
458
|
+
},
|
|
399
459
|
{
|
|
400
460
|
name: 'program and solver public classes',
|
|
401
461
|
run: () => {
|