eyeprolog 1.5.21 → 1.5.23

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.
@@ -8,9 +8,9 @@ when this report is generated; it is not inferred from fixture counts.
8
8
 
9
9
  | Gate | Passed | Total | Status |
10
10
  |---|---:|---:|---|
11
- | WG17 syntax | 372 | 372 | pass |
11
+ | WG17 syntax | 366 | 366 | pass |
12
12
 
13
- The WG17 syntax row executes the vendored 372-case conformity-testing matrix
13
+ The WG17 syntax row executes the vendored 366-case conformity-testing matrix
14
14
  against EyeProlog's strict ISO reader/writer. A behavior fix such as operator-token
15
15
  spelling therefore changes this report even when no corpus file is added or removed.
16
16
 
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.21",
6
+ "version": "1.5.23",
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
  }