eyeprolog 1.5.20 → 1.5.22

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/index.d.ts CHANGED
@@ -12,6 +12,8 @@ export interface EyePrologRunOptions {
12
12
  /** Host-supplied goals, executed in order. */
13
13
  goals?: Array<string | EyePrologTerm>;
14
14
  proof?: boolean;
15
+ /** Proof detail: abstract keeps library predicates as trusted leaves; expanded opens bundled Prolog library clauses. */
16
+ proofDetail?: 'abstract' | 'expanded';
15
17
  why?: boolean;
16
18
  explain?: boolean;
17
19
  maxDepth?: number;
@@ -310,9 +312,54 @@ export function hasForwardRules(program: Program): boolean;
310
312
  /** Execute EyeProlog `:+/2` rules to closure using an existing solver. */
311
313
  export function executeForwardRules(program: Program, solver: Solver, options?: EyePrologForwardRunOptions): EyePrologForwardRunResult;
312
314
  export function runQuads(source: string | Program, options?: EyePrologQuadRunOptions): EyePrologQuadRunResult;
313
- export function whyProof(program: Program, goal: EyePrologTerm, options?: EyePrologRunOptions): { ok: boolean; text: string };
315
+ export interface EyePrologProofMethod {
316
+ type: 'source' | 'builtin' | 'library' | 'conjunction';
317
+ kind?: 'fact' | 'rule';
318
+ filename?: string;
319
+ clause?: number;
320
+ name?: string;
321
+ arity?: number;
322
+ }
323
+
324
+ export interface EyePrologProofNode {
325
+ goal: string;
326
+ method: EyePrologProofMethod;
327
+ bindings: Array<{ name: string; value: string }>;
328
+ children: EyePrologProofNode[];
329
+ }
330
+
331
+ export interface EyePrologProofCertificate {
332
+ version: 1;
333
+ detail: 'abstract' | 'expanded';
334
+ answer: string;
335
+ proof: EyePrologProofNode;
336
+ }
337
+
338
+ export interface EyePrologProofResult {
339
+ ok: boolean;
340
+ certificate: EyePrologProofCertificate | null;
341
+ text: string;
342
+ }
343
+
344
+ export interface EyePrologProofTrustBoundary {
345
+ type: 'builtin' | 'library';
346
+ name: string;
347
+ arity: number;
348
+ goal: string;
349
+ }
350
+
351
+ export interface EyePrologProofVerification {
352
+ ok: boolean;
353
+ error: string | null;
354
+ trusted: EyePrologProofTrustBoundary[];
355
+ }
356
+
357
+ export function proofCertificate(program: Program, goal: EyePrologTerm, options?: EyePrologRunOptions): EyePrologProofResult;
358
+ export function proofCertificatesFromText(text: string, program: Program): EyePrologProofCertificate[];
359
+ export function verifyProof(program: Program, certificate: EyePrologProofCertificate | EyePrologProofResult, options?: EyePrologRunOptions): EyePrologProofVerification;
360
+ export function whyProof(program: Program, goal: EyePrologTerm, options?: EyePrologRunOptions): EyePrologProofResult;
314
361
  export function whyNoProof(goal: EyePrologTerm): string;
315
- export function explainProof(program: Program, goal: EyePrologTerm, options?: EyePrologRunOptions): { ok: boolean; text: string };
362
+ export function explainProof(program: Program, goal: EyePrologTerm, options?: EyePrologRunOptions): EyePrologProofResult;
316
363
 
317
364
  declare const eyeprolog: {
318
365
  VAR: typeof VAR;
@@ -386,6 +433,9 @@ declare const eyeprolog: {
386
433
  hasForwardRules: typeof hasForwardRules;
387
434
  executeForwardRules: typeof executeForwardRules;
388
435
  runQuads: typeof runQuads;
436
+ proofCertificate: typeof proofCertificate;
437
+ proofCertificatesFromText: typeof proofCertificatesFromText;
438
+ verifyProof: typeof verifyProof;
389
439
  whyProof: typeof whyProof;
390
440
  whyNoProof: typeof whyNoProof;
391
441
  explainProof: typeof explainProof;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.5.20",
6
+ "version": "1.5.22",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
@@ -21,6 +21,12 @@ higher-level frontends.
21
21
  5. **Frontends/tools** — `execute.js`, `repl.js`, `cli.js`, `quads.js`,
22
22
  `explain.js`, and the playground worker.
23
23
 
24
+ `explain.js` owns both proof construction and proof-certificate verification.
25
+ Verification walks the supplied certificate and checks source-clause steps against
26
+ the parsed `Program`; it does not call the solver to rediscover the proof.
27
+ Built-in and abstract library nodes are explicit trust boundaries, while expanded
28
+ proofs expose bundled Prolog-library clauses as ordinary source steps.
29
+
24
30
  `iso.js` and `program.js` remain facade modules for their existing exports, so
25
31
  this refactor does not change the public JavaScript API.
26
32
 
package/src/cli.js CHANGED
@@ -25,6 +25,8 @@ export async function main(argv) {
25
25
  const options = {
26
26
  files: [],
27
27
  proof: false,
28
+ proofDetail: 'abstract',
29
+ verifyProof: null,
28
30
  quads: false,
29
31
  stats: false,
30
32
  isoStrict: false,
@@ -47,6 +49,15 @@ export async function main(argv) {
47
49
  return;
48
50
  } else if (!endOptions && (arg === '--proof' || arg === '-p')) {
49
51
  options.proof = true;
52
+ } else if (!endOptions && arg === '--proof-detail') {
53
+ const detail = argv[++i];
54
+ if (detail !== 'abstract' && detail !== 'expanded') throw new Error('--proof-detail requires abstract or expanded');
55
+ options.proof = true;
56
+ options.proofDetail = detail;
57
+ } else if (!endOptions && arg === '--verify-proof') {
58
+ const file = argv[++i];
59
+ if (file == null) throw new Error('--verify-proof requires a file');
60
+ options.verifyProof = file;
50
61
  } else if (!endOptions && (arg === '--quads' || arg === '-q')) {
51
62
  options.quads = true;
52
63
  } else if (!endOptions && (arg === '--stats' || arg === '-s')) {
@@ -94,9 +105,18 @@ export async function main(argv) {
94
105
  if (options.isoStrict && options.quads) {
95
106
  throw new Error('--iso-strict cannot be combined with --quads');
96
107
  }
108
+ if (options.verifyProof != null && options.quads) {
109
+ throw new Error('--verify-proof cannot be combined with --quads');
110
+ }
111
+ if (options.verifyProof != null && options.proof) {
112
+ throw new Error('--verify-proof cannot be combined with --proof or --proof-detail');
113
+ }
114
+ if (options.verifyProof != null && options.goals.length > 0) {
115
+ throw new Error('--verify-proof cannot be combined with --goal');
116
+ }
97
117
 
98
118
  if (options.isoStrict && options.files.length === 0 && options.goals.length === 0 &&
99
- !options.proof && !options.stats && !options.warnings) {
119
+ options.verifyProof == null && !options.proof && !options.stats && !options.warnings) {
100
120
  const engine = await loadEngine();
101
121
  const { runRepl } = await import('./repl.js');
102
122
  const exitCode = await runRepl(engine, {
@@ -134,7 +154,7 @@ export async function main(argv) {
134
154
  }
135
155
  }
136
156
 
137
- if (options.goals.length === 0 && !options.quads) {
157
+ if (options.goals.length === 0 && !options.quads && options.verifyProof == null) {
138
158
  for (const source of sourceParts) options.goals.push(...goalsFromSource(source.text));
139
159
  }
140
160
 
@@ -150,7 +170,7 @@ export async function main(argv) {
150
170
 
151
171
  const engine = await loadEngine();
152
172
  let program = engine.Program.parseSources(sourceParts, {
153
- sourceMetadata: options.proof || options.isoStrict,
173
+ sourceMetadata: options.proof || options.verifyProof != null || options.isoStrict,
154
174
  isoStrict: options.isoStrict,
155
175
  autoload: options.autoload,
156
176
  autoloadGoals: options.goals,
@@ -164,6 +184,20 @@ export async function main(argv) {
164
184
  return;
165
185
  }
166
186
 
187
+ if (options.verifyProof != null) {
188
+ const explanation = await loadExplanation();
189
+ const proofText = await fs.readFile(options.verifyProof, 'utf8');
190
+ const certificates = explanation.proofCertificatesFromText(proofText, program);
191
+ if (certificates.length === 0) throw new Error(`no why/2 proof certificate found in ${options.verifyProof}`);
192
+ const registry = options.isoStrict ? engine.getStrictIsoRegistry() : engine.getEyePrologRegistry();
193
+ for (let i = 0; i < certificates.length; i++) {
194
+ const checked = explanation.verifyProof(program, certificates[i], { registry });
195
+ if (!checked.ok) throw new Error(`proof certificate ${i + 1} failed verification: ${checked.error}`);
196
+ }
197
+ process.stdout.write(`verified ${certificates.length} proof certificate${certificates.length === 1 ? '' : 's'}.\n`);
198
+ return;
199
+ }
200
+
167
201
  if (!options.quads || options.goals.length > 0) {
168
202
  if (options.goals.length === 0 && !options.isoStrict && engine.hasForwardRules(program)) {
169
203
  await runForwardDefault(engine, program, options);
@@ -244,7 +278,7 @@ async function runDefault(engine, program, options) {
244
278
  const { haltCode } = engine.executeGoals(program, solver, goals, {
245
279
  onAnswer: (line, resolved) => {
246
280
  process.stdout.write(line);
247
- if (options.proof) writeExplanation(explanation, program, resolved, registry);
281
+ if (options.proof) writeExplanation(explanation, program, resolved, registry, options);
248
282
  },
249
283
  });
250
284
  if (haltCode != null) process.exitCode = haltCode;
@@ -253,8 +287,8 @@ async function runDefault(engine, program, options) {
253
287
  }
254
288
  }
255
289
 
256
- function writeExplanation(explanation, program, resolved, registry) {
257
- const proof = explanation.whyProof(program, resolved, { registry });
290
+ function writeExplanation(explanation, program, resolved, registry, options = {}) {
291
+ const proof = explanation.whyProof(program, resolved, { registry, proofDetail: options?.proofDetail ?? 'abstract' });
258
292
  process.stdout.write(proof.text);
259
293
  if (!proof.ok) process.stdout.write(explanation.whyNoProof(resolved));
260
294
  }
@@ -276,6 +310,8 @@ Input:
276
310
  Options:
277
311
  -h, --help Show this help text and exit.
278
312
  -p, --proof Enable proof explanations.
313
+ --proof-detail mode Use abstract or expanded proof detail (implies --proof).
314
+ --verify-proof file Verify why/2 proof certificates against the input program.
279
315
  -q, --quads Run embedded quad tests and fail if any do not hold.
280
316
  -s, --stats Print solver and memory statistics to stderr after execution.
281
317
  --iso-strict Use ISO/IEC 13211-1 core + Corrigenda 1-3 only;
package/src/explain.js CHANGED
@@ -2,19 +2,34 @@
2
2
  // The explanation printer replays a successful goal against the program and emits
3
3
  // ordinary EyeProlog facts with nested proof terms. Explanations are therefore both
4
4
  // human-readable and machine-readable.
5
- import { ATOM, COMPOUND, Env, Term, VAR, deref, flattenConjunction, freshTerm, termToString, unify, variantTerms } from './term.js';
5
+ import { ATOM, COMPOUND, Env, Term, VAR, deref, flattenConjunction, freshTerm, properListItems, termToString, unify, variantTerms } from './term.js';
6
6
  import { selectClauseCandidates } from './program.js';
7
+ import { parseGoalText, parseProgramText } from './parser.js';
7
8
  import { getEyePrologRegistry } from './standard-library.js';
8
9
  import { Solver, nextFreshId } from './solver.js';
9
10
 
10
- export function whyProof(program, goal, options = {}) {
11
+ let verifyFreshCounter = 0;
12
+
13
+ export function proofCertificate(program, goal, options = {}) {
11
14
  const maxDepth = options.maxDepth ?? 256;
12
15
  const registry = options.registry ?? getEyePrologRegistry();
13
16
  const env = options.env ?? new Env();
14
- for (const proof of proveGoalAll(program, goal, env, 0, maxDepth, registry, [])) {
15
- return { ok: true, text: renderWhyFacts(goal, proof.node, proof.env) };
17
+ const detail = normalizeProofDetail(options.proofDetail ?? 'abstract');
18
+ for (const proof of proveGoalAll(program, goal, env, 0, maxDepth, registry, [], detail)) {
19
+ const answer = resolveForProof(goal, proof.env);
20
+ const certificate = {
21
+ version: 1,
22
+ detail,
23
+ answer: termToString(answer, new Env(), true),
24
+ proof: certificateNode(proof.node),
25
+ };
26
+ return { ok: true, certificate, text: renderWhyFacts(goal, proof.node, proof.env) };
16
27
  }
17
- return { ok: false, text: '' };
28
+ return { ok: false, certificate: null, text: '' };
29
+ }
30
+
31
+ export function whyProof(program, goal, options = {}) {
32
+ return proofCertificate(program, goal, options);
18
33
  }
19
34
 
20
35
  export function whyNoProof(goal) {
@@ -26,11 +41,11 @@ export function explainProof(program, goal, options = {}) {
26
41
  return whyProof(program, goal, options);
27
42
  }
28
43
 
29
- function* proveGoalAll(program, goal, env, depth, maxDepth, registry, active) {
44
+ function* proveGoalAll(program, goal, env, depth, maxDepth, registry, active, detail) {
30
45
  if (depth > maxDepth) return;
31
46
 
32
47
  if (goal.type === COMPOUND && goal.name === ',' && goal.arity === 2) {
33
- for (const proved of proveGoalsAll(program, flattenConjunction(goal), env, depth + 1, maxDepth, registry, active)) {
48
+ for (const proved of proveGoalsAll(program, flattenConjunction(goal), env, depth + 1, maxDepth, registry, active, detail)) {
34
49
  yield {
35
50
  env: proved.env,
36
51
  node: {
@@ -58,7 +73,7 @@ function* proveGoalAll(program, goal, env, depth, maxDepth, registry, active) {
58
73
  sourceHead: resolveForProof(goal, proofEnv),
59
74
  sourceBody: [],
60
75
  bindings: [],
61
- children: builtinChildren(program, goal, proofEnv, depth + 1, maxDepth, registry, active),
76
+ children: builtinChildren(program, goal, proofEnv, depth + 1, maxDepth, registry, active, detail),
62
77
  },
63
78
  };
64
79
  }
@@ -74,7 +89,7 @@ function* proveGoalAll(program, goal, env, depth, maxDepth, registry, active) {
74
89
  // a standard Prolog module. The implementation remains
75
90
  // ordinary clauses, but explanations collapse its private helper expansion
76
91
  // behind an explicit library(Name, Arity) boundary.
77
- if (group.module !== 'user' && program.modules.get(group.module)?.filename?.startsWith('src/lib/')) {
92
+ if (detail !== 'expanded' && group.module !== 'user' && program.modules.get(group.module)?.filename?.startsWith('src/lib/')) {
78
93
  const solver = new Solver(program, { registry });
79
94
  for (const next of solver.solve([goal], env.clone(), 0)) {
80
95
  const proofEnv = next.clone ? next.clone() : next;
@@ -128,7 +143,7 @@ function* proveGoalAll(program, goal, env, depth, maxDepth, registry, active) {
128
143
  let activePushed = true;
129
144
  active.push({ goal, env });
130
145
  try {
131
- for (const proved of proveGoalsAll(program, freshBody, next, depth + 1, maxDepth, registry, active)) {
146
+ for (const proved of proveGoalsAll(program, freshBody, next, depth + 1, maxDepth, registry, active, detail)) {
132
147
  active.pop();
133
148
  activePushed = false;
134
149
  yield {
@@ -160,7 +175,7 @@ function clauseCandidateAt(candidate, index) {
160
175
  return Array.isArray(candidate) ? candidate[index] : index === 0 ? candidate : undefined;
161
176
  }
162
177
 
163
- function* proveGoalsAll(program, goals, env, depth, maxDepth, registry, active) {
178
+ function* proveGoalsAll(program, goals, env, depth, maxDepth, registry, active, detail) {
164
179
  if (goals.length === 0) {
165
180
  yield { env: env.clone(), children: [] };
166
181
  return;
@@ -170,8 +185,8 @@ function* proveGoalsAll(program, goals, env, depth, maxDepth, registry, active)
170
185
  const goal = goals[selectedIndex];
171
186
  const rest = selectedIndex === 0 ? goals.slice(1) : [...goals.slice(0, selectedIndex), ...goals.slice(selectedIndex + 1)];
172
187
 
173
- for (const proved of proveGoalAll(program, goal, env, depth, maxDepth, registry, active)) {
174
- for (const tail of proveGoalsAll(program, rest, proved.env, depth, maxDepth, registry, active)) {
188
+ for (const proved of proveGoalAll(program, goal, env, depth, maxDepth, registry, active, detail)) {
189
+ for (const tail of proveGoalsAll(program, rest, proved.env, depth, maxDepth, registry, active, detail)) {
175
190
  const children = tail.children.slice();
176
191
  children.splice(selectedIndex, 0, proved.node);
177
192
  yield { env: tail.env, children };
@@ -212,10 +227,10 @@ function selectReadyDeterministicBuiltin(goals, env, registry) {
212
227
  return 0;
213
228
  }
214
229
 
215
- function builtinChildren(program, goal, env, depth, maxDepth, registry, active) {
230
+ function builtinChildren(program, goal, env, depth, maxDepth, registry, active, detail) {
216
231
  if (goal.type !== COMPOUND) return [];
217
232
  if (goal.name === 'once' && goal.arity === 1) {
218
- for (const proved of proveGoalAll(program, goal.args[0], env.clone(), depth, maxDepth, registry, active)) return [proved.node];
233
+ for (const proved of proveGoalAll(program, goal.args[0], env.clone(), depth, maxDepth, registry, active, detail)) return [proved.node];
219
234
  }
220
235
  return [];
221
236
  }
@@ -250,6 +265,291 @@ function libraryMethod(goal) {
250
265
  };
251
266
  }
252
267
 
268
+ function normalizeProofDetail(value) {
269
+ if (value === 'abstract' || value === 'expanded') return value;
270
+ throw new Error(`unknown proof detail: ${value}`);
271
+ }
272
+
273
+ function certificateNode(node) {
274
+ return {
275
+ goal: termToString(node.goal, new Env(), true),
276
+ method: certificateMethod(node.method),
277
+ bindings: node.bindings.map((binding) => ({
278
+ name: String(binding.name),
279
+ value: termToString(binding.value, new Env(), true),
280
+ })),
281
+ children: node.children.map(certificateNode),
282
+ };
283
+ }
284
+
285
+ function certificateMethod(method) {
286
+ if (typeof method === 'string') return { type: method };
287
+ if (!method || typeof method !== 'object') return { type: String(method) };
288
+ return { ...method };
289
+ }
290
+
291
+ export function proofCertificatesFromText(text, program) {
292
+ const clauses = parseProgramText(String(text), {
293
+ doubleQuotes: program.doubleQuotes ?? 'chars',
294
+ sourceMetadata: false,
295
+ });
296
+ const certificates = [];
297
+ for (const clause of clauses) {
298
+ if (!clause?.head || clause.body?.length !== 0 || clause.head.type !== COMPOUND ||
299
+ clause.head.name !== 'why' || clause.head.arity !== 2) continue;
300
+ const proof = certificateNodeFromTerm(clause.head.args[1]);
301
+ if (!proof) continue;
302
+ certificates.push({
303
+ version: 1,
304
+ detail: containsExpandedLibrarySource(proof) ? 'expanded' : 'abstract',
305
+ answer: termToString(clause.head.args[0], new Env(), true),
306
+ proof,
307
+ });
308
+ }
309
+ return certificates;
310
+ }
311
+
312
+ function certificateNodeFromTerm(term) {
313
+ if (term.type !== COMPOUND || term.name !== 'proof') return null;
314
+ const fields = term.args;
315
+ if (fields.length < 2) return null;
316
+ const goalField = fields[0];
317
+ const byField = fields[1];
318
+ if (goalField.type !== COMPOUND || goalField.name !== 'goal' || goalField.arity !== 1) return null;
319
+ if (byField.type !== COMPOUND || byField.name !== 'by' || byField.arity !== 1) return null;
320
+ const node = {
321
+ goal: termToString(goalField.args[0], new Env(), true),
322
+ method: certificateMethodFromTerm(byField.args[0]),
323
+ bindings: [],
324
+ children: [],
325
+ };
326
+ if (!node.method) return null;
327
+ for (let i = 2; i < fields.length; i++) {
328
+ const field = fields[i];
329
+ if (field.type !== COMPOUND) return null;
330
+ if (field.name === 'bindings' && field.arity === 1) {
331
+ const items = properListItems(field.args[0], new Env());
332
+ if (items == null) return null;
333
+ node.bindings = items.map((item) => {
334
+ if (item.type !== COMPOUND || item.name !== 'binding' || item.arity !== 2) throw new Error('malformed certificate binding');
335
+ return { name: certificateText(item.args[0]), value: termToString(item.args[1], new Env(), true) };
336
+ });
337
+ } else if (field.name === 'uses' && field.arity === 1) {
338
+ const items = properListItems(field.args[0], new Env());
339
+ if (items == null) return null;
340
+ node.children = items.map(certificateNodeFromTerm);
341
+ if (node.children.some((child) => child == null)) return null;
342
+ } else {
343
+ return null;
344
+ }
345
+ }
346
+ return node;
347
+ }
348
+
349
+ function certificateMethodFromTerm(term) {
350
+ if (term.type === ATOM && term.name === 'conjunction') return { type: 'conjunction' };
351
+ if (term.type !== COMPOUND) return null;
352
+ if ((term.name === 'fact' || term.name === 'rule') && term.arity === 2) {
353
+ const clause = term.args[1];
354
+ if (clause.type !== COMPOUND || clause.name !== 'clause' || clause.arity !== 1) return null;
355
+ return { type: 'source', kind: term.name, filename: certificateText(term.args[0]), clause: Number(clause.args[0].name) };
356
+ }
357
+ if ((term.name === 'builtin' || term.name === 'library') && term.arity === 2) {
358
+ return { type: term.name, name: String(term.args[0].name), arity: Number(term.args[1].name) };
359
+ }
360
+ return null;
361
+ }
362
+
363
+ function certificateText(term) {
364
+ if (term.type === ATOM || term.type === 'string') return String(term.name);
365
+ const items = properListItems(term, new Env());
366
+ if (items != null && items.every((item) => item.type === ATOM && String(item.name).length === 1)) {
367
+ return items.map((item) => String(item.name)).join('');
368
+ }
369
+ if (items != null && items.every((item) => item.type === 'number' && /^\d+$/.test(String(item.name)))) {
370
+ const codes = items.map((item) => Number(item.name));
371
+ if (codes.every((code) => Number.isInteger(code) && code >= 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff))) {
372
+ return String.fromCodePoint(...codes);
373
+ }
374
+ }
375
+ throw new Error('expected certificate text');
376
+ }
377
+
378
+ function containsLibraryBoundary(node) {
379
+ if (node.method?.type === 'library') return true;
380
+ return node.children.some(containsLibraryBoundary);
381
+ }
382
+
383
+ function containsExpandedLibrarySource(node) {
384
+ if (node.method?.type === 'source' && String(node.method.filename ?? '').startsWith('src/lib/')) return true;
385
+ return node.children.some(containsExpandedLibrarySource);
386
+ }
387
+
388
+ export function verifyProof(program, input, options = {}) {
389
+ const certificate = input?.certificate ?? input;
390
+ try {
391
+ if (!certificate || certificate.version !== 1 || typeof certificate.answer !== 'string' || !certificate.proof ||
392
+ (certificate.detail !== 'abstract' && certificate.detail !== 'expanded')) {
393
+ throw new Error('invalid proof certificate');
394
+ }
395
+ if (certificate.detail === 'abstract' && containsExpandedLibrarySource(certificate.proof)) {
396
+ throw new Error('abstract proof certificate contains expanded library source');
397
+ }
398
+ if (certificate.detail === 'expanded' && containsLibraryBoundary(certificate.proof)) {
399
+ throw new Error('expanded proof certificate contains an abstract library boundary');
400
+ }
401
+ const registry = options.registry ?? getEyePrologRegistry();
402
+ const answer = parseCertificateTerm(certificate.answer, program);
403
+ const rootGoal = parseCertificateTerm(certificate.proof.goal, program);
404
+ const answerEnv = new Env();
405
+ if (!unify(answer, rootGoal, answerEnv)) throw new Error('certificate answer does not match root goal');
406
+ verifyCertificateNode(program, certificate.proof, rootGoal, registry, options, new Env());
407
+ return { ok: true, error: null, trusted: collectTrustedBoundaries(certificate.proof) };
408
+ } catch (error) {
409
+ return { ok: false, error: error instanceof Error ? error.message : String(error), trusted: [] };
410
+ }
411
+ }
412
+
413
+ function collectTrustedBoundaries(node, out = []) {
414
+ if (node.method?.type === 'builtin' || node.method?.type === 'library') {
415
+ out.push({
416
+ type: node.method.type,
417
+ name: String(node.method.name),
418
+ arity: Number(node.method.arity),
419
+ goal: String(node.goal),
420
+ });
421
+ }
422
+ for (const child of node.children) collectTrustedBoundaries(child, out);
423
+ return out;
424
+ }
425
+
426
+ function parseCertificateTerm(text, program) {
427
+ return parseGoalText(String(text), { doubleQuotes: program.doubleQuotes ?? 'chars' });
428
+ }
429
+
430
+ function verifyCertificateNode(program, node, expectedGoal, registry, options, inheritedEnv) {
431
+ if (!node || typeof node.goal !== 'string' || !node.method || !Array.isArray(node.children) || !Array.isArray(node.bindings)) {
432
+ throw new Error('malformed proof node');
433
+ }
434
+ const nodeGoal = parseCertificateTerm(node.goal, program);
435
+ if (!unify(expectedGoal, nodeGoal, inheritedEnv)) throw new Error(`proof goal mismatch: ${node.goal}`);
436
+ const method = node.method;
437
+
438
+ if (method.type === 'conjunction') {
439
+ if (nodeGoal.type !== COMPOUND || nodeGoal.name !== ',' || nodeGoal.arity !== 2) throw new Error('invalid conjunction proof');
440
+ const goals = flattenConjunction(nodeGoal);
441
+ if (goals.length !== node.children.length) throw new Error('conjunction child count mismatch');
442
+ for (let i = 0; i < goals.length; i++) {
443
+ const childGoal = parseCertificateTerm(node.children[i].goal, program);
444
+ if (!unify(goals[i], childGoal, inheritedEnv)) throw new Error(`conjunction child ${i + 1} does not match`);
445
+ verifyCertificateNode(program, node.children[i], childGoal, registry, options, new Env());
446
+ }
447
+ if (node.bindings.length !== 0) throw new Error('conjunction proof must not carry clause bindings');
448
+ return;
449
+ }
450
+
451
+ if (method.type === 'builtin') {
452
+ const name = nodeGoal.type === COMPOUND || nodeGoal.type === ATOM ? nodeGoal.name : null;
453
+ const arity = nodeGoal.type === COMPOUND ? nodeGoal.arity : 0;
454
+ if (method.name !== name || method.arity !== arity || registry.get(name, arity) == null) {
455
+ throw new Error(`untrusted builtin boundary: ${method.name}/${method.arity}`);
456
+ }
457
+ if (node.bindings.length !== 0) throw new Error('builtin proof must not carry clause bindings');
458
+ if (name === 'once' && arity === 1) {
459
+ if (node.children.length !== 1) throw new Error('once/1 proof requires exactly one child');
460
+ const childGoal = parseCertificateTerm(node.children[0].goal, program);
461
+ const innerEnv = new Env();
462
+ if (!unify(nodeGoal.args[0], childGoal, innerEnv)) throw new Error('once/1 child does not match called goal');
463
+ verifyCertificateNode(program, node.children[0], childGoal, registry, options, new Env());
464
+ } else if (node.children.length !== 0) {
465
+ throw new Error(`unexpected children for builtin ${name}/${arity}`);
466
+ }
467
+ return;
468
+ }
469
+
470
+ if (method.type === 'library') {
471
+ const name = nodeGoal.type === COMPOUND || nodeGoal.type === ATOM ? nodeGoal.name : null;
472
+ const arity = nodeGoal.type === COMPOUND ? nodeGoal.arity : 0;
473
+ if (method.name !== name || method.arity !== arity) throw new Error('library boundary does not match goal');
474
+ const group = findLibraryGroup(program, name, arity);
475
+ if (!group) throw new Error(`untrusted library boundary: ${name}/${arity}`);
476
+ if (node.children.length !== 0 || node.bindings.length !== 0) throw new Error('abstract library proof must be a leaf');
477
+ return;
478
+ }
479
+
480
+ if (method.type === 'source') {
481
+ verifySourceNode(program, node, nodeGoal, registry, options);
482
+ return;
483
+ }
484
+
485
+ throw new Error(`unknown proof method: ${method.type}`);
486
+ }
487
+
488
+ function findLibraryGroup(program, name, arity) {
489
+ for (const group of program.groups.values()) {
490
+ if (group.name !== name || group.arity !== arity || group.module === 'user') continue;
491
+ if (program.modules.get(group.module)?.filename?.startsWith('src/lib/')) return group;
492
+ }
493
+ return null;
494
+ }
495
+
496
+ function verifySourceNode(program, node, nodeGoal, registry, options) {
497
+ const method = node.method;
498
+ const candidates = program.clauses.filter((clause) => {
499
+ const source = clause.source ?? {};
500
+ const filename = source.filename ?? '<input>';
501
+ const clauseNumber = source.clause ?? ((clause.index ?? 0) + 1);
502
+ return filename === method.filename && clauseNumber === method.clause;
503
+ });
504
+ if (candidates.length === 0) throw new Error(`source clause not found: ${method.filename}:${method.clause}`);
505
+
506
+ for (const clause of candidates) {
507
+ const kind = clause.body.length === 0 ? 'fact' : 'rule';
508
+ if (method.kind !== kind) continue;
509
+ const id = `verify${++verifyFreshCounter}`;
510
+ const variables = new Map();
511
+ const head = freshTerm(clause.head, id, variables);
512
+ const body = clause.body.map((term) => freshTerm(term, id, variables));
513
+ const env = new Env();
514
+ if (!unify(head, nodeGoal, env)) continue;
515
+ if (body.length !== node.children.length) continue;
516
+
517
+ let valid = true;
518
+ for (let i = 0; i < body.length; i++) {
519
+ try {
520
+ const childGoal = parseCertificateTerm(node.children[i].goal, program);
521
+ if (!unify(body[i], childGoal, env)) { valid = false; break; }
522
+ verifyCertificateNode(program, node.children[i], childGoal, registry, options, new Env());
523
+ } catch {
524
+ valid = false;
525
+ break;
526
+ }
527
+ }
528
+ if (!valid) continue;
529
+ if (!verifyBindings(node.bindings, variables, env, program)) continue;
530
+ return;
531
+ }
532
+ throw new Error(`source proof does not validate: ${node.goal}`);
533
+ }
534
+
535
+ function verifyBindings(bindings, variables, env, program) {
536
+ const expected = new Map();
537
+ for (const [name, fresh] of variables.entries()) {
538
+ const resolved = deref(fresh, env);
539
+ if (resolved.type !== VAR) expected.set(name, resolved);
540
+ }
541
+ if (bindings.length !== expected.size) return false;
542
+ for (const binding of bindings) {
543
+ if (!binding || typeof binding.name !== 'string' || typeof binding.value !== 'string') return false;
544
+ const resolved = expected.get(binding.name);
545
+ if (!resolved) return false;
546
+ const value = parseCertificateTerm(binding.value, program);
547
+ const check = new Env();
548
+ if (!unify(resolveForProof(resolved, env), value, check)) return false;
549
+ }
550
+ return true;
551
+ }
552
+
253
553
  function renderMethodTerm(method) {
254
554
  if (method && method.type === 'source') return `${method.kind}(${quoteString(method.filename)}, clause(${method.clause}))`;
255
555
  if (method && method.type === 'builtin') return `builtin(${quoteAtomText(method.name)}, ${method.arity})`;
package/src/index.js CHANGED
@@ -79,7 +79,7 @@ export function run(source, options = {}) {
79
79
  ({ haltCode } = executeForwardRules(program, solver, {
80
80
  onAnswer: (line, resolved) => {
81
81
  output.push(line);
82
- if (includeWhy) appendExplanation(output, program, resolved, runOptions.registry);
82
+ if (includeWhy) appendExplanation(output, program, resolved, runOptions.registry, options.proofDetail);
83
83
  },
84
84
  onFuse: (line) => output.push(line),
85
85
  onDiagnostic: (line) => {
@@ -91,15 +91,15 @@ export function run(source, options = {}) {
91
91
  ({ haltCode } = executeGoals(program, solver, goals, {
92
92
  onAnswer: (line, resolved) => {
93
93
  output.push(line);
94
- if (includeWhy) appendExplanation(output, program, resolved, runOptions.registry);
94
+ if (includeWhy) appendExplanation(output, program, resolved, runOptions.registry, options.proofDetail);
95
95
  },
96
96
  }));
97
97
  }
98
98
  return { stdout: output.join(''), stats: solver.stats, haltCode };
99
99
  }
100
100
 
101
- function appendExplanation(output, program, resolved, registry) {
102
- const proof = whyProof(program, resolved, { registry });
101
+ function appendExplanation(output, program, resolved, registry, proofDetail = 'abstract') {
102
+ const proof = whyProof(program, resolved, { registry, proofDetail });
103
103
  output.push(proof.text);
104
104
  if (!proof.ok) output.push(whyNoProof(resolved));
105
105
  }
package/src/write.js CHANGED
@@ -152,6 +152,20 @@ function writeString(value) {
152
152
  return out + '"';
153
153
  }
154
154
 
155
+ function quotedListCharacter(item, doubleQuotes) {
156
+ if (doubleQuotes === 'chars') {
157
+ if (item.type !== ATOM || Array.from(item.name).length !== 1) return null;
158
+ return item.name;
159
+ }
160
+ if (doubleQuotes === 'codes') {
161
+ if (item.type !== NUMBER || !/^\d+$/.test(item.name)) return null;
162
+ const code = BigInt(item.name);
163
+ if (code < 0n || code > 0x10ffffn || (code >= 0xd800n && code <= 0xdfffn)) return null;
164
+ return String.fromCodePoint(Number(code));
165
+ }
166
+ return null;
167
+ }
168
+
155
169
  function quotedListSplice(term, env, doubleQuotes) {
156
170
  if (doubleQuotes !== 'chars' && doubleQuotes !== 'codes') return null;
157
171
  const characters = [];
@@ -164,16 +178,9 @@ function quotedListSplice(term, env, doubleQuotes) {
164
178
  if (!isCons(cursor)) {
165
179
  return characters.length === 0 ? null : { text: characters.join(''), tail: cursor };
166
180
  }
167
- const item = deref(cursor.args[0], env);
168
- if (doubleQuotes === 'chars') {
169
- if (item.type !== ATOM || Array.from(item.name).length !== 1) return null;
170
- characters.push(item.name);
171
- } else {
172
- if (item.type !== NUMBER || !/^\d+$/.test(item.name)) return null;
173
- const code = BigInt(item.name);
174
- if (code < 0n || code > 0x10ffffn || (code >= 0xd800n && code <= 0xdfffn)) return null;
175
- characters.push(String.fromCodePoint(Number(code)));
176
- }
181
+ const character = quotedListCharacter(deref(cursor.args[0], env), doubleQuotes);
182
+ if (character == null) return null;
183
+ characters.push(character);
177
184
  cursor = cursor.args[1];
178
185
  }
179
186
  }
@@ -351,15 +358,26 @@ function format(term, env, options, table, maxPriority = 1200, context = 'term')
351
358
 
352
359
  if (!options.ignoreOps) {
353
360
  const parts = [];
361
+ let quotedSuffix = [];
354
362
  let cursor = resolved;
355
363
  while (true) {
356
364
  cursor = deref(cursor, env);
357
365
  const separator = options.compact ? ',' : ', ';
358
- if (isEmptyList(cursor)) return `[${parts.join(separator)}]`;
366
+ if (isEmptyList(cursor)) {
367
+ if (quotedSuffix.length > 1 && quotedSuffix.length < parts.length) {
368
+ const prefix = parts.slice(0, parts.length - quotedSuffix.length);
369
+ return `[${prefix.join(separator)}|${writeString(quotedSuffix.join(''))}]`;
370
+ }
371
+ return `[${parts.join(separator)}]`;
372
+ }
359
373
  if (!isCons(cursor)) {
360
374
  const tailSeparator = options.compact ? '|' : ' | ';
361
375
  return `[${parts.join(separator)}${tailSeparator}${format(cursor, env, options, table, 999, 'argument')}]`;
362
376
  }
377
+ const item = deref(cursor.args[0], env);
378
+ const character = quotedListCharacter(item, options.doubleQuotes);
379
+ if (character == null) quotedSuffix = [];
380
+ else quotedSuffix.push(character);
363
381
  parts.push(format(cursor.args[0], env, options, table, 999, 'argument'));
364
382
  cursor = cursor.args[1];
365
383
  }
@@ -124,7 +124,7 @@ families; `--iso-strict` is intended to remove their Part 1 interpretation.
124
124
  | 5.5.9 Built-in predicates | EyeProlog libraries, CLP(Z), statistics, Part 3 `phrase/2-3`, and bundled-library autoloaded predicates | Strict registry contains only the Part 1 + Corrigenda core registry. |
125
125
  | 5.5.10 Evaluable functors | Normal mode additionally accepts the EyeProlog evaluable atom `e`; the remaining arithmetic functors accepted by strict mode are the Part 1 + Corrigenda set. | Strict mode rejects `e/0` as non-evaluable and retains the Corrigendum arithmetic additions. **covered** — strict extension-boundary regression plus `src/iso-arithmetic.js`. |
126
126
  | 5.5.11 Reserved atoms | None | None. |
127
- | Cor.3 5.5.12 Options | Extra library/host options may exist outside core option lists | Normal mode additionally accepts the EyeProlog `write_term/2-3` options `double_quotes(true|false)` and `spacing(true|false)`. `double_quotes(true)` is orthogonal to `ignore_ops(true)`: eligible character/code lists retain double-quoted notation while operator terms use functional notation, and the order of those distinct options does not affect the result. `spacing(false)` emits only lexically required separators; `spacing(true)` adds conventional layout around operators. Strict core excludes these implementation-specific extensions and accepts only the Part 1 plus Corrigendum 3 write-option surface; unknown extension options raise `domain_error(write_option,...)`. |
127
+ | Cor.3 5.5.12 Options | Extra library/host options may exist outside core option lists | Normal mode additionally accepts the EyeProlog `write_term/2-3` options `double_quotes(true|false)` and `spacing(true|false)`. `double_quotes(true)` is orthogonal to `ignore_ops(true)`: eligible character/code lists, including a proper list's maximal character/code suffix of at least two elements, retain double-quoted notation while operator terms use functional notation, and the order of those distinct options does not affect the result. `spacing(false)` emits only lexically required separators; `spacing(true)` adds conventional layout around operators. Strict core excludes these implementation-specific extensions and accepts only the Part 1 plus Corrigendum 3 write-option surface; unknown extension options raise `domain_error(write_option,...)`. |
128
128
 
129
129
  Normal mode provides documented module and DCG compatibility profiles whose
130
130
  features overlap standardized Part 2 and Part 3 facilities. They are extensions
@@ -1 +1 @@
1
- univ_results(foo(a, b), [foo, a, b], "a", ['.', 1, []], [1.0]).
1
+ univ_results(foo(a, b), [foo|"ab"], "a", ['.', 1, []], [1.0]).
@@ -47,6 +47,9 @@ import {
47
47
  unify,
48
48
  variantTerms,
49
49
  parseProgramText,
50
+ proofCertificate,
51
+ proofCertificatesFromText,
52
+ verifyProof,
50
53
  } from '../src/index.js';
51
54
  import { ISO_OPERATOR_DEFINITIONS, parseGoalText, parseNumberTokenText, tryParseClausesFastInto } from '../src/parser.js';
52
55
  import { PrologError, formalErrorTerm } from '../src/iso.js';
@@ -73,6 +76,10 @@ const testRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
73
76
  const packageRoot = path.resolve(testRoot, '..');
74
77
  const bin = path.join(packageRoot, 'bin', 'eyeprolog.js');
75
78
  const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
79
+ // The issue #49 hand-off checks assert demand-driven completion and bounded memory,
80
+ // not a machine-speed threshold. Leave enough wall-clock headroom for slower or
81
+ // contended CI hosts while still terminating a genuinely stuck child process.
82
+ const DCG_HANDOFF_TEST_TIMEOUT_MS = 20_000;
76
83
  let tmp = null;
77
84
  let tmpCounter = 0;
78
85
 
@@ -2863,7 +2870,7 @@ c4 ?- call((!;1)).
2863
2870
  const result = spawnSync(process.execPath, ['--input-type=module', '--eval', script], {
2864
2871
  cwd: packageRoot,
2865
2872
  encoding: 'utf8',
2866
- timeout: 5000,
2873
+ timeout: DCG_HANDOFF_TEST_TIMEOUT_MS,
2867
2874
  });
2868
2875
  if (result.error) throw result.error;
2869
2876
  assertEqual(result.status, 0, `65536-cell hand-off status; stderr=${result.stderr}`);
@@ -2906,7 +2913,7 @@ c4 ?- call((!;1)).
2906
2913
  'length(_,E),E>12,N is 2^E,\\+ \\+ (length(L,N),time(phrase(a,L))).\n' +
2907
2914
  ';\n;\n;\n\n' +
2908
2915
  'halt.\n',
2909
- timeout: 5000,
2916
+ timeout: DCG_HANDOFF_TEST_TIMEOUT_MS,
2910
2917
  });
2911
2918
  if (result.error) throw result.error;
2912
2919
  assertEqual(result.status, 0, `deep REPL hand-off status; stderr=${result.stderr}`);
@@ -3842,6 +3849,39 @@ child.stdin.write(\`consult(${consultedAtom}).\\n\`);
3842
3849
  assertEqual(result.stderr, '', 'stderr');
3843
3850
  },
3844
3851
  },
3852
+
3853
+ {
3854
+ name: '--proof-detail expanded exposes bundled Prolog library clauses',
3855
+ run: () => {
3856
+ const input = '%% goal: q(a)\n:- use_module(library(lists)).\nq(X) :- member(X, [a,b]).\n';
3857
+ const result = runCli(['--proof-detail', 'expanded', '-'], { input });
3858
+ assertEqual(result.status, 0, 'exit status');
3859
+ assertIncludes(result.stdout, 'by(fact("src/lib/lists.pl"', 'expanded library source');
3860
+ assertNotIncludes(result.stdout, 'by(library(member, 2))', 'abstract library boundary');
3861
+ },
3862
+ },
3863
+ {
3864
+ name: '--verify-proof accepts saved why/2 proof output and rejects tampering',
3865
+ run: () => {
3866
+ const programFile = path.join(tmp, `proof-program-${++tmpCounter}.pl`);
3867
+ const proofFile = path.join(tmp, `proof-certificate-${++tmpCounter}.pl`);
3868
+ fs.writeFileSync(programFile, '%% goal: q(a)\np(a).\nq(X) :- p(X).\n');
3869
+ const generated = runCli(['--proof', programFile]);
3870
+ assertEqual(generated.status, 0, 'proof generation status');
3871
+ fs.writeFileSync(proofFile, generated.stdout);
3872
+ const verified = runCli(['--verify-proof', proofFile, programFile]);
3873
+ assertEqual(verified.status, 0, 'verification status');
3874
+ assertEqual(verified.stdout, 'verified 1 proof certificate.\n', 'verification stdout');
3875
+ const strictVerified = runCli(['--iso-strict', '--verify-proof', proofFile, programFile]);
3876
+ assertEqual(strictVerified.status, 0, 'strict verification status');
3877
+ assertEqual(strictVerified.stdout, 'verified 1 proof certificate.\n', 'strict verification stdout');
3878
+ const tamperedFile = path.join(tmp, `proof-certificate-bad-${++tmpCounter}.pl`);
3879
+ fs.writeFileSync(tamperedFile, generated.stdout.replace('goal(p(a))', 'goal(p(b))'));
3880
+ const rejected = runCli(['--verify-proof', tamperedFile, programFile]);
3881
+ assertEqual(rejected.status, 1, 'tampered verification status');
3882
+ assertIncludes(rejected.stderr, 'proof certificate 1 failed verification', 'tampered verification stderr');
3883
+ },
3884
+ },
3845
3885
  {
3846
3886
  name: '-pw combines proof and warning flags',
3847
3887
  run: () => {
@@ -5624,6 +5664,74 @@ true :+ ready.
5624
5664
  },
5625
5665
  },
5626
5666
 
5667
+ {
5668
+ name: 'proof certificates verify source derivations without proof search',
5669
+ run: () => {
5670
+ const program = Program.parse('p(a, b).\nq(X, Y) :- p(X, Y).\n', { sourceMetadata: true });
5671
+ const result = proofCertificate(program, parseGoalText('q(a, b)'));
5672
+ assertEqual(result.ok, true, 'certificate generated');
5673
+ assertEqual(result.certificate.version, 1, 'certificate version');
5674
+ assertEqual(result.certificate.answer, 'q(a, b)', 'certificate answer');
5675
+ const checked = verifyProof(program, result);
5676
+ assertEqual(checked.ok, true, 'certificate verifies');
5677
+ assertEqual(checked.trusted.length, 0, 'source-only certificate has no trusted boundaries');
5678
+ },
5679
+ },
5680
+ {
5681
+ name: 'proof verification rejects a tampered child goal',
5682
+ run: () => {
5683
+ const program = Program.parse('p(a, b).\nq(X, Y) :- p(X, Y).\n', { sourceMetadata: true });
5684
+ const result = proofCertificate(program, parseGoalText('q(a, b)'));
5685
+ const tampered = structuredClone(result.certificate);
5686
+ tampered.proof.children[0].goal = 'p(a, c)';
5687
+ assertEqual(verifyProof(program, tampered).ok, false, 'tampered child is rejected');
5688
+ const bindingTamper = structuredClone(result.certificate);
5689
+ bindingTamper.proof.bindings[1].value = 'c';
5690
+ assertEqual(verifyProof(program, bindingTamper).ok, false, 'tampered binding is rejected');
5691
+ const changedProgram = Program.parse('p(a, c).\nq(X, Y) :- p(X, Y).\n', { sourceMetadata: true });
5692
+ assertEqual(verifyProof(changedProgram, result).ok, false, 'changed source program is rejected');
5693
+ },
5694
+ },
5695
+ {
5696
+ name: 'why/2 text round-trips into a verifiable proof certificate',
5697
+ run: () => {
5698
+ const program = Program.parse('p(a).\nq(X) :- p(X).\n', { sourceMetadata: true });
5699
+ const result = proofCertificate(program, parseGoalText('q(a)'));
5700
+ const parsed = proofCertificatesFromText(result.text, program);
5701
+ assertEqual(parsed.length, 1, 'certificate count');
5702
+ assertEqual(verifyProof(program, parsed[0]).ok, true, 'round-tripped certificate verifies');
5703
+ },
5704
+ },
5705
+ {
5706
+ name: 'proof certificate text round-trips under every double_quotes mode',
5707
+ run: () => {
5708
+ for (const doubleQuotes of ['chars', 'codes', 'atom']) {
5709
+ const program = Program.parse('p(a).\nq(X) :- p(X).\n', { sourceMetadata: true, doubleQuotes });
5710
+ const result = proofCertificate(program, parseGoalText('q(a)', { doubleQuotes }));
5711
+ const parsed = proofCertificatesFromText(result.text, program);
5712
+ assertEqual(parsed.length, 1, `${doubleQuotes} certificate count`);
5713
+ assertEqual(verifyProof(program, parsed[0]).ok, true, `${doubleQuotes} certificate verifies`);
5714
+ }
5715
+ },
5716
+ },
5717
+ {
5718
+ name: 'expanded proof detail opens bundled Prolog library clauses',
5719
+ run: () => {
5720
+ const program = Program.parse(':- use_module(library(lists)).\nq(X) :- member(X, [a,b]).\n', { sourceMetadata: true });
5721
+ const abstract = proofCertificate(program, parseGoalText('q(a)'));
5722
+ const expanded = proofCertificate(program, parseGoalText('q(a)'), { proofDetail: 'expanded' });
5723
+ assertEqual(abstract.certificate.proof.children[0].method.type, 'library', 'abstract library boundary');
5724
+ assertEqual(expanded.certificate.proof.children[0].method.type, 'source', 'expanded library source');
5725
+ const abstractChecked = verifyProof(program, abstract);
5726
+ const expandedChecked = verifyProof(program, expanded);
5727
+ assertEqual(abstractChecked.ok, true, 'abstract certificate verifies');
5728
+ assertEqual(abstractChecked.trusted.length, 1, 'abstract certificate reports library trust');
5729
+ assertEqual(abstractChecked.trusted[0].type, 'library', 'reported trust boundary type');
5730
+ assertEqual(expandedChecked.ok, true, 'expanded certificate verifies');
5731
+ assertEqual(expandedChecked.trusted.length, 0, 'pure-Prolog expanded certificate removes library trust');
5732
+ },
5733
+ },
5734
+
5627
5735
  {
5628
5736
  name: 'run accepts Program instances',
5629
5737
  run: () => {
@@ -7171,6 +7279,92 @@ function whiteBoxCases() {
7171
7279
  'double_quotes(codes) representation');
7172
7280
  },
7173
7281
  },
7282
+ {
7283
+ name: 'double-quoted character and code suffixes are recognized inside proper lists (issue #88 suffix follow-up)',
7284
+ run: () => {
7285
+ const repl = runCli([], {
7286
+ input: 'T = [A,b,c,d,e,f].\nhalt.\n',
7287
+ });
7288
+ assertEqual(repl.status, 0, 'REPL exit status');
7289
+ assertIncludes(repl.stdout, 'T = [A|"bcdef"].', 'Scryer-compatible character suffix');
7290
+ assertNotIncludes(repl.stdout, 'T = [A, b, c, d, e, f].', 'expanded suffix suppressed');
7291
+
7292
+ const chars = parseGoalText('p([foo,b,c])').args[0];
7293
+ assertEqual(
7294
+ formatTermForWrite(chars, new Env(), {
7295
+ quoted: true,
7296
+ doubleQuotes: 'chars',
7297
+ doubleBar: true,
7298
+ compact: true,
7299
+ operators: ISO_OPERATOR_DEFINITIONS,
7300
+ }),
7301
+ '[foo|"bc"]',
7302
+ 'ordinary list notation uses a quoted suffix',
7303
+ );
7304
+ assertEqual(
7305
+ formatTermForWrite(chars, new Env(), {
7306
+ quoted: true,
7307
+ ignoreOps: true,
7308
+ doubleQuotes: 'chars',
7309
+ doubleBar: true,
7310
+ compact: true,
7311
+ operators: ISO_OPERATOR_DEFINITIONS,
7312
+ }),
7313
+ `'.'(foo,"bc")`,
7314
+ 'ignore_ops remains orthogonal to suffix quoting',
7315
+ );
7316
+ assertEqual(
7317
+ formatTermForWrite(chars, new Env(), {
7318
+ quoted: true,
7319
+ doubleQuotes: null,
7320
+ doubleBar: true,
7321
+ compact: true,
7322
+ operators: ISO_OPERATOR_DEFINITIONS,
7323
+ }),
7324
+ '[foo,b,c]',
7325
+ 'disabled double_quotes keeps expanded list notation',
7326
+ );
7327
+ const improper = parseGoalText('p([foo,b,c|tail])').args[0];
7328
+ assertEqual(
7329
+ formatTermForWrite(improper, new Env(), {
7330
+ quoted: true,
7331
+ doubleQuotes: 'chars',
7332
+ doubleBar: true,
7333
+ compact: true,
7334
+ operators: ISO_OPERATOR_DEFINITIONS,
7335
+ }),
7336
+ '[foo,b,c|tail]',
7337
+ 'improper suffix is not mistaken for a proper string suffix',
7338
+ );
7339
+
7340
+ const astral = parseGoalText("p([foo,'😀',x])").args[0];
7341
+ assertEqual(
7342
+ formatTermForWrite(astral, new Env(), {
7343
+ quoted: true,
7344
+ doubleQuotes: 'chars',
7345
+ doubleBar: true,
7346
+ compact: true,
7347
+ operators: ISO_OPERATOR_DEFINITIONS,
7348
+ }),
7349
+ '[foo|"😀x"]',
7350
+ 'Unicode scalar suffix',
7351
+ );
7352
+
7353
+ const codes = parseGoalText('p([foo,98,128512])', { doubleQuotes: 'codes' }).args[0];
7354
+ assertEqual(
7355
+ formatTermForWrite(codes, new Env(), {
7356
+ quoted: true,
7357
+ doubleQuotes: 'codes',
7358
+ doubleBar: true,
7359
+ compact: true,
7360
+ operators: ISO_OPERATOR_DEFINITIONS,
7361
+ }),
7362
+ '[foo|"b😀"]',
7363
+ 'code-list suffix',
7364
+ );
7365
+ },
7366
+ },
7367
+
7174
7368
  {
7175
7369
  name: 'charsio write_term_to_chars composes double_quotes with ignore_ops',
7176
7370
  run: () => {
@@ -1467,25 +1467,42 @@ why(
1467
1467
  ).
1468
1468
  ```
1469
1469
 
1470
- Proof output is valid EyeProlog input:
1470
+ Proof output is valid EyeProlog input and can be kept as a proof certificate:
1471
1471
 
1472
1472
  ```sh
1473
1473
  eyeprolog --proof examples/socrates.pl > socrates.why.pl
1474
+ eyeprolog --verify-proof socrates.why.pl examples/socrates.pl
1474
1475
  ```
1475
1476
 
1477
+ The second command checks the supplied `why/2` derivation against the program;
1478
+ it does not search again for a proof. A changed source clause, child goal, source
1479
+ location, or recorded substitution makes the certificate fail verification.
1480
+ Certificate input is parsed as Prolog data rather than loaded as a program, so
1481
+ its terms cannot trigger directives while being checked.
1482
+
1476
1483
  A normal answer is one resolved ground term followed by a period. Strings,
1477
1484
  quoted atoms, lists, and compounds are rendered in supported source syntax so
1478
1485
  the output can be read back. Enabling `--proof`, `--warnings`, or `--stats`
1479
1486
  must not change which answers are found.
1480
1487
 
1481
- The second argument of `why/2` is an abstract proof term of the general shape
1488
+ The second argument of `why/2` is a proof term of the general shape
1482
1489
  `proof(goal(G), by(Method), bindings(Bindings), uses(Proofs))`. User clauses
1483
1490
  are identified as `fact(Filename, clause(N))` or
1484
1491
  `rule(Filename, clause(N))`, with one-based source clause numbers. Built-ins
1485
- are identified as `builtin(Name, Arity)`. Explanation data is outside the
1492
+ are identified as `builtin(Name, Arity)`. By default, bundled Prolog-library
1493
+ predicates appear as `library(Name, Arity)` trusted boundaries. Use
1494
+ `--proof-detail expanded` to replace those boundaries with the library source
1495
+ clauses and any trusted built-ins they call. Explanation data is outside the
1486
1496
  logical semantics of the input program: it describes the derivation but does
1487
1497
  not participate in finding it.
1488
1498
 
1499
+ Verification checks source steps structurally: the named clause must exist, its
1500
+ head must unify with the certified goal, its body must correspond to the child
1501
+ proofs, and recorded clause-variable bindings must agree with that derivation.
1502
+ Built-ins and abstract library nodes are deliberately trusted boundaries. This
1503
+ separates proof discovery from proof checking without pretending that host
1504
+ operations can be justified by Prolog source that does not exist.
1505
+
1489
1506
  A second program can query `why/2`. Read a proof as an argument. If it contains
1490
1507
  irrelevant detours, improve the helpers. If a key premise is hidden inside an
1491
1508
  opaque value, model it as a fact. Designing for a good explanation often
@@ -1862,10 +1879,42 @@ prints numeric work counters; those counters describe this run rather than an
1862
1879
  additional logical answer.
1863
1880
 
1864
1881
  `run/2` accepts source text or an already parsed `Program`. Its options include
1865
- `proof` (with `why` and `explain` as aliases), `maxDepth`, `maxInferences`,
1866
- `maxMemoryBytes`, `solutionLimit`, a custom `registry`, and `strictNegation` or
1867
- `analyzeNegation`. It returns `stdout`, the solver's numeric `stats`, and a
1868
- nullable `haltCode`; it does not write to the process streams.
1882
+ `proof` (with `why` and `explain` as aliases), `proofDetail` (`abstract` or
1883
+ `expanded`), `maxDepth`, `maxInferences`, `maxMemoryBytes`, `solutionLimit`, a
1884
+ custom `registry`, and `strictNegation` or `analyzeNegation`. It returns
1885
+ `stdout`, the solver's numeric `stats`, and a nullable `haltCode`; it does not
1886
+ write to the process streams.
1887
+
1888
+ For applications that exchange proofs independently of answer output, the same
1889
+ module exposes `proofCertificate(program, goal, options)`,
1890
+ `proofCertificatesFromText(text, program)`, and
1891
+ `verifyProof(program, certificate, options)`:
1892
+
1893
+ ```js
1894
+ import {
1895
+ Program, parseGoalText,
1896
+ proofCertificate, proofCertificatesFromText, verifyProof
1897
+ } from 'eyeprolog';
1898
+
1899
+ const program = Program.parse(`
1900
+ p(a).
1901
+ q(X) :- p(X).
1902
+ `, { sourceMetadata: true });
1903
+
1904
+ const made = proofCertificate(program, parseGoalText('q(a)'));
1905
+ console.log(verifyProof(program, made).ok); // true
1906
+
1907
+ const received = proofCertificatesFromText(made.text, program)[0];
1908
+ console.log(verifyProof(program, received).ok); // true
1909
+ ```
1910
+
1911
+ `proofCertificate` returns both the ordinary `why/2` text and a JSON-serializable
1912
+ certificate object. `verifyProof` walks the supplied certificate rather than
1913
+ asking the solver to find another proof; its `trusted` array lists every builtin
1914
+ or abstract-library boundary that was assumed while checking it. Passing
1915
+ `proofDetail: 'expanded'` exposes bundled Prolog-library clauses in the
1916
+ certificate and therefore reduces library-level trust boundaries to the
1917
+ built-ins those clauses ultimately use.
1869
1918
 
1870
1919
  When `run` receives an already parsed `Program`, bundled-library imports
1871
1920
  needed only by its host-supplied goals are added to that Program before solving,
@@ -5480,9 +5529,11 @@ still retained where adjacent graphic tokens would otherwise merge, as in
5480
5529
  `write_term/2-3` supports `quoted/1`, `ignore_ops/1`, `numbervars/1`, and
5481
5530
  `variable_names/1`. Normal mode additionally accepts the EyeProlog extension
5482
5531
  `double_quotes(true|false)`: `true` lets eligible character/code lists use the
5483
- current `double_quotes` representation. Proper lists can therefore be written
5484
- as `"text"`, while a partial list such as `[a,b|Tail]` is written as
5485
- `"ab"||Tail`. This representation choice is independent of `ignore_ops/1`: with
5532
+ current `double_quotes` representation. Proper character/code lists can therefore be written
5533
+ as `"text"`. A proper list whose final segment contains at least two characters/codes
5534
+ uses that representation for the suffix, so `[A,b,c,d,e,f]` is written as
5535
+ `[A|"bcdef"]`. A partial list such as `[a,b|Tail]` is written as `"ab"||Tail`.
5536
+ This representation choice is independent of `ignore_ops/1`: with
5486
5537
  `ignore_ops(true)`, operator terms use functional notation while an explicitly
5487
5538
  requested character/code list remains double quoted. Thus
5488
5539
  `write_term(f("ab",a+b),[quoted(true),ignore_ops(true),double_quotes(true)])`
@@ -5547,6 +5598,21 @@ and document the command line; the conformance corpus fixes success, failure,
5547
5598
  mode, and error behavior. Use this part for working practice and those chapters
5548
5599
  for exact reference.
5549
5600
 
5601
+ ### Historical note: the practical language becomes portable
5602
+
5603
+ By the late 1970s and 1980s, Prolog had spread from its Marseille origins into
5604
+ several implementation traditions. The Edinburgh and DECsystem-10 lineage in
5605
+ particular helped establish a practical programming vocabulary around ordered
5606
+ control, term inspection, dynamic clauses, operators, and streams. These
5607
+ facilities made Prolog useful far beyond theorem-proving examples, but
5608
+ differences between systems also made portability a recurring concern.
5609
+
5610
+ ISO/IEC 13211-1:1995 gave that accumulated practice a common core. It did not
5611
+ freeze the language: constraints, modules, tabling, coroutining, and other
5612
+ facilities continued to develop in implementations and later standardization
5613
+ work. What the standard supplied was a stable shared account of ordinary Prolog
5614
+ terms, control, state, errors, and I/O against which extensions could be named.
5615
+
5550
5616
  # Part IX — Reference as practice
5551
5617
 
5552
5618
  Reference is useful only when the route into it is clear. Begin with the task
@@ -9319,6 +9385,8 @@ make the observed question explicit.
9319
9385
  | --- | --- |
9320
9386
  | `-h`, `--help` | Show usage |
9321
9387
  | `-p`, `--proof` | Print `why/2` explanations |
9388
+ | `--proof-detail abstract|expanded` | Select library abstraction for proof output; implies `--proof` |
9389
+ | `--verify-proof File` | Verify saved `why/2` proof certificates against the input program without proof search |
9322
9390
  | `-q`, `--quads` | Run embedded quad tests and fail if any do not hold |
9323
9391
  | `--iso-strict` | Restrict parsing and execution to ISO/IEC 13211-1:1995 + Corrigenda 1–3; reject EyeProlog language extensions (including `table` and `:+`) and disable bundled-library autoloading |
9324
9392
  | `--portable` | Enforce the conservative EyeProlog/Trealla/Scryer interoperability profile |
@@ -9363,7 +9431,9 @@ Work in a fixed sequence:
9363
9431
 
9364
9432
  1. predict the ground answers before running the program;
9365
9433
  2. run without observation flags and compare stdout with that prediction;
9366
- 3. add `--proof` when the support for an answer is the question;
9434
+ 3. add `--proof` when the support for an answer is the question; save the
9435
+ output and use `--verify-proof` when the derivation itself must cross a
9436
+ process or review boundary;
9367
9437
  4. add `--warnings` when portability or negative dependencies are the
9368
9438
  question; use `--portable` when non-profile dependencies must fail CI;
9369
9439
  5. add `--stats` only when comparing two executions of the same semantic case.
@@ -9373,6 +9443,8 @@ For example:
9373
9443
  ```sh
9374
9444
  eyeprolog --goal 'ancestor(X, Y)' examples/ancestor.pl
9375
9445
  eyeprolog --proof --goal 'type(X, Y)' examples/socrates.pl
9446
+ eyeprolog --proof examples/socrates.pl > socrates.why.pl
9447
+ eyeprolog --verify-proof socrates.why.pl examples/socrates.pl
9376
9448
  eyeprolog --warnings --goal 'answer(X)' test/conformance/warnings/negation/unstratified_mutual.pl
9377
9449
  eyeprolog --portable --goal 'sudoku9_solution(S)' examples/clpz-sudoku-9x9.pl
9378
9450
  eyeprolog --stats --goal 'path(a, X)' examples/path-discovery.pl > answers.pl 2> run.stats
@@ -10594,6 +10666,22 @@ variables. Variant recognition is important for tabling and cycle analysis.
10594
10666
  **Witness.** A constructed ground term demonstrating an existential result,
10595
10667
  such as a path, assignment, factorization, schedule, or proof-relevant object.
10596
10668
 
10669
+ ### Historical note: manuals become specifications
10670
+
10671
+ Early Prolog programmers learned from implementation manuals, examples, and
10672
+ books whose descriptions were often inseparable from one particular system. As
10673
+ the language spread, reference writing acquired a second task: distinguish the
10674
+ portable language from implementation convention. Predicate indexes, precise
10675
+ mode and error descriptions, and standards documents became tools for comparing
10676
+ systems rather than merely operating one of them.
10677
+
10678
+ ISO standardization made that distinction explicit, while conformance tests and
10679
+ cross-processor corpora made many disagreements executable. A mature reference
10680
+ therefore joins several forms of evidence: normative prose, named predicates
10681
+ and flags, examples, implementation boundaries, and reproducible tests. The
10682
+ result is not a substitute for programming practice; it is a map from a
10683
+ concrete question to the exact contract that governs it.
10684
+
10597
10685
  # Part X — Laboratories
10598
10686
 
10599
10687
  ## 44. Twelve laboratories
@@ -10858,6 +10946,21 @@ A beautiful program is not merely short. It makes the reason for its
10858
10946
  correctness, the shape of its search, and the boundary of its trust available
10859
10947
  to the next reader.
10860
10948
 
10949
+ ### Historical note: logic programming grows through exercises
10950
+
10951
+ Logic programming has long been taught by construction. Lists, family
10952
+ relations, puzzles, grammars, interpreters, search problems, and small expert
10953
+ systems became recurring exercises because each exposes both a logical relation
10954
+ and the control needed to compute with it. Texts such as *The Art of Prolog*
10955
+ made this dual reading central: an exercise was not finished when a clause
10956
+ parsed, but when its meaning, modes, and behavior could be explained.
10957
+
10958
+ Laboratory practice later absorbed regression testing, property-oriented
10959
+ checking, benchmark corpora, and reproducible command-line runs. These tools
10960
+ fit logic programming unusually well because a small change can be examined at
10961
+ several levels at once: answers, failures, witnesses, proofs, and search. The
10962
+ laboratory is where a declarative claim becomes an executable experiment.
10963
+
10861
10964
  # Part XI — Review
10862
10965
 
10863
10966
  ## 45. Checkpoint notes and selected answers
@@ -10982,3 +11085,17 @@ unbounded theorem.
10982
11085
 
10983
11086
  For laboratory checkpoints, leave an artifact. A useful completion is not merely a paragraph: it is a small source file, predicted output, actual output, and one sentence explaining any difference.
10984
11087
 
11088
+ ### Historical note: review becomes explanation
11089
+
11090
+ The declarative reading of logic programs encouraged debugging methods that ask
11091
+ what a relation was intended to mean, not only which machine step came next. In
11092
+ the early 1980s, Ehud Shapiro's work on algorithmic debugging used computation
11093
+ trees together with a programmer's judgments about intended results to narrow a
11094
+ fault to the clause responsible for it. Declarative debugging developed this
11095
+ idea alongside, rather than instead of, ordinary tracing.
11096
+
11097
+ That tradition gives review a distinctive role in logic programming. A worked
11098
+ answer is useful when it can be reconstructed from the relation, the calling
11099
+ mode, and the evidence, and when a disagreement can be turned into a smaller
11100
+ question about meaning or control. Review then becomes another pass through the
11101
+ same discipline as programming: predict, execute, explain, and revise.
package/why-eyeprolog.md CHANGED
@@ -203,9 +203,20 @@ make decisions, combine data from several sources, or need to be reviewed by
203
203
  someone who did not write them.
204
204
 
205
205
  Proofs make successful reasoning easier to inspect, test, teach, and discuss.
206
- They do not authenticate source data or replace application security. Embedders
207
- remain responsible for validating inputs and imposing suitable time, memory,
208
- depth, and solution limits.
206
+ EyeProlog also treats a successful `why/2` term as a portable proof certificate.
207
+ A certificate can be saved, transmitted as ordinary Prolog data, and checked later
208
+ against the program without repeating the search that found the answer. Source
209
+ steps are checked against the named clauses and their substitutions. Built-ins
210
+ and abstract library steps remain explicit trusted boundaries; expanded proof
211
+ detail opens bundled Prolog-library clauses so that more of the derivation can
212
+ be checked from source. The verification result enumerates the remaining trusted
213
+ boundaries rather than folding them into an undifferentiated success result.
214
+
215
+ Verification and discovery therefore have different jobs: solving searches for
216
+ a derivation, while certificate verification checks a supplied derivation. The
217
+ certificate does not authenticate source data or replace application security.
218
+ Embedders remain responsible for validating inputs and imposing suitable time,
219
+ memory, depth, and solution limits.
209
220
 
210
221
  ## One engine across JavaScript environments
211
222