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.
- package/conformance-report.md +1 -1
- package/examples/defeasible-reasoning.pl +120 -0
- package/examples/output/defeasible-reasoning.pl +6 -0
- package/index.d.ts +9 -0
- package/package.json +1 -1
- package/playground.html +1 -0
- package/src/index.js +1 -1
- package/src/platform.js +34 -0
- package/src/quads.js +14 -1
- package/src/solver.js +50 -7
- package/test/conformance/ISO-COMPLIANCE.md +1 -1
- package/test/conformance/NEUMERKEL-LATEST.md +10 -4
- package/test/conformance/NEUMERKEL-LIVE.md +14 -2
- package/test/conformance/README.md +1 -1
- package/test/fixtures/length_quad.pl +2 -1
- package/test/neumerkel.mjs +102 -38
- package/test/regression/cases-regression.mjs +21 -31
- package/test/run-conformance-report.mjs +1 -1
- package/test/run-neumerkel-tests.mjs +6 -4
- package/test/run-neumerkel.mjs +3 -42
- package/test/test-style.mjs +18 -2
- package/the-art-of-eyeprolog.md +4 -3
package/conformance-report.md
CHANGED
|
@@ -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
|
|
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
package/playground.html
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: **
|
|
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
|
|
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 |
|
|
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** | **
|
|
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
|
|
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. `
|
|
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
|
|
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).
|
package/test/neumerkel.mjs
CHANGED
|
@@ -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
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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: **${
|
|
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
|
|
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
|
|
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
|
-
//
|
|
2415
|
-
//
|
|
2416
|
-
//
|
|
2417
|
-
//
|
|
2418
|
-
//
|
|
2419
|
-
//
|
|
2420
|
-
|
|
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
|
|
2423
|
-
// Max=unbounded. EyeProlog reports no value for
|
|
2424
|
-
// bounded=false, so
|
|
2425
|
-
//
|
|
2426
|
-
//
|
|
2427
|
-
//
|
|
2428
|
-
|
|
2429
|
-
assertEqual(result.
|
|
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:
|
|
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
|
|
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
|
-
|
|
2458
|
-
|
|
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
|
|
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
|
|
32
|
-
if (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 !==
|
|
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('**
|
|
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')) {
|
package/test/run-neumerkel.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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();
|
package/test/test-style.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/the-art-of-eyeprolog.md
CHANGED
|
@@ -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 **
|
|
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
|
|
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
|
|
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.
|