eyelang 1.3.2 → 1.3.4

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
@@ -4,7 +4,7 @@
4
4
  [![DOI](https://img.shields.io/badge/DOI-10.5281%2Fzenodo.20761726-blue.svg)](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 a few deliberate eyelang choices, such as `?x` variables for N3/SPARQL-style readability, explicit `table(path, 2).` declarations for tabled predicates, and advisory `mode/3` declarations for host tooling.
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.
581
+
580
582
  ## Implementation limits
581
583
 
582
- 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. 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.
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.
@@ -222,7 +222,7 @@ Arity-zero data is written as an atom constant, not as a zero-arity compound:
222
222
  value(example, nil).
223
223
  ```
224
224
 
225
- The syntax `nil()` is intentionally rejected so eyelang source and read-back output use one representation for arity-zero data.
225
+ The syntax `nil()` is intentionally rejected so eyelang source and read-back output use one representation for arity-zero data. Host APIs SHOULD follow the same rule: constructing a term with an atom name and an empty argument list is canonicalized to the atom constant itself.
226
226
 
227
227
  ## 5. Terms
228
228
 
@@ -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`. 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.
@@ -473,7 +493,7 @@ Context terms are data representations of atomic formulas and comma conjunctions
473
493
  | `holds(?context, ?name, ?args)` | Enumerates context members of any arity, exposing each member as atom constant `?name` plus a proper argument list `?args`. |
474
494
  | `functor(?term, ?name, ?arity)` | Decomposes a non-variable term into its name and arity. |
475
495
  | `arg(?index, ?term, ?arg)` | Extracts the 1-based argument of a compound term. |
476
- | `compound_name_arguments(?term, ?name, ?args)` | Decomposes a compound term or constructs one from an atom name and proper argument list. |
496
+ | `compound_name_arguments(?term, ?name, ?args)` | Decomposes a compound term, treats an atom as a zero-argument term, or constructs a term from an atom name and proper argument list. Empty `?args` constructs an atom. |
477
497
 
478
498
  Example:
479
499
 
@@ -483,6 +503,7 @@ holds((ready, name(alice, "Alice"), route(alice, bob, 7)), ?name, ?args).
483
503
  functor(route(alice, bob, 7), route, 3).
484
504
  arg(2, route(alice, bob, 7), bob).
485
505
  compound_name_arguments(?term, route, [alice, bob, 7]).
506
+ compound_name_arguments(nil, nil, []).
486
507
  ```
487
508
 
488
509
  The first goal can yield `holds((name(alice, "Alice"), knows(alice, bob)), name(alice, "Alice")).` The second can yield `holds((ready, name(alice, "Alice"), route(alice, bob, 7)), ready, []).`, `holds((ready, name(alice, "Alice"), route(alice, bob, 7)), name, [alice, "Alice"]).`, and `holds((ready, name(alice, "Alice"), route(alice, bob, 7)), route, [alice, bob, 7]).`
@@ -493,7 +514,7 @@ The first goal can yield `holds((name(alice, "Alice"), knows(alice, bob)), name(
493
514
 
494
515
  | Built-in | Meaning |
495
516
  |---|---|
496
- | `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. |
497
518
  | `once(?goal)` | Succeeds with at most the first solution of `?goal`. |
498
519
  | `forall(?generator, ?test)` | Succeeds when every solution of `?generator` also satisfies `?test`; succeeds vacuously when `?generator` has no solutions. |
499
520
 
package/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export interface EyelangRunOptions {
11
11
  registry?: BuiltinRegistry;
12
12
  sourceMetadata?: boolean;
13
13
  markRecursive?: boolean;
14
+ strictNegation?: boolean;
14
15
  [key: string]: unknown;
15
16
  }
16
17
 
@@ -43,6 +44,7 @@ export interface EyelangPredicateGroup {
43
44
  mode: string[] | null;
44
45
  determinism: 'det' | 'semidet' | null;
45
46
  recursive: boolean;
47
+ negationStratum: number | null;
46
48
  }
47
49
 
48
50
  export type EyelangTerm = Term | { type: string; name: string; args?: EyelangTerm[]; arity?: number };
@@ -70,6 +72,9 @@ export class Program {
70
72
  groups: Map<string, EyelangPredicateGroup>;
71
73
  materializedGroups: Set<string>;
72
74
  hasMaterialize: boolean;
75
+ negationDependencies: Array<{ from: string; to: string; negative: boolean }>;
76
+ negationStratificationErrors: Array<{ from: string; to: string }>;
77
+ stratifiedNegation: boolean;
73
78
  static parse(source: string, options?: EyelangRunOptions): Program;
74
79
  static parseSources(sources?: Array<string | EyelangSourcePart>, options?: EyelangRunOptions): Program;
75
80
  makeGroup(name: string, arity: number): EyelangPredicateGroup;
@@ -77,6 +82,9 @@ export class Program {
77
82
  findGroup(name: string, arity: number): EyelangPredicateGroup | null;
78
83
  applyDeclarations(options?: EyelangRunOptions): void;
79
84
  markRecursivePredicates(): void;
85
+ analyzeNegationStratification(): Array<{ from: string; to: string }>;
86
+ assertStratifiedNegation(): true;
87
+ isStratifiedNegation(): boolean;
80
88
  hasMaterializeDeclarations(): boolean;
81
89
  groupIsMaterialized(group: EyelangPredicateGroup): boolean;
82
90
  groupHasRule(group: EyelangPredicateGroup): boolean;
@@ -128,6 +136,7 @@ export function variable(name: string): Term;
128
136
  export function atom(name: string): Term;
129
137
  export function stringTerm(value: string): Term;
130
138
  export function numberTerm(value: string | number): Term;
139
+ /** Construct a compound term; an empty argument list is canonicalized to atom(name). */
131
140
  export function compound(name: string, args?: EyelangTerm[]): Term;
132
141
  export function emptyList(): Term;
133
142
  export function cons(head: EyelangTerm, tail: EyelangTerm): Term;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyelang",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "description": "A small Prolog-like logic programming language for rules, goals, answers, and proofs.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -20,7 +20,7 @@ function argReady(goal, env) {
20
20
 
21
21
  function compoundNameArgumentsReady(goal, env) {
22
22
  const term = deref(goal.args[0], env);
23
- if (term.type === 'compound') return true;
23
+ if (term.type === 'compound' || term.type === 'atom') return true;
24
24
  return term.type === 'var' && lexicalValue(goal.args[1], env) !== null && properListItems(goal.args[2], env) !== null;
25
25
  }
26
26
 
@@ -45,9 +45,10 @@ function* argBuiltin({ goal, env }) {
45
45
 
46
46
  function* compoundNameArguments({ goal, env }) {
47
47
  const term = deref(goal.args[0], env);
48
- if (term.type === 'compound') {
48
+ if (term.type === 'compound' || term.type === 'atom') {
49
49
  const next = env.clone();
50
- if (unify(goal.args[1], atom(term.name), next) && unify(goal.args[2], listFromItems(term.args), next)) yield next;
50
+ const args = term.type === 'compound' ? term.args : [];
51
+ if (unify(goal.args[1], atom(term.name), next) && unify(goal.args[2], listFromItems(args), next)) yield next;
51
52
  return;
52
53
  }
53
54
  if (term.type !== 'var') return;
package/src/program.js CHANGED
@@ -43,6 +43,7 @@ export class Program {
43
43
  mode: null,
44
44
  determinism: null,
45
45
  recursive: false,
46
+ negationStratum: null,
46
47
  };
47
48
  if (arity > 2) {
48
49
  for (let left = 0; left < arity; left++) {
@@ -100,6 +101,8 @@ export class Program {
100
101
  }
101
102
  }
102
103
  if (options.markRecursive !== false) this.markRecursivePredicates();
104
+ this.analyzeNegationStratification();
105
+ if (options.strictNegation === true) this.assertStratifiedNegation();
103
106
  }
104
107
  markRecursivePredicates() {
105
108
  // Recursion is a group-level diagnostic hint. It is computed from predicate
@@ -136,6 +139,74 @@ export class Program {
136
139
  group.recursive = recursive;
137
140
  }
138
141
  }
142
+
143
+ analyzeNegationStratification() {
144
+ // Stratified negation is a portability diagnostic. A program is stratified
145
+ // when no predicate depends negatively on itself, directly or indirectly.
146
+ const groups = [...this.groups.values()];
147
+ const groupKeys = new Map(groups.map((group) => [group, `${group.name}/${group.arity}`]));
148
+ const groupByKey = new Map(groups.map((group) => [`${group.name}/${group.arity}`, group]));
149
+ const indexByKey = new Map(groups.map((group, i) => [`${group.name}/${group.arity}`, i]));
150
+ const edges = [];
151
+
152
+ for (const group of groups) {
153
+ const from = groupKeys.get(group);
154
+ for (const clause of group.clauses) {
155
+ for (const goal of clause.body) {
156
+ for (const dep of collectGoalDependencies(goal, false)) {
157
+ if (!groupByKey.has(dep.key)) continue;
158
+ edges.push({ from, to: dep.key, negative: dep.negative });
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ const adjacency = groups.map(() => []);
165
+ for (const edge of edges) {
166
+ const from = indexByKey.get(edge.from);
167
+ const to = indexByKey.get(edge.to);
168
+ if (from == null || to == null) continue;
169
+ adjacency[from].push(to);
170
+ }
171
+
172
+ const sccs = stronglyConnectedComponents(adjacency);
173
+ const componentByIndex = new Map();
174
+ for (let component = 0; component < sccs.length; component++) {
175
+ for (const index of sccs[component]) componentByIndex.set(index, component);
176
+ }
177
+
178
+ const violations = [];
179
+ const seen = new Set();
180
+ for (const edge of edges) {
181
+ if (!edge.negative) continue;
182
+ const from = indexByKey.get(edge.from);
183
+ const to = indexByKey.get(edge.to);
184
+ if (from == null || to == null) continue;
185
+ if (componentByIndex.get(from) !== componentByIndex.get(to)) continue;
186
+ const key = `${edge.from}->${edge.to}`;
187
+ if (seen.has(key)) continue;
188
+ seen.add(key);
189
+ violations.push({ from: edge.from, to: edge.to });
190
+ }
191
+
192
+ const strata = computeNegationStrata(groups, edges, indexByKey);
193
+ for (const group of groups) group.negationStratum = strata.get(groupKeys.get(group)) ?? null;
194
+
195
+ this.negationDependencies = edges;
196
+ this.negationStratificationErrors = violations;
197
+ this.stratifiedNegation = violations.length === 0;
198
+ return violations;
199
+ }
200
+ assertStratifiedNegation() {
201
+ const violations = this.negationStratificationErrors ?? this.analyzeNegationStratification();
202
+ if (violations.length === 0) return true;
203
+ const details = violations.map((edge) => `${edge.from} depends negatively on ${edge.to}`).join('; ');
204
+ throw new Error(`unstratified negation: ${details}`);
205
+ }
206
+ isStratifiedNegation() {
207
+ return this.stratifiedNegation !== false;
208
+ }
209
+
139
210
  hasMaterializeDeclarations() {
140
211
  return this.hasMaterialize;
141
212
  }
@@ -177,6 +248,92 @@ export class Program {
177
248
  }
178
249
 
179
250
 
251
+
252
+ function collectGoalDependencies(goal, negated) {
253
+ if (goal.type !== COMPOUND) return [];
254
+ if (goal.name === ',' && goal.arity === 2) {
255
+ return [
256
+ ...collectGoalDependencies(goal.args[0], negated),
257
+ ...collectGoalDependencies(goal.args[1], negated),
258
+ ];
259
+ }
260
+ if (goal.name === 'not' && goal.arity === 1) {
261
+ return collectGoalDependencies(goal.args[0], !negated);
262
+ }
263
+ if (goal.name === 'once' && goal.arity === 1) {
264
+ return collectGoalDependencies(goal.args[0], negated);
265
+ }
266
+ if (goal.name === 'forall' && goal.arity === 2) {
267
+ return [
268
+ ...collectGoalDependencies(goal.args[0], negated),
269
+ ...collectGoalDependencies(goal.args[1], negated),
270
+ ];
271
+ }
272
+ return [{ key: `${goal.name}/${goal.arity}`, negative: negated }];
273
+ }
274
+
275
+ function stronglyConnectedComponents(adjacency) {
276
+ let index = 0;
277
+ const stack = [];
278
+ const onStack = new Set();
279
+ const indexes = new Map();
280
+ const lowlinks = new Map();
281
+ const components = [];
282
+
283
+ function visit(v) {
284
+ indexes.set(v, index);
285
+ lowlinks.set(v, index);
286
+ index++;
287
+ stack.push(v);
288
+ onStack.add(v);
289
+
290
+ for (const w of adjacency[v]) {
291
+ if (!indexes.has(w)) {
292
+ visit(w);
293
+ lowlinks.set(v, Math.min(lowlinks.get(v), lowlinks.get(w)));
294
+ } else if (onStack.has(w)) {
295
+ lowlinks.set(v, Math.min(lowlinks.get(v), indexes.get(w)));
296
+ }
297
+ }
298
+
299
+ if (lowlinks.get(v) === indexes.get(v)) {
300
+ const component = [];
301
+ while (true) {
302
+ const w = stack.pop();
303
+ onStack.delete(w);
304
+ component.push(w);
305
+ if (w === v) break;
306
+ }
307
+ components.push(component);
308
+ }
309
+ }
310
+
311
+ for (let v = 0; v < adjacency.length; v++) {
312
+ if (!indexes.has(v)) visit(v);
313
+ }
314
+ return components;
315
+ }
316
+
317
+ function computeNegationStrata(groups, edges, indexByKey) {
318
+ const strata = new Map(groups.map((group) => [`${group.name}/${group.arity}`, 0]));
319
+ if (groups.length === 0) return strata;
320
+
321
+ for (let pass = 0; pass < groups.length; pass++) {
322
+ let changed = false;
323
+ for (const edge of edges) {
324
+ if (!indexByKey.has(edge.from) || !indexByKey.has(edge.to)) continue;
325
+ const fromStratum = strata.get(edge.from) ?? 0;
326
+ const required = (strata.get(edge.to) ?? 0) + (edge.negative ? 1 : 0);
327
+ if (fromStratum < required) {
328
+ strata.set(edge.from, required);
329
+ changed = true;
330
+ }
331
+ }
332
+ if (!changed) return strata;
333
+ }
334
+ return new Map(groups.map((group) => [`${group.name}/${group.arity}`, null]));
335
+ }
336
+
180
337
  function declarationIndicator(name, arity) {
181
338
  if (name?.type !== ATOM || arity?.type !== 'number') return null;
182
339
  if (!/^\d+$/.test(arity.name)) return null;
package/src/term.js CHANGED
@@ -21,7 +21,7 @@ export const variable = (name) => new Term(VAR, name);
21
21
  export const atom = (name) => new Term(ATOM, name);
22
22
  export const stringTerm = (value) => new Term(STRING, value);
23
23
  export const numberTerm = (value) => new Term(NUMBER, value);
24
- export const compound = (name, args = []) => new Term(COMPOUND, name, args);
24
+ export const compound = (name, args = []) => args.length === 0 ? atom(name) : new Term(COMPOUND, name, args);
25
25
  export const emptyList = () => atom('[]');
26
26
  export const cons = (head, tail) => compound('.', [head, tail]);
27
27
 
@@ -113,17 +113,20 @@ export function unify(left, right, env) {
113
113
  }
114
114
 
115
115
  export function cloneTerm(term) {
116
+ if (term.type === COMPOUND && term.arity === 0) return atom(term.name);
116
117
  return new Term(term.type, term.name, term.args.map(cloneTerm));
117
118
  }
118
119
 
119
120
  export function freshTerm(term, suffix) {
120
121
  if (term.type === VAR) return variable(`${term.name}#${suffix}`);
122
+ if (term.type === COMPOUND && term.arity === 0) return atom(term.name);
121
123
  return new Term(term.type, term.name, term.args.map((arg) => freshTerm(arg, suffix)));
122
124
  }
123
125
 
124
126
  export function copyResolved(term, env) {
125
127
  const resolved = deref(term, env);
126
128
  if (resolved.type === VAR) return variable(resolved.name);
129
+ if (resolved.type === COMPOUND && resolved.arity === 0) return atom(resolved.name);
127
130
  return new Term(resolved.type, resolved.name, resolved.args.map((arg) => copyResolved(arg, env)));
128
131
  }
129
132
 
@@ -6,5 +6,6 @@ answer(functor_string, pair(?name, ?arity)) :- functor("hi", ?name, ?arity).
6
6
  answer(arg_nested, ?x) :- arg(1, path(edge(a, b), c), ?x).
7
7
  answer(compose_nested, ?x) :- compound_name_arguments(?x, outer, [inner(a), [b, c]]).
8
8
  answer(compose_atom_empty_args, ?x) :- compound_name_arguments(?x, z, []).
9
+ answer(decompose_atom_empty_args, pair(?name, ?args)) :- compound_name_arguments(z, ?name, ?args).
9
10
  answer(arg_zero_rejected, ok) :- not(arg(0, edge(a, b), ?x)).
10
11
  answer(arg_too_large_rejected, ok) :- not(arg(3, edge(a, b), ?x)).
@@ -4,5 +4,6 @@ answer(functor_string, pair("hi", 0)).
4
4
  answer(arg_nested, edge(a, b)).
5
5
  answer(compose_nested, outer(inner(a), [b, c])).
6
6
  answer(compose_atom_empty_args, z).
7
+ answer(decompose_atom_empty_args, pair(z, [])).
7
8
  answer(arg_zero_rejected, ok).
8
9
  answer(arg_too_large_rejected, ok).
@@ -337,6 +337,18 @@ function apiCases() {
337
337
  },
338
338
  },
339
339
 
340
+ {
341
+ name: 'compound factory canonicalizes zero arity to atoms',
342
+ run: () => {
343
+ const nil = compound('nil', []);
344
+ assertEqual(nil.type, 'atom', 'type');
345
+ assertEqual(nil.name, 'nil', 'name');
346
+ assertEqual(nil.arity, 0, 'arity');
347
+ assertEqual(termToString(nil, new Env(), true), 'nil', 'readback');
348
+ assertEqual(unify(nil, atom('nil'), new Env()), true, 'unifies with atom');
349
+ },
350
+ },
351
+
340
352
  {
341
353
  name: 'portable hash helpers match standard vectors',
342
354
  run: () => {
@@ -384,6 +396,49 @@ function apiCases() {
384
396
  assertEqual(group.arity, 2, 'group arity');
385
397
  },
386
398
  },
399
+ {
400
+ name: 'program reports stratified negation metadata',
401
+ run: () => {
402
+ const program = Program.parse(`
403
+ materialize(open, 1).
404
+ candidate(a).
405
+ blocked(b).
406
+ closed(?x) :- blocked(?x).
407
+ open(?x) :- candidate(?x), not(closed(?x)).
408
+ `);
409
+ assertEqual(program.isStratifiedNegation(), true, 'stratified negation');
410
+ assertEqual(program.negationStratificationErrors.length, 0, 'stratification errors');
411
+ assertEqual(program.findGroup('closed', 1).negationStratum, 0, 'closed stratum');
412
+ assertEqual(program.findGroup('open', 1).negationStratum, 1, 'open stratum');
413
+ },
414
+ },
415
+ {
416
+ name: 'program detects unstratified negation cycles',
417
+ run: () => {
418
+ const program = Program.parse('p(?x) :- q(?x).\nq(?x) :- not(p(?x)).\n');
419
+ assertEqual(program.isStratifiedNegation(), false, 'unstratified negation');
420
+ assertEqual(program.negationStratificationErrors.length, 1, 'stratification error count');
421
+ assertEqual(program.negationStratificationErrors[0].from, 'q/1', 'error source');
422
+ assertEqual(program.negationStratificationErrors[0].to, 'p/1', 'error target');
423
+ let threw = false;
424
+ try { program.assertStratifiedNegation(); } catch (err) {
425
+ threw = true;
426
+ assertIncludes(err.message, 'unstratified negation', 'error message');
427
+ }
428
+ assertEqual(threw, true, 'assertion throws');
429
+ },
430
+ },
431
+ {
432
+ name: 'strictNegation option rejects unstratified programs',
433
+ run: () => {
434
+ let threw = false;
435
+ try { Program.parse('p(?x) :- not(p(?x)).\n', { strictNegation: true }); } catch (err) {
436
+ threw = true;
437
+ assertIncludes(err.message, 'p/1 depends negatively on p/1', 'error message');
438
+ }
439
+ assertEqual(threw, true, 'strict negation throws');
440
+ },
441
+ },
387
442
  {
388
443
  name: 'program and solver public classes',
389
444
  run: () => {