eyeprolog 1.5.81 → 1.5.83

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.
@@ -7,7 +7,7 @@ when this report is generated; it is not inferred from fixture counts.
7
7
  ## Latest Neumerkel evidence
8
8
 
9
9
  See the tracked [latest Neumerkel conformity report](test/conformance/NEUMERKEL-LATEST.md).
10
- `npm test` fetches all seven TU Wien sources once and executes the discovered inventory.
10
+ `npm test` fetches all eight TU Wien sources once and executes the discovered inventory.
11
11
  The release workflow then synchronizes this tracked report from those exact successful
12
12
  cached source bytes, avoiding a second live fetch and its race window.
13
13
 
@@ -0,0 +1,120 @@
1
+ % Defeasible reasoning: an employee travel-expense reimbursement policy.
2
+ %
3
+ % Defeasible rules state a default and let a more specific rule override it.
4
+ % Most of that is ordinary, stratified negation as failure (7.3, \+/1): compute
5
+ % the exception from facts, then let the default rule negate the exception.
6
+ % Nothing here needs the well-founded semantics (WFS) yet.
7
+ %
8
+ % The last section is different on purpose. It models two independent
9
+ % reimbursement policies that can both claim the same expense with no company
10
+ % rule saying which one wins — team_offsite_hotel qualifies for both the flat
11
+ % per-diem blanket allowance and itemized reimbursement, and neither policy
12
+ % was written anticipating the other. Three ways to handle that:
13
+ %
14
+ % 1) Plain \+/1, each side negating the other. That is an unstratified
15
+ % negation cycle: proving either side requires failing the other, which
16
+ % requires proving the first again. Try it (swap tnot/1 for \+/1 below
17
+ % and run with --warnings): the engine loops until it runs out of stack.
18
+ % 2) tnot/1 and wfs_truth/2. EyeProlog evaluates the same cycle under WFS
19
+ % and reports both sides `undefined` instead of picking one arbitrarily
20
+ % — honest, since the policies themselves do not resolve the conflict,
21
+ % but callers now have to know to ask wfs_truth/2 instead of just
22
+ % calling covered_by_blanket/1 or itemized/1 directly.
23
+ % 3) An explicit conflict predicate, the way examples/nixon-diamond.pl
24
+ % already handles the same shape of problem (two independent defaults,
25
+ % no priority between them): join the two eligibility facts directly and
26
+ % name the outcome. No negation, no cycle, nothing for --warnings to
27
+ % flag, and the result is an ordinary fact any caller can query without
28
+ % having to know WFS exists.
29
+ %
30
+ % Here (3) is the better fit, and is this codebase's actual convention for
31
+ % this situation. Reach for tnot/1 and wfs_truth/2 when the mutual defeat is
32
+ % already how the rules are naturally stated as each other's negation — a
33
+ % permission that holds unless its prohibition holds and vice versa, as
34
+ % examples/odrl-policy-reasoning.pl's profile rules do for a real policy
35
+ % language — not to manufacture a cycle for a conflict a direct join would
36
+ % already detect. And for everyday defeasible overriding, where a more
37
+ % specific rule is meant to win outright, plain stratified \+/1 already says
38
+ % exactly that and is ISO-portable; WFS would not change the answer there,
39
+ % only the machinery needed to get it.
40
+
41
+ %% goal: reimbursementQuestion(X0, X1, X2)
42
+ %% goal: conflictQuestion(X0, X1, X2)
43
+ %% goal: explicitConflictQuestion(X0, X1, X2)
44
+
45
+ % --- Submitted expenses and the facts a claims clerk would see ------------
46
+
47
+ submitted(taxi_receipt).
48
+ submitted(client_dinner_wine).
49
+ submitted(client_dinner_wine_preapproved).
50
+ submitted(team_offsite_hotel).
51
+
52
+ category(client_dinner_wine, alcohol).
53
+ category(client_dinner_wine_preapproved, alcohol).
54
+
55
+ % A manager can preapprove alcohol as part of client entertainment; that is
56
+ % the exception to the exception.
57
+ preapproved_entertainment(client_dinner_wine_preapproved).
58
+
59
+ % --- Ordinary defeasible tier: specificity resolves every case -------------
60
+ %
61
+ % default: an expense is reimbursable
62
+ % exception: ... unless it is alcohol
63
+ % exception to that: ... unless the alcohol was preapproved entertainment
64
+ %
65
+ % Each rule only negates a lower, already-computed layer, so this is
66
+ % stratified and plain \+/1 is all it takes.
67
+
68
+ excluded(Expense) :-
69
+ category(Expense, alcohol),
70
+ \+ preapproved_entertainment(Expense).
71
+
72
+ reimbursable(Expense) :-
73
+ submitted(Expense),
74
+ \+ excluded(Expense).
75
+
76
+ % --- The one case with a genuine, unresolved conflict, two ways ------------
77
+ %
78
+ % team_offsite_hotel independently qualifies for both the flat per-diem
79
+ % blanket allowance and itemized reimbursement.
80
+ blanket_eligible(team_offsite_hotel).
81
+ itemizable(team_offsite_hotel).
82
+
83
+ % 2) WFS via tnot/1: each classification defeats the other, leaving both
84
+ % `undefined` rather than an arbitrary pick.
85
+ covered_by_blanket(Expense) :-
86
+ blanket_eligible(Expense),
87
+ tnot(itemized(Expense)).
88
+ itemized(Expense) :-
89
+ itemizable(Expense),
90
+ tnot(covered_by_blanket(Expense)).
91
+
92
+ % 3) This codebase's usual idiom (examples/nixon-diamond.pl): detect the
93
+ % conflict directly from the two eligibility facts, no negation involved.
94
+ policy_conflict(Expense, blanket_allowance, itemized_reimbursement) :-
95
+ blanket_eligible(Expense),
96
+ itemizable(Expense).
97
+
98
+ % --- Curated questions -----------------------------------------------------
99
+
100
+ % Plain expense, alcohol excluded by default, and the preapproved override —
101
+ % all decided by ordinary negation as failure, no WFS involved.
102
+ reimbursementQuestion(plain, taxi_receipt, Verdict) :-
103
+ wfs_truth(reimbursable(taxi_receipt), Verdict).
104
+ reimbursementQuestion(alcohol_excluded, client_dinner_wine, Verdict) :-
105
+ wfs_truth(reimbursable(client_dinner_wine), Verdict).
106
+ reimbursementQuestion(alcohol_preapproved, client_dinner_wine_preapproved, Verdict) :-
107
+ wfs_truth(reimbursable(client_dinner_wine_preapproved), Verdict).
108
+
109
+ % The unresolved policy conflict under WFS: both classifications come back
110
+ % `undefined`, which only tells a caller anything if it remembers to ask
111
+ % wfs_truth/2 in the first place.
112
+ conflictQuestion(blanket_allowance, team_offsite_hotel, Verdict) :-
113
+ wfs_truth(covered_by_blanket(team_offsite_hotel), Verdict).
114
+ conflictQuestion(itemized_reimbursement, team_offsite_hotel, Verdict) :-
115
+ wfs_truth(itemized(team_offsite_hotel), Verdict).
116
+
117
+ % The same conflict, detected directly: an ordinary fact any caller can query
118
+ % without knowing WFS is involved at all.
119
+ explicitConflictQuestion(team_offsite_hotel, blanket_allowance, itemized_reimbursement) :-
120
+ policy_conflict(team_offsite_hotel, blanket_allowance, itemized_reimbursement).
@@ -0,0 +1,6 @@
1
+ reimbursementQuestion(plain, taxi_receipt, true).
2
+ reimbursementQuestion(alcohol_excluded, client_dinner_wine, false).
3
+ reimbursementQuestion(alcohol_preapproved, client_dinner_wine_preapproved, true).
4
+ conflictQuestion(blanket_allowance, team_offsite_hotel, undefined).
5
+ conflictQuestion(itemized_reimbursement, team_offsite_hotel, undefined).
6
+ explicitConflictQuestion(team_offsite_hotel, blanket_allowance, itemized_reimbursement).
package/index.d.ts CHANGED
@@ -96,6 +96,12 @@ export interface EyePrologQuadResult {
96
96
  kind?: 'failed' | 'malformed' | 'bad_identifier' | 'unsupported' | 'undecided';
97
97
  expected?: EyePrologTerm;
98
98
  reason?: string;
99
+ /** The quad's query term, for a caller building its own per-result label. */
100
+ query?: EyePrologTerm;
101
+ /** The quad's identifier, or null when the quad has none. */
102
+ id?: EyePrologTerm | null;
103
+ /** The source line this answer description starts on, when known. */
104
+ line?: number | null;
99
105
  }
100
106
 
101
107
  export interface EyePrologQuadRunResult {
@@ -312,6 +318,8 @@ export function hasForwardRules(program: Program): boolean;
312
318
  /** Execute EyeProlog `:+/2` rules to closure using an existing solver. */
313
319
  export function executeForwardRules(program: Program, solver: Solver, options?: EyePrologForwardRunOptions): EyePrologForwardRunResult;
314
320
  export function runQuads(source: string | Program, options?: EyePrologQuadRunOptions): EyePrologQuadRunResult;
321
+ /** Render a quad term (query, identifier, or expected answer) the way quad failure reports do. */
322
+ export function formatQuadTerm(program: Program, term: EyePrologTerm): string;
315
323
  export interface EyePrologProofMethod {
316
324
  type: 'source' | 'builtin' | 'library' | 'conjunction';
317
325
  kind?: 'fact' | 'rule';
@@ -433,6 +441,7 @@ declare const eyeprolog: {
433
441
  hasForwardRules: typeof hasForwardRules;
434
442
  executeForwardRules: typeof executeForwardRules;
435
443
  runQuads: typeof runQuads;
444
+ formatQuadTerm: typeof formatQuadTerm;
436
445
  proofCertificate: typeof proofCertificate;
437
446
  proofCertificatesFromText: typeof proofCertificatesFromText;
438
447
  verifyProof: typeof verifyProof;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.5.81",
6
+ "version": "1.5.83",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/playground.html CHANGED
@@ -498,6 +498,7 @@
498
498
  "deep-taxonomy-1000",
499
499
  "deep-taxonomy-10000",
500
500
  "deep-taxonomy-100000",
501
+ "defeasible-reasoning",
501
502
  "delfour",
502
503
  "deontic-logic",
503
504
  "derived-backward-rule",
package/src/index.js CHANGED
@@ -28,7 +28,7 @@ export {
28
28
  eyePrologInteropLibraryModules,
29
29
  } from './standard-library.js';
30
30
  export { StreamManager } from './io.js';
31
- export { runQuads } from './quads.js';
31
+ export { formatQuadTerm, runQuads } from './quads.js';
32
32
  export { executeForwardRules, hasForwardRules } from './execute.js';
33
33
 
34
34
  import { installCleanupLifecycle } from './cleanup.js';
package/src/platform.js CHANGED
@@ -7,11 +7,13 @@ let fs = null;
7
7
  let path = null;
8
8
  let BufferCtor = null;
9
9
  let v8 = null;
10
+ let vm = null;
10
11
 
11
12
  if (isNode) {
12
13
  ({ default: fs } = await import('node:fs'));
13
14
  ({ default: path } = await import('node:path'));
14
15
  ({ default: v8 } = await import('node:v8'));
16
+ ({ default: vm } = await import('node:vm'));
15
17
  BufferCtor = globalThis.Buffer ?? null;
16
18
  }
17
19
 
@@ -47,6 +49,38 @@ export function usedHeapSize() {
47
49
  return Number.isFinite(memory?.usedJSHeapSize) ? memory.usedJSHeapSize : null;
48
50
  }
49
51
 
52
+ let forcedGc = null;
53
+ let forcedGcUnavailable = false;
54
+
55
+ // A resource_error(memory) decision reads ambient process heap usage, which
56
+ // also includes still-unreclaimed garbage from work that already finished
57
+ // (for example a prior query's abandoned search branches). Without this, one
58
+ // short-lived, memory-heavy query can leave enough uncollected garbage behind
59
+ // that an unrelated, actually-modest query run immediately afterward in the
60
+ // same process sees a false positive. Node does not expose a synchronous GC
61
+ // by default; this obtains one without requiring the host process to have
62
+ // been launched with --expose-gc, so the guard sees genuinely live memory
63
+ // before it gives up.
64
+ export function forceGarbageCollection() {
65
+ if (!isNode || forcedGcUnavailable) return false;
66
+ try {
67
+ if (forcedGc == null) {
68
+ if (typeof globalThis.gc === 'function') {
69
+ forcedGc = globalThis.gc;
70
+ } else {
71
+ v8.setFlagsFromString('--expose-gc');
72
+ forcedGc = vm.runInNewContext('gc');
73
+ v8.setFlagsFromString('--no-expose-gc');
74
+ }
75
+ }
76
+ forcedGc();
77
+ return true;
78
+ } catch (_) {
79
+ forcedGcUnavailable = true;
80
+ return false;
81
+ }
82
+ }
83
+
50
84
  export function memoryStatistics() {
51
85
  const stats = {};
52
86
  if (isNode && typeof process.memoryUsage === 'function') {
package/src/quads.js CHANGED
@@ -48,6 +48,13 @@ export function runQuads(source, options = {}) {
48
48
  // prevent later expectations for the same query from being checked.
49
49
  for (const description of quad.answers) {
50
50
  const result = checkQuadDescription(program, quad, description, options, context);
51
+ // A caller that wants to report each answer description as its own
52
+ // test (rather than only the aggregate counts below) needs enough to
53
+ // build a label and re-derive the same failure text formatFailure
54
+ // would have printed.
55
+ result.query = quad.query;
56
+ result.id = quad.id ?? null;
57
+ result.line = description?.answerLine ?? quad.source?.line ?? null;
51
58
  results.push(result);
52
59
  if (!result.ok) lines.push(formatFailure(program, quad, result, description));
53
60
  }
@@ -192,6 +199,12 @@ function checkAlternative(program, quad, alternative, options, context, unordere
192
199
  return { ok: false };
193
200
  }
194
201
  if (leaf.more) return { ok: true };
202
+ // A bare `sto` leaf claims nothing beyond "this outcome is occurs-check
203
+ // dependent" (see matchLeaf); it accepts unconditionally, including when
204
+ // the search never actually settled within budget. Otherwise an
205
+ // inconclusive search behind such a leaf would report undecided over a
206
+ // claim that was never making a claim to begin with.
207
+ if (leaf.sto && !leaf.hasExpectation) return { ok: true };
195
208
  if (!leaf.unexpected && (leaf.false || leaf.loops || leaf.error != null)) {
196
209
  if (actual.undecided) return undecidedResult(actual, alternative);
197
210
  return { ok: position === leaves.length - 1 };
@@ -963,7 +976,7 @@ function formatFailure(program, quad, result, description = quad.answers[0]) {
963
976
  ` ?- ${formatQuadTerm(program, quad.query)}.\n` + detail;
964
977
  }
965
978
 
966
- function formatQuadTerm(program, term) {
979
+ export function formatQuadTerm(program, term) {
967
980
  const operators = [...program.operators.values()];
968
981
  if (!operators.some(({ name, specifier }) => name === '~' && ['xfx', 'xfy', 'yfx'].includes(specifier))) {
969
982
  operators.push({ priority: 700, specifier: 'xfx', name: '~' });
package/src/solver.js CHANGED
@@ -11,7 +11,7 @@ import { PrologError, getStrictIsoRegistry } from './iso.js';
11
11
  import { getEyePrologRegistry } from './standard-library.js';
12
12
  import { selectClauseCandidates, selectClauseCandidatesForValues, selectGroundClauseCandidates } from './program-indexing.js';
13
13
  import { StreamManager } from './io.js';
14
- import { hardHeapLimit, softHeapLimit, usedHeapSize } from './platform.js';
14
+ import { forceGarbageCollection, hardHeapLimit, softHeapLimit, usedHeapSize } from './platform.js';
15
15
  import { evaluateWfs, relationForGroup, truthOfGroundGoal } from './wfs.js';
16
16
  import { ISO_MAX_ARITY } from './iso-limits.js';
17
17
  import { evaluatePositiveDatalog, relationForDatalogGroup, datalogCandidateIndexes } from './datalog.js';
@@ -124,7 +124,7 @@ export class Solver {
124
124
  // same process heap. Share their sampling deadline so each short-lived
125
125
  // child does not repeat an expensive host memory query before the parent
126
126
  // has advanced the guard interval.
127
- this.memoryCheckState = options.memoryCheckState ?? { nextObservation: 0 };
127
+ this.memoryCheckState = options.memoryCheckState ?? { nextObservation: 0, reclaimed: false };
128
128
  // Do not impose an implicit answer cap. Infinite and very large searches are
129
129
  // part of normal Prolog semantics; callers that need a resource bound can
130
130
  // still supply solutionLimit explicitly.
@@ -1080,9 +1080,17 @@ export class Solver {
1080
1080
  if (!force && observation < this.memoryCheckState.nextObservation) return;
1081
1081
  this.memoryCheckState.nextObservation = observation + 256;
1082
1082
  if (!Number.isFinite(this.maxMemoryBytes)) return;
1083
- const used = usedHeapSize();
1083
+ let used = usedHeapSize();
1084
1084
  if (used != null && used < this.maxMemoryBytes) this.finishMemoryRecovery();
1085
1085
  if (used != null && used >= this.currentMemoryLimit()) {
1086
+ // Ambient heap usage also counts already-abandoned garbage (an earlier
1087
+ // query's discarded search branches, for example) that a real
1088
+ // out-of-memory condition would not have left reclaimable. Reclaim it
1089
+ // once before finally giving up, so that garbage cannot cost this (or,
1090
+ // in a caller that runs many queries in one process, a later) query a
1091
+ // false resource_error(memory).
1092
+ used = this.reclaimMemory();
1093
+ if (used != null && used < this.currentMemoryLimit()) return;
1086
1094
  if (this.memoryRecovery.active && this.memoryRecovery.checks > 0) {
1087
1095
  this.memoryRecovery.checks--;
1088
1096
  return;
@@ -1093,9 +1101,11 @@ export class Solver {
1093
1101
 
1094
1102
  checkMemoryReservation(bytes) {
1095
1103
  if (!Number.isFinite(this.maxMemoryBytes) || !Number.isFinite(bytes) || bytes <= 0) return;
1096
- const used = usedHeapSize();
1104
+ let used = usedHeapSize();
1097
1105
  if (used != null && used < this.maxMemoryBytes) this.finishMemoryRecovery();
1098
1106
  if (used != null && bytes > Math.max(0, this.currentMemoryLimit() - used)) {
1107
+ used = this.reclaimMemory();
1108
+ if (used != null && bytes <= Math.max(0, this.currentMemoryLimit() - used)) return;
1099
1109
  if (this.memoryRecovery.active && bytes <= this.memoryRecovery.reservationBytes) {
1100
1110
  this.memoryRecovery.reservationBytes -= bytes;
1101
1111
  return;
@@ -1104,6 +1114,23 @@ export class Solver {
1104
1114
  }
1105
1115
  }
1106
1116
 
1117
+ // Force a garbage-collection pass and re-measure, once per query (shared
1118
+ // across this query's own nested meta-call solvers via memoryCheckState),
1119
+ // right before a memory check would otherwise throw. A full collection
1120
+ // pass is not free, so this only ever runs at most once on the rare path
1121
+ // that was already about to fail; it never runs on the common path where
1122
+ // memory stays under budget. It only ever makes the guard more accurate,
1123
+ // never less: a genuinely exhausted heap still measures as exhausted
1124
+ // afterward.
1125
+ reclaimMemory() {
1126
+ if (this.memoryCheckState.reclaimed) return usedHeapSize();
1127
+ this.memoryCheckState.reclaimed = true;
1128
+ if (!forceGarbageCollection()) return usedHeapSize();
1129
+ const reclaimed = usedHeapSize();
1130
+ if (reclaimed != null && reclaimed < this.maxMemoryBytes) this.finishMemoryRecovery();
1131
+ return reclaimed;
1132
+ }
1133
+
1107
1134
  currentMemoryLimit() {
1108
1135
  if (!this.memoryRecovery.active) return this.maxMemoryBytes;
1109
1136
  // Retain at least five percent of the actual host ceiling for error
@@ -1825,7 +1852,7 @@ function* bundledMemberSolutions(solver, goal, env, state) {
1825
1852
  yield next;
1826
1853
  }
1827
1854
  candidate = cons(variable(`__member${id}_head_${before}`), candidate);
1828
- generatedLengthAllocationCheckpoint(solver, before + 1n);
1855
+ if (generatedLengthAllocationCheckpoint(solver, before + 1n)) break;
1829
1856
  }
1830
1857
  }
1831
1858
  state.pending = false;
@@ -2063,7 +2090,7 @@ function* generatedLengthSolutions(solver, list, length, env) {
2063
2090
  const answer = bindGeneratedLength(solver, length, count + extra, next);
2064
2091
  if (answer != null) yield answer;
2065
2092
  suffix = cons(variable(`__length${id}_${extra}`), suffix);
2066
- generatedLengthAllocationCheckpoint(solver, extra + 1n);
2093
+ if (generatedLengthAllocationCheckpoint(solver, extra + 1n)) return;
2067
2094
  }
2068
2095
  }
2069
2096
 
@@ -2081,8 +2108,23 @@ function lengthAllocationCheckpoint(solver, steps) {
2081
2108
  if ((steps & 255n) === 0n) solver.checkMemoryLimit(true);
2082
2109
  }
2083
2110
 
2111
+ // Each generated answer is one inference, the same as an ordinary clause
2112
+ // resolution step, so this native loop is bounded by maxInferences exactly
2113
+ // like the general solve loop is (see the `this.inferences++` accounting
2114
+ // there). Without this, a caller that deliberately tightens maxInferences to
2115
+ // get a fast, bounded probe for nontermination (quads.js's `loops` detection,
2116
+ // for example) could not do that for this generator: it produced no
2117
+ // inference-count evidence at all and depended entirely on eventually
2118
+ // hitting the much larger general memory ceiling. Returns true once the
2119
+ // caller should stop generating further answers.
2084
2120
  function generatedLengthAllocationCheckpoint(solver, steps) {
2085
- if ((steps & 255n) !== 0n) return;
2121
+ solver.inferences++;
2122
+ solver.inferenceObservation.value++;
2123
+ if (solver.inferences > solver.maxInferences) {
2124
+ solver.inferenceLimitExceeded = true;
2125
+ return true;
2126
+ }
2127
+ if ((steps & 255n) !== 0n) return false;
2086
2128
  // The open-ended generator retains its current list spine between answers.
2087
2129
  // Reserve room proportional to that live spine so the protected length/2
2088
2130
  // call raises resource_error(memory) before its caller's outer solver hits
@@ -2092,6 +2134,7 @@ function generatedLengthAllocationCheckpoint(solver, steps) {
2092
2134
  : Number(steps) * GENERATED_LENGTH_CELL_RESERVE_BYTES;
2093
2135
  solver.checkMemoryReservation(estimatedSpineBytes);
2094
2136
  solver.checkMemoryLimit(true);
2137
+ return false;
2095
2138
  }
2096
2139
 
2097
2140
  function pushFastPiFrames(stack, goal, rest, env, depth, active) {
@@ -315,7 +315,7 @@ post-N289 STC drafts remain review input until standardized.
315
315
  | Implementation-specific strict/normal boundary is documented and tested | covered | all 5.5 hooks have explicit dispositions; the WG17 cross-profile gate verifies syntax-preservation for standard text accepted by the strict reader. |
316
316
  | Published Corrigenda 1-3 are incorporated | covered | `ISO-CORRIGENDA-MATRIX.md` inventories every published amendment cluster with executable, editorial, or superseded disposition |
317
317
  | Current post-N289 draft is tracked without silently changing the published baseline | covered | `STC-DRAFT-STATUS.md` tracks reviewed draft items separately from normative requirements |
318
- | Latest Neumerkel conformity is a live release gate | covered | `npm test` fetches and executes the seven current TU Wien conformity sources with dynamic inventories; the vendored WG17 matrix remains an offline reviewed-outcome regression layer |
318
+ | Latest Neumerkel conformity is a live release gate | covered | `npm test` fetches and executes the eight current TU Wien conformity sources with dynamic inventories; the vendored WG17 matrix remains an offline reviewed-outcome regression layer |
319
319
  | Third-party standard-core regression provenance is retained | covered | adapted Logtalk, Scryer, Trealla, and SWI-Prolog cases retain source identifiers and licenses in `THIRD_PARTY.md` |
320
320
  | No unexplained deviation remains in the release-facing ledger | covered | the release-facing ledger contains no remaining `review` rows; documented variation points are implementation-defined/specific or draft-only rather than unexplained deviations. |
321
321
 
@@ -1,9 +1,9 @@
1
1
  # EyeProlog — latest Neumerkel conformity
2
2
 
3
- Status: **FAIL** — **685/686** discovered upstream cases passed.
3
+ Status: **PASS — 1 divergence to be addressed** — **718/719** discovered upstream cases passed.
4
4
 
5
5
  This tracked report records the latest upstream inventory successfully checked by EyeProlog.
6
- `npm test` fetches the seven TU Wien sources again and executes the discovered cases.
6
+ `npm test` fetches the eight TU Wien sources again and executes the discovered cases.
7
7
  Release/report checks can additionally require these tracked counts to match the live suites.
8
8
  Counts are output from upstream, not hard-coded test constants.
9
9
 
@@ -13,10 +13,15 @@ Counts are output from upstream, not hard-coded test constants.
13
13
  | number_chars/2 | 86 | 86 |
14
14
  | variable_names/1 | 75 | 75 |
15
15
  | dif/2 | 26 | 26 |
16
- | length/2 | 36 | 37 |
16
+ | length/2 | 37 | 37 |
17
17
  | phrase/2,3 | 58 | 58 |
18
+ | Prologue draft | 32 | 33 |
18
19
  | setup_call_cleanup/3 | 25 | 25 |
19
- | **Total** | **685** | **686** |
20
+ | **Total** | **718** | **719** |
21
+
22
+ ## Known divergences
23
+
24
+ - **Prologue draft** (line 118): bounded=false: EyeProlog reports no max_integer value at all (ISO 7.11.1.1), so this quad's evaluation_error(int_overflow) | Max = unbounded pair never applies
20
25
 
21
26
  ## Upstream sources
22
27
 
@@ -26,6 +31,7 @@ Counts are output from upstream, not hard-coded test constants.
26
31
  - [dif](https://www.complang.tuwien.ac.at/ulrich/iso-prolog/dif)
27
32
  - [length](https://www.complang.tuwien.ac.at/ulrich/iso-prolog/length_quad.pl)
28
33
  - [phrase](https://www.complang.tuwien.ac.at/ulrich/iso-prolog/phrase_quad.pl)
34
+ - [prologue](https://www.complang.tuwien.ac.at/ulrich/iso-prolog/prologue_quad.pl)
29
35
  - [cleanup](https://www.complang.tuwien.ac.at/ulrich/iso-prolog/cleanup)
30
36
 
31
37
  Exact fetched bytes, SHA-256 hashes, fetch timestamps, and HTTP validators remain under
@@ -4,7 +4,7 @@ EyeProlog treats Ulrich Neumerkel's current ISO/WG17 conformity material as a
4
4
  moving upstream release gate, not as a frozen snapshot with permanent case
5
5
  counts.
6
6
 
7
- `node test/run-neumerkel.mjs` fetches these seven TU Wien sources on every live run:
7
+ `node test/run-neumerkel.mjs` fetches these eight TU Wien sources on every live run:
8
8
 
9
9
  1. `conformity_testing` — Part 1 syntax/reader/writer matrix;
10
10
  2. `number_chars_cont_quad.pl` — `number_chars/2` continuation corpus;
@@ -12,7 +12,19 @@ counts.
12
12
  4. `dif` — `dif/2` comparison table;
13
13
  5. `length_quad.pl` — `length/2` corpus;
14
14
  6. `phrase_quad.pl` — `phrase/2,3` corpus;
15
- 7. `cleanup` — `setup_call_cleanup/3` examples.
15
+ 7. `prologue_quad.pl` — Prolog Prologue working-draft corpus;
16
+ 8. `cleanup` — `setup_call_cleanup/3` examples.
17
+
18
+ The Prologue corpus carries one permanent, documented divergence (its
19
+ `max_integer` quad — see `KNOWN_QUAD_DIVERGENCES` in `test/neumerkel.mjs`):
20
+ EyeProlog's `bounded=false` reports no `max_integer` value at all, which
21
+ neither of the quad's two anticipated answers describes. That quad is still
22
+ executed every run and counted honestly (it does not report as passing), but
23
+ it does not abort the run the way an unexplained failure would; the tracked
24
+ report and console summary both say "N divergence(s) to be addressed" rather
25
+ than a bare pass/fail count. See `NEUMERKEL-LATEST.md`'s own "Known
26
+ divergences" section for the explanation. If the divergence ever stopped
27
+ reproducing, the counts would simply climb back to a plain, unqualified PASS.
16
28
 
17
29
  The runner discovers the inventory at run time. A new upstream row is therefore
18
30
  executed automatically and a removed row disappears automatically. The syntax
@@ -91,7 +91,7 @@ The conformance commands are:
91
91
  ```sh
92
92
  node test/run-conformance-all.mjs # live Neumerkel + local ISO/conformance layers
93
93
  node test/run-conformance-all.mjs --offline # same local layers, no network
94
- node test/run-neumerkel.mjs # the seven live upstream suites only
94
+ node test/run-neumerkel.mjs # the eight live upstream suites only
95
95
  node test/run-neumerkel.mjs --cached # exact last fetched bytes; reproduction only
96
96
  node test/run-neumerkel.mjs --cached --verify-report # verify tracked report against last successful live snapshot
97
97
  node test/run-iso-strict.mjs # Part 1 + Corrigenda strict-core processor gate
@@ -84,7 +84,8 @@ a5 ?- atom_length(1,N).
84
84
  false.
85
85
  30 ?- freeze(L,L=[_|L]), length(L,N).
86
86
  sto, loops
87
- | sto, resource_error(...).
87
+ | sto, resource_error(...)
88
+ | sto, false.
88
89
  31 ?- freeze(L,L=[_|L]), N is 2^64, length(L,N).
89
90
  sto, false.
90
91
  32 ?- length([a,b|L], N).
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { run } from '../src/index.js';
7
+ import { Program, formatQuadTerm, run, runQuads } from '../src/index.js';
8
8
  import {
9
9
  countTopLevelTerms,
10
10
  decodeDocument,
@@ -31,6 +31,7 @@ export const NEUMERKEL_SOURCES = Object.freeze([
31
31
  { key: 'dif', filename: 'dif.html', url: `${baseUrl}dif`, kind: 'dif' },
32
32
  { key: 'length', filename: 'length_quad.pl', url: `${baseUrl}length_quad.pl`, kind: 'quad' },
33
33
  { key: 'phrase', filename: 'phrase_quad.pl', url: `${baseUrl}phrase_quad.pl`, kind: 'quad' },
34
+ { key: 'prologue', filename: 'prologue_quad.pl', url: `${baseUrl}prologue_quad.pl`, kind: 'quad' },
34
35
  { key: 'cleanup', filename: 'cleanup.html', url: `${baseUrl}cleanup`, kind: 'cleanup' },
35
36
  ]);
36
37
 
@@ -305,45 +306,78 @@ function expectedMatches(expected, actual) {
305
306
  return expected === actual.type;
306
307
  }
307
308
 
308
- // Issue #111: making quads.js's `sto` handling precise exposed one genuine,
309
- // permanent divergence from this upstream quad. EyeProlog's frozen goal
310
- // unifies L with a term containing itself and fails via occurs-check well
311
- // within budget, a real (if approximate) implementation choice; the quad only
312
- // anticipates looping or resource exhaustion for this STO example. The
313
- // mirroring regression test lives in test/regression/cases-regression.mjs.
309
+ // A permanent, deliberate divergence from an upstream quad: EyeProlog's own
310
+ // implementation choice, not a bug to chase. Keyed by corpus key, then by the
311
+ // answer description's source line. It is still reported and counted as an
312
+ // ordinary failing test (so the totals stay honest and it self-corrects the
313
+ // moment it stops reproducing, exactly as happened with the length corpus's
314
+ // occurs-check quad); it just does not abort the run the way an unexplained
315
+ // failure would.
314
316
  const KNOWN_QUAD_DIVERGENCES = {
315
- length: { failed: 1, mustInclude: 'quads: FAILED 30,' },
317
+ prologue: new Map([
318
+ [118, 'bounded=false: EyeProlog reports no max_integer value at all (ISO 7.11.1.1), ' +
319
+ 'so this quad\'s evaluation_error(int_overflow) | Max = unbounded pair never applies'],
320
+ ]),
316
321
  };
317
322
 
318
- function ensureQuadSuccess(label, item) {
319
- const cli = path.join(packageRoot, 'bin', 'eyeprolog.js');
323
+ // Report each answer description as its own test, the same way the syntax and
324
+ // dif cases below do, instead of one aggregate pass/fail line for the whole
325
+ // corpus: a single upstream change then points straight at the one quad that
326
+ // moved instead of leaving a reader to dig through a raw quad-report string.
327
+ function runQuadCorpus(reporter, label, item, knownDivergences = new Map()) {
320
328
  const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'eyeprolog-neumerkel-'));
329
+ const previousCwd = process.cwd();
330
+ let result;
331
+ let program;
321
332
  try {
322
- // Some upstream option tests deliberately open a relative file named `f`.
323
- // Run every live quad in an isolated cwd so conformance cannot dirty the checkout.
324
- const child = spawnSync(process.execPath, [cli, '-q', item.localPath], { encoding: 'utf8', cwd: scratch });
325
- const divergence = KNOWN_QUAD_DIVERGENCES[label] ?? null;
326
- const expectedStatus = divergence == null ? 0 : 1;
327
- if (child.status !== expectedStatus) {
328
- throw new Error(`${label}: EyeProlog quad runner exited ${child.status}\n${child.stdout}${child.stderr}`);
329
- }
330
- const match = String(child.stdout).match(/quads:\s+(\d+) run,\s+(\d+) passed,\s+(\d+) failed(?:,\s+(\d+) undecided)?\./);
331
- if (match == null) throw new Error(`${label}: could not parse quad report\n${child.stdout}${child.stderr}`);
332
- const total = Number(match[1]);
333
- const passed = Number(match[2]);
334
- const failed = Number(match[3]);
335
- const undecided = Number(match[4] ?? 0);
336
- const expectedFailed = divergence?.failed ?? 0;
337
- if (failed !== expectedFailed || undecided !== 0 || passed !== total - expectedFailed) {
338
- throw new Error(`${label}: ${passed}/${total} passed, ${failed} failed, ${undecided} undecided\n${child.stdout}${child.stderr}`);
339
- }
340
- if (divergence != null && !String(child.stdout).includes(divergence.mustInclude)) {
341
- throw new Error(`${label}: expected documented divergence (${divergence.mustInclude}) not found\n${child.stdout}${child.stderr}`);
342
- }
343
- return { total, passed, failed, undecided, stdout: child.stdout };
333
+ // These upstream quad files assume the Prologue predicates are available
334
+ // as system predicates and carry no use_module/1 directive of their own,
335
+ // the same reason the CLI's -q mode prepends this for a quad file (see
336
+ // src/cli.js). Parse against the real package root so library/module
337
+ // resolution keeps working, then only switch directories for execution:
338
+ // some upstream option tests deliberately open a relative file named `f`,
339
+ // and EyeProlog's file predicates resolve against process.cwd(). Isolate
340
+ // that in a scratch cwd so conformance cannot dirty the checkout.
341
+ // A separate source part (rather than a concatenated prelude line) keeps
342
+ // the quad file's own line numbers unshifted for reporting.
343
+ const source = fs.readFileSync(item.localPath, 'utf8');
344
+ program = Program.parseSources([
345
+ { text: ':- use_module(library(prologue)).\n', filename: '<quad-prelude>' },
346
+ { text: source, filename: item.localPath, baseDir: packageRoot },
347
+ ]);
348
+ process.chdir(scratch);
349
+ result = runQuads(program);
344
350
  } finally {
351
+ process.chdir(previousCwd);
345
352
  fs.rmSync(scratch, { recursive: true, force: true });
346
353
  }
354
+
355
+ const failures = [];
356
+ let passed = 0;
357
+ result.results.forEach((description, index) => {
358
+ const locator = description.id != null ? formatQuadTerm(program, description.id) : `#${index + 1}`;
359
+ const divergence = knownDivergences.get(description.line);
360
+ const name = divergence == null
361
+ ? `${label} ${locator} (line ${description.line ?? '?'}) ?- ${formatQuadTerm(program, description.query)}`
362
+ : `${label} ${locator} (line ${description.line ?? '?'}, documented divergence: ${divergence}) ?- ${formatQuadTerm(program, description.query)}`;
363
+ try {
364
+ reporter.test(name, () => {
365
+ if (!description.ok) {
366
+ const reason = description.reason ? `: ${description.reason}` : '';
367
+ throw new Error(`${description.kind ?? 'failed'}${reason}`);
368
+ }
369
+ });
370
+ passed++;
371
+ } catch (error) {
372
+ // A documented divergence is expected to keep failing; note it without
373
+ // aborting the run over it. Upstream changes otherwise often arrive in
374
+ // small clusters -- keep running the corpus so one live test run
375
+ // exposes every new mismatch instead of forcing a fix/rerun cycle for
376
+ // each row.
377
+ if (divergence == null) failures.push(error);
378
+ }
379
+ });
380
+ return { total: result.results.length, passed, failures };
347
381
  }
348
382
 
349
383
  export async function executeNeumerkel({ reporter, mode = 'live', cacheDir = defaultCacheDir, sourceDir = null } = {}) {
@@ -379,10 +413,19 @@ export async function executeNeumerkel({ reporter, mode = 'live', cacheDir = def
379
413
  );
380
414
  }
381
415
 
382
- for (const key of ['number_chars', 'variable_names', 'length', 'phrase']) {
383
- const result = reporter.batch(`${key.replace('_', ' ')} live corpus`, () =>
384
- ensureQuadSuccess(key, sources.get(key)));
385
- summary[key] = { passed: result.passed, total: result.total };
416
+ const quadFailures = [];
417
+ for (const key of ['number_chars', 'variable_names', 'length', 'phrase', 'prologue']) {
418
+ const { total, passed, failures } = runQuadCorpus(
419
+ reporter, key.replace('_', ' '), sources.get(key), KNOWN_QUAD_DIVERGENCES[key],
420
+ );
421
+ summary[key] = { passed, total };
422
+ quadFailures.push(...failures);
423
+ }
424
+ if (quadFailures.length > 0) {
425
+ throw new AggregateError(
426
+ quadFailures,
427
+ `${quadFailures.length} live Neumerkel quad case${quadFailures.length === 1 ? '' : 's'} failed`,
428
+ );
386
429
  }
387
430
 
388
431
  const difCases = parseDifCases(sources.get('dif').text);
@@ -443,6 +486,7 @@ export function formatNeumerkelSummary(summary) {
443
486
  ['dif', 'dif/2'],
444
487
  ['length', 'length/2'],
445
488
  ['phrase', 'phrase/2,3'],
489
+ ['prologue', 'Prologue draft'],
446
490
  ['cleanup', 'setup_call_cleanup/3'],
447
491
  ];
448
492
  return labels.map(([key, label]) => {
@@ -459,18 +503,30 @@ export function formatNeumerkelMarkdown({ summary }) {
459
503
  ['dif', 'dif/2'],
460
504
  ['length', 'length/2'],
461
505
  ['phrase', 'phrase/2,3'],
506
+ ['prologue', 'Prologue draft'],
462
507
  ['cleanup', 'setup_call_cleanup/3'],
463
508
  ];
464
509
  const rows = labels.map(([key, label]) => ({ key, label, ...summary[key] }));
465
510
  const passed = rows.reduce((sum, row) => sum + row.passed, 0);
466
511
  const total = rows.reduce((sum, row) => sum + row.total, 0);
512
+ const divergenceCount = Object.values(KNOWN_QUAD_DIVERGENCES)
513
+ .reduce((sum, byLine) => sum + byLine.size, 0);
514
+ const shortfall = total - passed;
515
+ // A shortfall fully accounted for by known, documented divergences (see
516
+ // KNOWN_QUAD_DIVERGENCES and the list below) is not a build failure; any
517
+ // other shortfall is.
518
+ const status = shortfall === 0
519
+ ? 'PASS'
520
+ : shortfall === divergenceCount
521
+ ? `PASS — ${shortfall} divergence${shortfall === 1 ? '' : 's'} to be addressed`
522
+ : 'FAIL';
467
523
  const lines = [
468
524
  '# EyeProlog — latest Neumerkel conformity',
469
525
  '',
470
- `Status: **${passed === total ? 'PASS' : 'FAIL'}** — **${passed}/${total}** discovered upstream cases passed.`,
526
+ `Status: **${status}** — **${passed}/${total}** discovered upstream cases passed.`,
471
527
  '',
472
528
  'This tracked report records the latest upstream inventory successfully checked by EyeProlog.',
473
- '`npm test` fetches the seven TU Wien sources again and executes the discovered cases.',
529
+ '`npm test` fetches the eight TU Wien sources again and executes the discovered cases.',
474
530
  'Release/report checks can additionally require these tracked counts to match the live suites.',
475
531
  'Counts are output from upstream, not hard-coded test constants.',
476
532
  '',
@@ -480,6 +536,14 @@ export function formatNeumerkelMarkdown({ summary }) {
480
536
  for (const row of rows) lines.push(`| ${row.label} | ${row.passed} | ${row.total} |`);
481
537
  lines.push(`| **Total** | **${passed}** | **${total}** |`);
482
538
 
539
+ if (divergenceCount > 0) {
540
+ lines.push('', '## Known divergences', '');
541
+ for (const [key, byLine] of Object.entries(KNOWN_QUAD_DIVERGENCES)) {
542
+ const label = labels.find(([labelKey]) => labelKey === key)?.[1] ?? key;
543
+ for (const [line, reason] of byLine) lines.push(`- **${label}** (line ${line}): ${reason}`);
544
+ }
545
+ }
546
+
483
547
  lines.push(
484
548
  '',
485
549
  '## Upstream sources',
@@ -2397,7 +2397,7 @@ c4 ?- call((!;1)).
2397
2397
  },
2398
2398
  },
2399
2399
  {
2400
- name: 'vendored Prolog Prologue corpus records the bounded=false max_integer divergence',
2400
+ name: 'runQuads passes the complete vendored Prolog Prologue corpus, with one documented max_integer divergence',
2401
2401
  run: () => {
2402
2402
  const filename = path.join(testRoot, 'fixtures', 'prologue_quad_runner.pl');
2403
2403
  const source = fs.readFileSync(filename, 'utf8');
@@ -2411,25 +2411,26 @@ c4 ?- call((!;1)).
2411
2411
  termToString(query).includes('current_prolog_flag(max_integer, Max)'));
2412
2412
  assertEqual(maxIntegerQuads.length, 1, 'max_integer quad count');
2413
2413
 
2414
- // This regression is about the one deliberate ISO divergence in the
2415
- // upstream Prologue fixture. Running all 33 records also explores two
2416
- // intentionally non-terminating STO examples and used to dominate the
2417
- // regression suite by several seconds, without adding evidence for
2418
- // max_integer. Keep the full vendored corpus intact, but execute only
2419
- // the relevant record here.
2420
- program.quads = maxIntegerQuads;
2414
+ // The full 33-quad corpus, including its two STO examples
2415
+ // (member(X,X) and select(E,Xs,Xs), both open-ended native-generator
2416
+ // searches -- see the maxInferences accounting added to
2417
+ // generatedLengthAllocationCheckpoint in src/solver.js) is bounded and
2418
+ // takes on the order of several seconds, not the indefinite hang it
2419
+ // used to depend on ambient heap pressure to avoid (see
2420
+ // Solver#reclaimMemory in src/solver.js).
2421
2421
  const result = publicApi.runQuads(program);
2422
- // The upstream working-draft quad accepts either integer overflow or
2423
- // Max=unbounded. EyeProlog reports no value for max_integer when
2424
- // bounded=false, so current_prolog_flag(max_integer, N) fails. Part 1
2425
- // does not mandate that outcome, so this is an implementation choice
2426
- // rather than a standards requirement. Preserve the upstream fixture
2427
- // unchanged and make the one deliberate divergence explicit here.
2428
- assertEqual(result.total, 1, 'quad total');
2429
- assertEqual(result.passed, 0, 'quad passed');
2422
+ // The upstream working-draft max_integer quad accepts either integer
2423
+ // overflow or Max=unbounded. EyeProlog reports no value for
2424
+ // max_integer when bounded=false, so
2425
+ // current_prolog_flag(max_integer, N) fails. Part 1 does not mandate
2426
+ // that outcome, so this is an implementation choice rather than a
2427
+ // standards requirement. Preserve the upstream fixture unchanged and
2428
+ // record the one deliberate divergence explicitly.
2429
+ assertEqual(result.total, 33, 'quad total');
2430
+ assertEqual(result.passed, 32, 'quad passed');
2430
2431
  assertEqual(result.failed, 1, 'quad failed');
2431
2432
  assertIncludes(result.stdout, 'current_prolog_flag(max_integer, Max)', 'max_integer divergence');
2432
- assertIncludes(result.stdout, 'quads: 1 run, 0 passed, 1 failed.', 'quad report');
2433
+ assertIncludes(result.stdout, 'quads: 33 run, 32 passed, 1 failed.', 'quad report');
2433
2434
  },
2434
2435
  },
2435
2436
  {
@@ -2448,25 +2449,14 @@ c4 ?- call((!;1)).
2448
2449
  },
2449
2450
  },
2450
2451
  {
2451
- name: 'CLI runs the complete authoritative length quad corpus, with one documented occurs-check divergence',
2452
+ name: 'CLI passes the complete authoritative length quad corpus',
2452
2453
  run: () => {
2453
2454
  const filename = path.join(testRoot, 'fixtures', 'length_quad.pl');
2454
2455
  const source = fs.readFileSync(filename, 'utf8');
2455
2456
  assertEqual(Program.parse(source).quads.length, 37, 'vendored quad total');
2456
2457
  const result = runCli(['-q', filename]);
2457
- // Making quads.js's `sto` handling precise (issue #111) exposed one
2458
- // genuine divergence here: `freeze(L,L=[_|L]), length(L,N)` unifies L
2459
- // with a term containing itself inside the frozen goal and fails via
2460
- // occurs-check well within the loop-detection budget. The upstream
2461
- // quad only anticipates looping or resource exhaustion for this STO
2462
- // example, not a clean finite failure, so neither offered alternative
2463
- // describes EyeProlog's actual (implementation-defined) behavior.
2464
- // Preserve the upstream fixture unchanged and record the one
2465
- // deliberate divergence explicitly, the same way the vendored
2466
- // Prologue corpus records its max_integer divergence above.
2467
- assertEqual(result.status, 1, 'quad exit status');
2468
- assertIncludes(result.stdout, 'quads: FAILED 30, length_quad.pl:86', 'documented occurs-check divergence');
2469
- assertIncludes(result.stdout, 'quads: 37 run, 36 passed, 1 failed.\n', 'quad report');
2458
+ assertEqual(result.status, 0, 'quad exit status');
2459
+ assertEqual(result.stdout, 'quads: 37 run, 37 passed, 0 failed.\n', 'quad report');
2470
2460
  assertEqual(result.stderr, '', 'quad stderr');
2471
2461
  },
2472
2462
  },
@@ -84,7 +84,7 @@ export function formatConformanceReport(report = buildConformanceReport()) {
84
84
  '## Latest Neumerkel evidence',
85
85
  '',
86
86
  'See the tracked [latest Neumerkel conformity report](test/conformance/NEUMERKEL-LATEST.md).',
87
- '`npm test` fetches all seven TU Wien sources once and executes the discovered inventory.',
87
+ '`npm test` fetches all eight TU Wien sources once and executes the discovered inventory.',
88
88
  'The release workflow then synchronizes this tracked report from those exact successful',
89
89
  'cached source bytes, avoiding a second live fetch and its race window.',
90
90
  '',
@@ -28,10 +28,10 @@ function syntaxHtmlWithBareAnchors() {
28
28
  export function runNeumerkelHarnessTests(reporter = new TestReporter()) {
29
29
  reporter.section('Neumerkel harness');
30
30
 
31
- reporter.test('live manifest tracks exactly seven upstream suite sources', () => {
32
- if (NEUMERKEL_SOURCES.length !== 7) throw new Error(`expected 7 sources, got ${NEUMERKEL_SOURCES.length}`);
31
+ reporter.test('live manifest tracks exactly eight upstream suite sources', () => {
32
+ if (NEUMERKEL_SOURCES.length !== 8) throw new Error(`expected 8 sources, got ${NEUMERKEL_SOURCES.length}`);
33
33
  const keys = new Set(NEUMERKEL_SOURCES.map(({ key }) => key));
34
- if (keys.size !== 7) throw new Error('Neumerkel source keys are not unique');
34
+ if (keys.size !== 8) throw new Error('Neumerkel source keys are not unique');
35
35
  });
36
36
 
37
37
  reporter.test('syntax inventory is discovered dynamically and reconstructs /**/ setup', () => {
@@ -106,13 +106,15 @@ export function runNeumerkelHarnessTests(reporter = new TestReporter()) {
106
106
  dif: { passed: 26, total: 26 },
107
107
  length: { passed: 37, total: 37 },
108
108
  phrase: { passed: 58, total: 58 },
109
+ prologue: { passed: 33, total: 33 },
109
110
  cleanup: { passed: 25, total: 25 },
110
111
  };
111
112
  const text = formatNeumerkelMarkdown({
112
113
  summary,
113
114
  manifest: { fetchedAt: '2026-09-03T12:00:00.000Z', sources: [{ etag: '"volatile-tag"' }] },
114
115
  });
115
- if (!text.includes('**675/675**')) throw new Error('Markdown total is not derived from suite counts');
116
+ if (!text.includes('**708/708**')) throw new Error('Markdown total is not derived from suite counts');
117
+ if (!text.includes('| Prologue draft | 33 | 33 |')) throw new Error('prologue row missing');
116
118
  if (!text.includes('| setup_call_cleanup/3 | 25 | 25 |')) throw new Error('cleanup row missing');
117
119
  if (!text.includes('https://www.complang.tuwien.ac.at/ulrich/iso-prolog/conformity_testing')) throw new Error('upstream source link missing');
118
120
  if (text.includes('2026-09-03T12:00:00.000Z') || text.includes('volatile-tag')) {
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { executeNeumerkel } from './neumerkel.mjs';
6
- import { isMainModule, nowMs, runStandalone } from './test-style.mjs';
6
+ import { isMainModule, runStandalone } from './test-style.mjs';
7
7
 
8
8
  function parseArgs(argv) {
9
9
  const options = {
@@ -30,7 +30,7 @@ function parseArgs(argv) {
30
30
  function printHelp() {
31
31
  process.stdout.write(
32
32
  'Usage: node test/run-neumerkel.mjs [--cached] [--source-dir DIR] [--verify-report] [--update-report]\n\n' +
33
- 'Default: fetch all seven current Neumerkel conformity sources live and run\n' +
33
+ 'Default: fetch all eight current Neumerkel conformity sources live and run\n' +
34
34
  'every discovered case. A stale tracked report is reported as a warning, not\n' +
35
35
  'an engine-test failure. --verify-report makes report freshness mandatory;\n' +
36
36
  '--update-report refreshes the tracked Markdown. --cached is reproduction only.\n',
@@ -42,7 +42,7 @@ export async function runNeumerkel(reporter, options = {}) {
42
42
  if (effective.sourceDir == null && process.env.EYEPROLOG_NEUMERKEL_SOURCE_DIR) {
43
43
  effective.sourceDir = path.resolve(process.env.EYEPROLOG_NEUMERKEL_SOURCE_DIR);
44
44
  }
45
- const result = await executeNeumerkel({ reporter: quietNeumerkelReporter(reporter), ...effective });
45
+ const result = await executeNeumerkel({ reporter, ...effective });
46
46
  const relativeReportPath = path.relative(process.cwd(), result.reportPath);
47
47
 
48
48
  if (effective.updateReport) {
@@ -64,45 +64,6 @@ export async function runNeumerkel(reporter, options = {}) {
64
64
  return result;
65
65
  }
66
66
 
67
- function quietNeumerkelReporter(reporter) {
68
- return {
69
- section(name) {
70
- reporter.section(name);
71
- },
72
- sectionTotal(label, elapsedMs = null) {
73
- reporter.sectionTotal(label, elapsedMs);
74
- },
75
- test(name, run) {
76
- reporter.total++;
77
- const nr = String(reporter.total).padStart(3, '0');
78
- const startedAt = nowMs();
79
- try {
80
- run();
81
- reporter.ok++;
82
- } catch (error) {
83
- const ms = nowMs() - startedAt;
84
- reporter.stderr.write(`FAIL ${nr} ${name} (${ms} ms)\n`);
85
- reporter.stderr.write(`${error?.stack ?? String(error)}\n`);
86
- throw error;
87
- }
88
- },
89
- batch(name, run) {
90
- const startedAt = nowMs();
91
- try {
92
- const result = run();
93
- reporter.total += result.total;
94
- reporter.ok += result.passed;
95
- return result;
96
- } catch (error) {
97
- const ms = nowMs() - startedAt;
98
- reporter.stderr.write(`FAIL ${name} (${ms} ms)\n`);
99
- reporter.stderr.write(`${error?.stack ?? String(error)}\n`);
100
- throw error;
101
- }
102
- },
103
- };
104
- }
105
-
106
67
  if (isMainModule(import.meta.url)) {
107
68
  const options = parseArgs(process.argv.slice(2));
108
69
  if (options.help) printHelp();
@@ -48,7 +48,17 @@ export class TestReporter {
48
48
  const total = this.total - this.currentSection.totalAtStart;
49
49
  const ms = elapsedMs ?? nowMs() - this.currentSection.startedAt;
50
50
  const suite = label ?? defaultSectionLabel(this.currentSection.name);
51
- this.stdout.write(`${colors.green}OK${colors.reset} ${ok}/${total} ${suite} tests passed ${colors.dim}(${ms} ms)${colors.reset}\n`);
51
+ // A section can finish without throwing (still an overall OK) while
52
+ // ok < total: a batch may tolerate a small, explicitly named number of
53
+ // documented divergences (see test/neumerkel.mjs's KNOWN_QUAD_DIVERGENCES)
54
+ // rather than requiring every counted item to individually pass. Naming
55
+ // that gap here keeps "OK x/y ... passed" from reading as self-contradictory
56
+ // when x is less than y.
57
+ const shortfall = total - ok;
58
+ const outcome = shortfall === 0
59
+ ? 'passed'
60
+ : `passed with ${shortfall} divergence${shortfall === 1 ? '' : 's'} to be addressed`;
61
+ this.stdout.write(`${colors.green}OK${colors.reset} ${ok}/${total} ${suite} tests ${outcome} ${colors.dim}(${ms} ms)${colors.reset}\n`);
52
62
  }
53
63
 
54
64
  test(name, run) {
@@ -105,7 +115,13 @@ export class TestReporter {
105
115
  totalLine() {
106
116
  const ms = nowMs() - this.startedAt;
107
117
  this.stdout.write(`\n${colors.yellow}== Total${colors.reset}\n`);
108
- this.stdout.write(`${colors.green}OK${colors.reset} ${this.ok}/${this.total} tests passed ${colors.dim}(${ms} ms)${colors.reset}\n`);
118
+ // See the matching note in sectionTotal(): a known, documented divergence
119
+ // can leave ok below total without the run having failed.
120
+ const shortfall = this.total - this.ok;
121
+ const outcome = shortfall === 0
122
+ ? 'passed'
123
+ : `passed with ${shortfall} divergence${shortfall === 1 ? '' : 's'} to be addressed`;
124
+ this.stdout.write(`${colors.green}OK${colors.reset} ${this.ok}/${this.total} tests ${outcome} ${colors.dim}(${ms} ms)${colors.reset}\n`);
109
125
  }
110
126
  }
111
127
 
@@ -9935,7 +9935,7 @@ Review questions:
9935
9935
  </figure>
9936
9936
 
9937
9937
  The [examples directory](https://github.com/eyereasoner/eyeprolog/tree/main/examples/) is the book's executable companion. The
9938
- top-level directory contains **228 self-contained runnable programs**. Every
9938
+ top-level directory contains **229 self-contained runnable programs**. Every
9939
9939
  source program has an exact answer file under
9940
9940
  [examples/output](https://github.com/eyereasoner/eyeprolog/tree/main/examples/output/), and **61 selected programs** have a checked
9941
9941
  explanation under [examples/proof](https://github.com/eyereasoner/eyeprolog/tree/main/examples/proof/). The thematic lists link every top-level program and open the program
@@ -10238,6 +10238,7 @@ decisions, reasons, integrity conditions, and proof.
10238
10238
  | [Access control policy](https://github.com/eyereasoner/eyeprolog/blob/main/examples/access-control-policy.pl) | Attribute and policy facts derive permit status and reasons. | [answers](https://github.com/eyereasoner/eyeprolog/blob/main/examples/output/access-control-policy.pl) · [proof](https://github.com/eyereasoner/eyeprolog/blob/main/examples/proof/access-control-policy.pl) |
10239
10239
  | [Clinical-trial screening](https://github.com/eyereasoner/eyeprolog/blob/main/examples/clinical-trial-screening.pl) | Inclusion and exclusion criteria produce an evidence-backed eligibility result. | [answers](https://github.com/eyereasoner/eyeprolog/blob/main/examples/output/clinical-trial-screening.pl) · [proof](https://github.com/eyereasoner/eyeprolog/blob/main/examples/proof/clinical-trial-screening.pl) |
10240
10240
  | [Data negotiation](https://github.com/eyereasoner/eyeprolog/blob/main/examples/data-negotiation.pl) | Offered and required data conditions derive an agreement or mismatch. | [answers](https://github.com/eyereasoner/eyeprolog/blob/main/examples/output/data-negotiation.pl) · [proof](https://github.com/eyereasoner/eyeprolog/blob/main/examples/proof/data-negotiation.pl) |
10241
+ | [Defeasible reasoning](https://github.com/eyereasoner/eyeprolog/blob/main/examples/defeasible-reasoning.pl) | A reimbursement policy overrides defaults by specificity, then compares three ways to handle one unresolved conflict between two independent defaults: an unstratified `\+/1` cycle, `tnot/1` with WFS's `undefined`, and this codebase's usual explicit conflict predicate. | [answers](https://github.com/eyereasoner/eyeprolog/blob/main/examples/output/defeasible-reasoning.pl) |
10241
10242
  | [Deontic Logic](https://github.com/eyereasoner/eyeprolog/blob/main/examples/deontic-logic.pl) | Deontic logic: obligations, prohibitions, compensations, and violations. | [answers](https://github.com/eyereasoner/eyeprolog/blob/main/examples/output/deontic-logic.pl) · [proof](https://github.com/eyereasoner/eyeprolog/blob/main/examples/proof/deontic-logic.pl) |
10242
10243
  | [GDPR compliance](https://github.com/eyereasoner/eyeprolog/blob/main/examples/gdpr-compliance.pl) | Purpose, basis, and processing facts support compliance conclusions. | [answers](https://github.com/eyereasoner/eyeprolog/blob/main/examples/output/gdpr-compliance.pl) · [proof](https://github.com/eyereasoner/eyeprolog/blob/main/examples/proof/gdpr-compliance.pl) |
10243
10244
  | [Illegitimate Reasoning](https://github.com/eyereasoner/eyeprolog/blob/main/examples/illegitimate-reasoning.pl) | Illegitimate reasoning detector. | [answers](https://github.com/eyereasoner/eyeprolog/blob/main/examples/output/illegitimate-reasoning.pl) |
@@ -10346,7 +10347,7 @@ hand.
10346
10347
 
10347
10348
  #### Running and extending the corpus
10348
10349
 
10349
- Run all 228 normal answer goldens and the 61 selected proof goldens with:
10350
+ Run all 229 normal answer goldens and the 61 selected proof goldens with:
10350
10351
 
10351
10352
  ```sh
10352
10353
  node test/run-examples.mjs
@@ -10445,7 +10446,7 @@ accept texts outside the strict grammar, but it may not reinterpret an accepted
10445
10446
  standard case.
10446
10447
 
10447
10448
  The file-based conformance corpus contains 810 cases, including 393 focused ISO cases derived from the success, failure, mode, and error behavior in ISO/IEC 13211-1 clauses 7 and 8, Part 2 modules, and Part 3 grammar rules.
10448
- Separate exact-output suites check 228 normal examples and 61 proof examples; all executable chapter programs are parsed and their declared goals are executed. The nine-case
10449
+ Separate exact-output suites check 229 normal examples and 61 proof examples; all executable chapter programs are parsed and their declared goals are executed. The nine-case
10449
10450
  playground contract suite imports the production worker, sends real reasoning
10450
10451
  requests through its message protocol, and crawls the served module graph for
10451
10452
  missing assets, bad MIME types, and static Node-only imports. `conformance-report.md` records the current executable WG17 syntax result and file-based conformance category totals.