@josd/eyecode 0.0.0-stage → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/proof.js ADDED
@@ -0,0 +1,679 @@
1
+ // Proof documents: rendering, and checking C1-C7 against the source program.
2
+ //
3
+ // A proof document is JavaScript source, one expression per line: first the
4
+ // claims, then clause(N, Display) for every source clause the proof cites,
5
+ // written as the program states it (fact(...), implies(...) or
6
+ // implied_by(...)), then one step(Goal, By, Bindings, Uses) per inference. The
7
+ // document reader parses it as data; it never executes it.
8
+ //
9
+ // Verification follows recorded uses; it never asks the solver to find a
10
+ // missing derivation. Only pure primitives are independently recomputed.
11
+ import { PRIMITIVE_KEYS, primitive } from './builtins.js';
12
+ import { freshClause, text } from './common.js';
13
+ import { readGoal } from './goals.js';
14
+ import { CONTROL_KEYS, Program } from './program.js';
15
+ import { readTerms } from './reader.js';
16
+ import {
17
+ Env, EyecodeError, Struct, Var, conjunction, copyResolved, deref, flattenConjunction, freshTerm,
18
+ isCallable, isGround, isTerm, key, listFromItems, properListItems, unify,
19
+ } from './terms.js';
20
+ import { Lettering, letterName, quote, write, writeNotingVariables } from './writer.js';
21
+
22
+ // Several goals or conclusions are displayed as an array, as a program
23
+ // states them; a single one as itself.
24
+ function goalsDisplay(goals) {
25
+ return goals.length === 1 ? goals[0] : listFromItems(goals);
26
+ }
27
+
28
+ // A clause as the program states it.
29
+ export function clauseDisplay(clause) {
30
+ if (clause.forward) {
31
+ // A query or a contradiction is displayed as the statement that wrote it.
32
+ if (clause.head === 'true') return new Struct('query', clause.body);
33
+ if (clause.head === 'false') return new Struct('contradiction', clause.body);
34
+ return new Struct('implies', [goalsDisplay(clause.body), goalsDisplay(clause.heads)]);
35
+ }
36
+ if (clause.body.length) return new Struct('implied_by', [clause.head, goalsDisplay(clause.body)]);
37
+ return new Struct('fact', [clause.head]);
38
+ }
39
+
40
+ function sameValue(a, b) {
41
+ // Identical means the same canonical text: 0.0 and -0.0 differ.
42
+ return typeof a === 'number' ? Object.is(a, b) : a === b;
43
+ }
44
+
45
+ // Whether term, read through env, is exactly target, variables included.
46
+ function resolvesTo(term, env, target) {
47
+ const pending = [term, target];
48
+ while (pending.length) {
49
+ const expected = pending.pop();
50
+ let actual = pending.pop();
51
+ if (actual instanceof Var) actual = deref(actual, env);
52
+ if (actual instanceof Struct) {
53
+ if (!(expected instanceof Struct) || actual.name !== expected.name || actual.args.length !== expected.args.length) {
54
+ return false;
55
+ }
56
+ for (let i = actual.args.length - 1; i >= 0; i--) pending.push(actual.args[i], expected.args[i]);
57
+ } else if (actual instanceof Var) {
58
+ if (!(expected instanceof Var) || actual.name !== expected.name) return false;
59
+ } else if (expected instanceof Struct || expected instanceof Var || typeof actual !== typeof expected ||
60
+ !sameValue(actual, expected)) {
61
+ return false;
62
+ }
63
+ }
64
+ return true;
65
+ }
66
+
67
+ // The canonical text of terms, written once per term object. A proof names
68
+ // most goals several times, as a step and as a use, and the checks look each
69
+ // up more than once; the text is the same every time.
70
+ class Spellings {
71
+ constructor() {
72
+ this.memo = new Map();
73
+ }
74
+
75
+ entry(term) {
76
+ if (!(term instanceof Struct)) {
77
+ const [spelling, hasVariables] = writeNotingVariables(term);
78
+ return [term, spelling, hasVariables];
79
+ }
80
+ let entry = this.memo.get(term);
81
+ if (entry === undefined) {
82
+ entry = [term, ...writeNotingVariables(term)];
83
+ this.memo.set(term, entry);
84
+ }
85
+ return entry;
86
+ }
87
+
88
+ spell(term) {
89
+ return this.entry(term)[1];
90
+ }
91
+
92
+ // The text of a term with its variables named by the lettering.
93
+ lettered(term, lettering) {
94
+ const [, spelling, hasVariables] = this.entry(term);
95
+ return hasVariables ? write(term, null, lettering) : spelling;
96
+ }
97
+ }
98
+
99
+ const PLAIN_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
100
+
101
+ function bindingsText(bindings, lettering) {
102
+ const parts = bindings.map(([name, value]) => {
103
+ const label = PLAIN_KEY.test(name) ? name : quote(name);
104
+ return `${label}: ${write(value, null, lettering)}`;
105
+ });
106
+ return `{${parts.join(', ')}}`;
107
+ }
108
+
109
+ // The proof document of a run's claims and their derivations. With
110
+ // checkable, also what checkRendered() needs to check it without reading the
111
+ // document back: the document and that, as a pair.
112
+ export function renderProof(program, claims, roots, checkable = false) {
113
+ const spellings = new Spellings();
114
+ const steps = new Map();
115
+ const cited = new Set();
116
+ const pending = [...roots].reverse();
117
+ while (pending.length) {
118
+ const node = pending.pop();
119
+ const nodeId = spellings.spell(node.goal);
120
+ if (steps.has(nodeId)) continue;
121
+ steps.set(nodeId, node);
122
+ if (node.by instanceof Struct) cited.add(Number(node.by.args[0]));
123
+ for (let i = node.children.length - 1; i >= 0; i--) pending.push(node.children[i]);
124
+ }
125
+ // Variables are named A, B, ... in the order the document first shows
126
+ // them, the claims written first, as in the run's conclusions.
127
+ const lettering = new Lettering();
128
+ const lines = claims.map((claim) => spellings.lettered(claim, lettering));
129
+ lines.push('');
130
+ for (const clauseId of [...cited].sort((a, b) => a - b)) {
131
+ lines.push(text(new Struct('clause', [BigInt(clauseId), clauseDisplay(program.clauses[clauseId - 1])])));
132
+ }
133
+ lines.push('');
134
+ for (const node of steps.values()) {
135
+ const uses = `[${node.children.map((child) => spellings.lettered(child.goal, lettering)).join(', ')}]`;
136
+ lines.push(`step(${spellings.lettered(node.goal, lettering)}, ${text(node.by)}, ` +
137
+ `${bindingsText(node.bindings, lettering)}, ${uses})`);
138
+ }
139
+ const document = `${lines.join('\n')}\n`;
140
+ if (!checkable) return document;
141
+ // The steps as reading the document would give them: bindings as '='/2
142
+ // pairs, uses as goals. Their variables keep the run's names, which the
143
+ // document only spells differently, so the checks come out the same; what a
144
+ // report shows of a term is spelled as the document spells it.
145
+ const read = new Map();
146
+ for (const [nodeId, node] of steps) {
147
+ read.set(nodeId, {
148
+ goal: node.goal,
149
+ by: node.by,
150
+ bindings: node.bindings.map(([name, value]) => new Struct('=', [name, value])),
151
+ uses: node.children.map((child) => child.goal),
152
+ });
153
+ }
154
+ return [document, [[...claims], read, spellings, (term) => spellings.lettered(term, lettering)]];
155
+ }
156
+
157
+ class Failures {
158
+ constructor(name = text) {
159
+ this.items = [];
160
+ this.name = name;
161
+ }
162
+
163
+ add(condition, detail, term = null) {
164
+ const record = { condition, detail };
165
+ if (term !== null) record.conclusion = this.name(term);
166
+ // The term itself stays out of JSON but is kept for the term report.
167
+ this.items.push([record, term]);
168
+ }
169
+ }
170
+
171
+ // Check a proof document against the program that produced it. goals names
172
+ // the goals the proof answers when they were asked from outside the program.
173
+ // allowTrusted false rejects any trusted boundary.
174
+ export function checkProof(program, document, { goals = null, allowTrusted = true } = {}) {
175
+ if (!(program instanceof Program)) throw new EyecodeError('checkProof() needs a Program; load one first');
176
+ const fail = new Failures();
177
+ const spellings = new Spellings();
178
+ const [claims, steps] = readDocument(program, document, fail, spellings);
179
+ return check(program, claims, steps, goals, allowTrusted, fail, spellings, text);
180
+ }
181
+
182
+ // Check a proof as renderProof(..., true) gave it: the report checkProof()
183
+ // gives for its document, without reading the document back. A reasoner
184
+ // checks its own proofs this way; their text is covered by the tests that
185
+ // what is written reads back as the same terms.
186
+ export function checkRendered(program, checkable, goals = null, allowTrusted = true) {
187
+ const [claims, steps, spellings, name] = checkable;
188
+ return check(program, claims, steps, goals, allowTrusted, new Failures(name), spellings, name);
189
+ }
190
+
191
+ function check(program, claims, steps, goals, allowTrusted, fail, spellings, name) {
192
+ for (const claim of claims) {
193
+ if (!flattenConjunction(claim).every((part) => steps.has(spellings.spell(part)))) {
194
+ fail.add('C4', `unjustified claim ${text(claim)}`, claim);
195
+ }
196
+ }
197
+ const tally = checkSteps(program, steps, allowTrusted, fail, spellings, name);
198
+ const confronted = checkBoundaries(program, steps, tally.boundaries, fail);
199
+ checkRelevance(program, claims, steps, goals || [], fail, spellings);
200
+ checkWellFounded(steps, fail, spellings);
201
+ let uses = 0;
202
+ for (const step of steps.values()) uses += step.uses.length;
203
+ const failed = (condition) => fail.items.filter(([record]) => record.condition === condition).length;
204
+ const conditions = [
205
+ { id: 'C1', name: 'resolution', covered: tally.verified, failed: failed('C1') },
206
+ { id: 'C2', name: 'well_founded', covered: steps.size, failed: failed('C2') },
207
+ { id: 'C3', name: 'justification', covered: steps.size, failed: failed('C3') },
208
+ { id: 'C4', name: 'coverage', covered: claims.length + uses, failed: failed('C4') },
209
+ { id: 'C5', name: 're_decision', covered: tally.redecided + tally.composed, failed: failed('C5') },
210
+ { id: 'C6', name: 'boundary_consistency', covered: confronted, failed: failed('C6') },
211
+ { id: 'C7', name: 'relevance', covered: claims.length + steps.size, failed: failed('C7') },
212
+ ];
213
+ return {
214
+ valid: !fail.items.length,
215
+ steps: steps.size,
216
+ claims: claims.length,
217
+ verified: tally.verified,
218
+ redecided: tally.redecided,
219
+ composed: tally.composed,
220
+ uses,
221
+ trusted: tally.trusted.map(([record]) => record),
222
+ failures: fail.items.map(([record]) => record),
223
+ conditions,
224
+ // Terms behind the records, for the term report.
225
+ failureTerms: fail.items.map(([, term]) => term),
226
+ trustedTerms: tally.trusted.map(([, term]) => term),
227
+ };
228
+ }
229
+
230
+ // The verdict allowTrusted false would give, from a report without it.
231
+ // Without failures, a strict check differs only in failing C5 once for each
232
+ // trusted boundary, in step order, so it needs no second reading of the
233
+ // document. A report with failures returns null: check again instead.
234
+ export function strict(report) {
235
+ if (report.failures.length) return null;
236
+ const failures = report.trusted.map((record) => ({
237
+ condition: 'C5', detail: `trusted boundary forbidden: ${record.kind}`, conclusion: record.conclusion,
238
+ }));
239
+ const conditions = report.conditions.map((condition) => (
240
+ condition.id === 'C5' ? { ...condition, failed: failures.length } : { ...condition }));
241
+ return { ...report, valid: !failures.length, failures, conditions, failureTerms: [...report.trustedTerms] };
242
+ }
243
+
244
+ // C3: split a document into claims and steps keyed by the goal's text.
245
+ // clause/2 records are compared with the source.
246
+ function readDocument(program, document, fail, spellings) {
247
+ const claims = [];
248
+ const steps = new Map();
249
+ const empty = new Env();
250
+ try {
251
+ for (const [term] of readTerms(document)) {
252
+ if (isTerm(term, 'clause', 2)) {
253
+ // These display records are not authority: the source program is.
254
+ const clause = clauseNamed(program, term.args[0]);
255
+ if (clause === null || !resolvesTo(term.args[1], empty, clauseDisplay(clause))) {
256
+ fail.add('C1', 'clause display differs from source', term);
257
+ }
258
+ continue;
259
+ }
260
+ if (!isTerm(term, 'step', 4)) {
261
+ claims.push(term);
262
+ continue;
263
+ }
264
+ const [goal, by, bindingList, useList] = term.args;
265
+ const bindings = properListItems(bindingList, empty);
266
+ const uses = properListItems(useList, empty);
267
+ if (bindings === null || uses === null) {
268
+ fail.add('C3', 'step bindings must be an object and uses an array', goal);
269
+ continue;
270
+ }
271
+ const goalId = spellings.spell(goal);
272
+ if (steps.has(goalId)) {
273
+ fail.add('C3', `duplicate justification for ${goalId}`, goal);
274
+ continue;
275
+ }
276
+ steps.set(goalId, { goal, by, bindings, uses });
277
+ }
278
+ } catch (error) {
279
+ if (!(error instanceof EyecodeError)) throw error;
280
+ fail.add('C3', error.message);
281
+ }
282
+ return [claims, steps];
283
+ }
284
+
285
+ function clauseNamed(program, id) {
286
+ if (typeof id !== 'bigint' || id < 1n || id > BigInt(program.clauses.length)) return null;
287
+ return program.clauses[Number(id) - 1];
288
+ }
289
+
290
+ const BUILTIN_BY = 'builtin';
291
+ const CONTROL_BY = 'control';
292
+
293
+ // C4, C1, C3 and C5 for each step: every use is justified, and the step is an
294
+ // instance of the clause it cites, a recomputed primitive, a control composed
295
+ // of its uses, or a trusted boundary the later checks confront.
296
+ function checkSteps(program, steps, allowTrusted, fail, spellings, name) {
297
+ const tally = { verified: 0, redecided: 0, composed: 0, boundaries: [], trusted: [] };
298
+
299
+ function covered(goal) {
300
+ if (isTerm(goal, 'and', 2)) return flattenConjunction(goal).every(covered);
301
+ if (steps.has(spellings.spell(goal))) return true;
302
+ // Source facts are available as leaves, including universal facts.
303
+ if (!isCallable(goal)) return false;
304
+ for (const clause of program.groups.get(key(goal)) || []) {
305
+ if (clause.body.length) continue;
306
+ const head = freshTerm(clause.head, 'given');
307
+ const env = new Env();
308
+ if (unify(head, goal, env) && resolvesTo(head, env, goal)) return true;
309
+ }
310
+ return false;
311
+ }
312
+
313
+ for (const step of steps.values()) {
314
+ const { goal, by, bindings, uses } = step;
315
+ for (const use of uses) {
316
+ if (!covered(use)) fail.add('C4', `unjustified use ${text(use)}`, goal);
317
+ }
318
+ if (isTerm(by, 'clause', 1)) {
319
+ if (checkResolution(program, step, fail)) tally.verified++;
320
+ } else if (by === BUILTIN_BY) {
321
+ if (bindings.length || uses.length || !isCallable(goal) || !PRIMITIVE_KEYS.has(key(goal))) {
322
+ fail.add('C3', 'invalid builtin justification', goal);
323
+ continue;
324
+ }
325
+ let agrees = false;
326
+ try {
327
+ for (const env of primitive(goal, new Env())) {
328
+ if (resolvesTo(goal, env, goal)) {
329
+ agrees = true;
330
+ break;
331
+ }
332
+ }
333
+ } catch (error) {
334
+ // A primitive that cannot be recomputed does not pass C5.
335
+ if (!(error instanceof EyecodeError)) throw error;
336
+ }
337
+ if (!agrees) fail.add('C5', `primitive disagrees: ${text(goal)}`, goal);
338
+ else tally.redecided++;
339
+ } else if (by === CONTROL_BY) {
340
+ let candidates = [];
341
+ if (isTerm(goal, 'call', 1) || isTerm(goal, 'once', 1)) candidates = [goal.args[0]];
342
+ else if (isTerm(goal, 'or', 2)) candidates = [...goal.args];
343
+ const composes = (candidate) => {
344
+ const parts = flattenConjunction(candidate);
345
+ return parts.length === uses.length && parts.every((part, i) => spellings.spell(part) === spellings.spell(uses[i]));
346
+ };
347
+ if (bindings.length || !candidates.some(composes)) {
348
+ fail.add('C5', `control step does not follow from its uses: ${text(goal)}`, goal);
349
+ } else {
350
+ tally.composed++;
351
+ }
352
+ } else if ((by === 'absent' && isTerm(goal, 'not', 1)) || (by === 'collected' && isTerm(goal, 'findall', 3))) {
353
+ if (bindings.length || uses.length) fail.add('C3', 'trusted boundaries cannot have bindings or uses', goal);
354
+ tally.boundaries.push(goal);
355
+ tally.trusted.push([{ kind: by, conclusion: name(goal) }, goal]);
356
+ if (!allowTrusted) fail.add('C5', `trusted boundary forbidden: ${by}`, goal);
357
+ } else {
358
+ fail.add('C3', `unknown justification ${text(by)}`, goal);
359
+ }
360
+ }
361
+ return tally;
362
+ }
363
+
364
+ const isConstant = (term) => typeof term === 'string' || typeof term === 'bigint' || typeof term === 'number';
365
+
366
+ // Shortcuts the checker may take; tests turn them off to compare with the
367
+ // general path.
368
+ export const shortcuts = { flatResolution: true };
369
+
370
+ // Check a ground instance of a flat backward clause by substitution. null
371
+ // delegates structured arguments, proof variables and forward heads to the
372
+ // general unifier. Flat ground steps need neither renamed terms nor a
373
+ // substitution trail: source variables can be matched directly.
374
+ function flatResolution(clause, goal, bindings, uses) {
375
+ if (clause.forward) return null;
376
+ const values = new Map();
377
+ for (const binding of bindings) {
378
+ if (!isTerm(binding, '=', 2) || typeof binding.args[0] !== 'string' || values.has(binding.args[0])) return false;
379
+ if (!isConstant(binding.args[1])) return null;
380
+ values.set(binding.args[0], binding.args[1]);
381
+ }
382
+ const declared = new Set(values.keys());
383
+ const names = new Set();
384
+ if (clause.body.length !== uses.length) return false;
385
+ const patterns = [clause.head, ...clause.body];
386
+ const targets = [goal, ...uses];
387
+ for (let i = 0; i < patterns.length; i++) {
388
+ const pattern = patterns[i];
389
+ const target = targets[i];
390
+ if (pattern instanceof Var || target instanceof Var) return null;
391
+ if (pattern instanceof Struct && target instanceof Struct) {
392
+ if (pattern.name !== target.name || pattern.args.length !== target.args.length) return false;
393
+ for (let j = 0; j < pattern.args.length; j++) {
394
+ let expected = pattern.args[j];
395
+ const actual = target.args[j];
396
+ if (expected instanceof Struct || actual instanceof Struct || actual instanceof Var) return null;
397
+ if (expected instanceof Var) {
398
+ names.add(expected.name);
399
+ if (!values.has(expected.name)) {
400
+ values.set(expected.name, actual);
401
+ continue;
402
+ }
403
+ expected = values.get(expected.name);
404
+ }
405
+ if (typeof expected !== typeof actual || !sameValue(expected, actual)) return false;
406
+ }
407
+ } else if (pattern instanceof Struct || target instanceof Struct || typeof pattern !== typeof target ||
408
+ !sameValue(pattern, target)) {
409
+ return false;
410
+ }
411
+ }
412
+ for (const name of declared) if (!names.has(name)) return false;
413
+ return true;
414
+ }
415
+
416
+ // C1: a clause step names a source clause, its bindings name distinct
417
+ // variables of that clause, and under them one of the clause's heads is the
418
+ // step's goal and its body is exactly the step's uses.
419
+ function checkResolution(program, step, fail) {
420
+ const { goal, by, bindings, uses } = step;
421
+ const clauseId = by.args[0];
422
+ const clause = clauseNamed(program, clauseId);
423
+ if (clause === null) {
424
+ fail.add('C1', `unknown clause ${text(by)}`, goal);
425
+ return false;
426
+ }
427
+ const flat = shortcuts.flatResolution ? flatResolution(clause, goal, bindings, uses) : null;
428
+ if (flat !== null) {
429
+ if (!flat) fail.add('C1', `not an instance of source clause ${clauseId}: ${text(goal)}`, goal);
430
+ return flat;
431
+ }
432
+ const [head, body, names] = freshClause(clause, `check${clauseId}`);
433
+ const env = new Env();
434
+ const seen = new Set();
435
+ let valid = true;
436
+ for (const binding of bindings) {
437
+ if (!isTerm(binding, '=', 2) || typeof binding.args[0] !== 'string' || !names.has(binding.args[0]) ||
438
+ seen.has(binding.args[0])) {
439
+ valid = false;
440
+ break;
441
+ }
442
+ seen.add(binding.args[0]);
443
+ if (!unify(names.get(binding.args[0]), binding.args[1], env)) valid = false;
444
+ }
445
+ const heads = clause.forward ? flattenConjunction(head) : [head];
446
+ let resolution = false;
447
+ for (const candidate of heads) {
448
+ const mark = env.mark();
449
+ let matches = unify(candidate, goal, env) && body.length === uses.length;
450
+ for (let i = 0; matches && i < body.length; i++) {
451
+ if (!unify(body[i], uses[i], env)) matches = false;
452
+ }
453
+ if (matches && resolvesTo(candidate, env, goal) && body.every((item, i) => resolvesTo(item, env, uses[i]))) {
454
+ resolution = true;
455
+ }
456
+ env.undo(mark);
457
+ }
458
+ if (!valid || !resolution) {
459
+ fail.add('C1', `not an instance of source clause ${clauseId}: ${text(goal)}`, goal);
460
+ return false;
461
+ }
462
+ return true;
463
+ }
464
+
465
+ // C6: a trusted boundary cannot be proved, but evidence at hand can refute
466
+ // it. An absence fails when a source fact, a step of this certificate or a
467
+ // recomputed primitive is a solution; a collection fails when such a solution
468
+ // is missing from its list. Returns how many boundaries the evidence could
469
+ // speak for.
470
+ function checkBoundaries(program, steps, boundaries, fail) {
471
+ const stepGoals = new Map();
472
+ for (const step of steps.values()) {
473
+ if (isCallable(step.goal)) {
474
+ const goalKey = key(step.goal);
475
+ if (!stepGoals.has(goalKey)) stepGoals.set(goalKey, []);
476
+ stepGoals.get(goalKey).push(step.goal);
477
+ }
478
+ }
479
+ let serial = 0;
480
+ let confronted = 0;
481
+ const nextSerial = () => ++serial;
482
+
483
+ // Each solution of a simple goal that the evidence shows, or null when the
484
+ // evidence cannot speak for the goal.
485
+ function evidence(goal) {
486
+ if (!isCallable(goal)) return null;
487
+ const goalKey = key(goal);
488
+ if (PRIMITIVE_KEYS.has(goalKey)) {
489
+ if (!isGround(goal)) return null;
490
+ try {
491
+ for (const _ of primitive(goal, new Env())) return [goal]; // eslint-disable-line no-unused-vars
492
+ } catch (error) {
493
+ if (!(error instanceof EyecodeError)) throw error;
494
+ return null;
495
+ }
496
+ return [];
497
+ }
498
+ if (CONTROL_KEYS.has(goalKey)) return null;
499
+ const facts = (program.groups.get(goalKey) || [])
500
+ .filter((clause) => !clause.body.length && !clause.forward)
501
+ .map((clause) => freshTerm(clause.head, `evidence${nextSerial()}`));
502
+ return [...facts, ...(stepGoals.get(goalKey) || []).map((item) => freshTerm(item, `evidence${nextSerial()}`))];
503
+ }
504
+
505
+ for (const boundary of boundaries) {
506
+ if (isTerm(boundary, 'not', 1)) {
507
+ const parts = flattenConjunction(boundary.args[0]);
508
+ // Without a join, only a single goal or a ground conjunction is decided.
509
+ if (parts.length > 1 && !isGround(boundary.args[0])) continue;
510
+ const shown = parts.map(evidence);
511
+ if (shown.some((items) => items === null)) continue;
512
+ confronted++;
513
+ const solved = parts.every((part, i) => shown[i].some((item) => unify(freshTerm(part, `absent${nextSerial()}`), item, new Env())));
514
+ if (solved) fail.add('C6', `absence contradicted by evidence: ${text(boundary)}`, boundary);
515
+ } else {
516
+ const items = properListItems(boundary.args[2], new Env());
517
+ if (items === null) {
518
+ fail.add('C6', `collected result is not a proper list: ${text(boundary)}`, boundary);
519
+ continue;
520
+ }
521
+ const parts = flattenConjunction(boundary.args[1]);
522
+ const shown = parts.length === 1 ? evidence(parts[0]) : null;
523
+ if (shown === null) continue;
524
+ confronted++;
525
+ for (const item of shown) {
526
+ const names = new Map();
527
+ const number = nextSerial();
528
+ const pattern = freshTerm(boundary.args[0], `collect${number}`, names);
529
+ const goal = freshTerm(parts[0], `collect${number}`, names);
530
+ const env = new Env();
531
+ if (!unify(goal, item, env)) continue;
532
+ const answer = copyResolved(pattern, env);
533
+ const listed = items.some((element) => unify(answer, freshTerm(element, `listed${nextSerial()}`), new Env()));
534
+ if (!listed) {
535
+ fail.add('C6', `collection misses ${text(answer)}: ${text(boundary)}`, boundary);
536
+ break;
537
+ }
538
+ }
539
+ }
540
+ }
541
+ return confronted;
542
+ }
543
+
544
+ // C7: the certificate answers the question that was asked and carries nothing
545
+ // beside it. A claim is an instance of a goal asked from outside, of a
546
+ // query() goal or, for printed conclusions, of a forward head.
547
+ function checkRelevance(program, claims, steps, goals, fail, spellings) {
548
+ let serial = 0;
549
+ function instanceOf(claim, question) {
550
+ serial++;
551
+ const env = new Env();
552
+ const pattern = freshTerm(question, `asked${serial}`);
553
+ return unify(pattern, claim, env) && resolvesTo(pattern, env, claim);
554
+ }
555
+ const asked = goals.map((goal) => (typeof goal === 'string' ? readGoal(goal) : goal));
556
+ const halted = claims.some((claim) => claim === 'false');
557
+ const questions = [];
558
+ if (asked.length && !halted) {
559
+ questions.push(...asked);
560
+ } else {
561
+ for (const clause of program.forward) {
562
+ const heads = flattenConjunction(clause.head);
563
+ if (heads.some((head) => head === 'true')) {
564
+ if (!halted && clause.body.length) questions.push(conjunction(clause.body));
565
+ }
566
+ for (const head of heads) if (head !== 'true') questions.push(head);
567
+ }
568
+ }
569
+ for (const claim of claims) {
570
+ if (!questions.some((question) => instanceOf(claim, question))) fail.add('C7', `claim answers no goal: ${text(claim)}`, claim);
571
+ }
572
+ const reached = new Set();
573
+ const reach = [];
574
+ for (const claim of claims) for (const part of flattenConjunction(claim)) reach.push(spellings.spell(part));
575
+ while (reach.length) {
576
+ const stepId = reach.pop();
577
+ if (reached.has(stepId) || !steps.has(stepId)) continue;
578
+ reached.add(stepId);
579
+ for (const use of steps.get(stepId).uses) {
580
+ for (const part of flattenConjunction(use)) reach.push(spellings.spell(part));
581
+ }
582
+ }
583
+ for (const [stepId, step] of steps) {
584
+ if (!reached.has(stepId)) fail.add('C7', `step serves no claim: ${stepId}`, step.goal);
585
+ }
586
+ }
587
+
588
+ // C2: no step depends on itself, walked iteratively.
589
+ function checkWellFounded(steps, fail, spellings) {
590
+ const visiting = new Set();
591
+ const visited = new Set();
592
+ function supports(stepId) {
593
+ const used = [];
594
+ const step = steps.get(stepId);
595
+ for (const use of step ? step.uses : []) {
596
+ for (const part of flattenConjunction(use)) {
597
+ const partId = spellings.spell(part);
598
+ if (steps.has(partId)) used.push(partId);
599
+ }
600
+ }
601
+ return used;
602
+ }
603
+ for (const start of steps.keys()) {
604
+ if (visited.has(start)) continue;
605
+ visiting.add(start);
606
+ const stack = [[start, supports(start), 0]];
607
+ while (stack.length) {
608
+ const top = stack[stack.length - 1];
609
+ if (top[2] >= top[1].length) {
610
+ visiting.delete(top[0]);
611
+ visited.add(top[0]);
612
+ stack.pop();
613
+ continue;
614
+ }
615
+ const following = top[1][top[2]];
616
+ top[2]++;
617
+ if (visiting.has(following)) {
618
+ fail.add('C2', `cyclic derivation at ${following}`, steps.get(following).goal);
619
+ continue;
620
+ }
621
+ if (visited.has(following)) continue;
622
+ visiting.add(following);
623
+ stack.push([following, supports(following), 0]);
624
+ }
625
+ }
626
+ }
627
+
628
+ function verdict(report) {
629
+ if (!report.valid) return new Struct('failed', [BigInt(report.failures.length)]);
630
+ return report.trusted.length ? 'checked_with_obligations' : 'checked';
631
+ }
632
+
633
+ // Rename a report fact's variables A, B, C, ... in order of appearance.
634
+ function lettered(term) {
635
+ const names = new Map();
636
+ function visit(t) {
637
+ if (t instanceof Var) {
638
+ if (!names.has(t.name)) names.set(t.name, new Var(letterName(names.size)));
639
+ return names.get(t.name);
640
+ }
641
+ if (t instanceof Struct) return new Struct(t.name, t.args.map(visit));
642
+ return t;
643
+ }
644
+ return visit(term);
645
+ }
646
+
647
+ // The report as JavaScript source, one fact per line, every condition included.
648
+ export function checkReport(report) {
649
+ const facts = [];
650
+ for (const condition of report.conditions) {
651
+ const outcome = condition.failed ? new Struct('failed', [BigInt(condition.failed)]) : 'ok';
652
+ facts.push(new Struct('condition', [condition.id, condition.name, outcome, BigInt(condition.covered)]));
653
+ }
654
+ report.failures.forEach((record, i) => {
655
+ const term = report.failureTerms[i];
656
+ const subject = term !== null ? term : (record.conclusion ?? 'proof_document');
657
+ facts.push(new Struct('failure', [record.condition, subject, record.detail]));
658
+ });
659
+ report.trusted.forEach((record, i) => {
660
+ const term = report.trustedTerms[i];
661
+ facts.push(new Struct('obligation', [record.kind, record.reason ?? 'theory_scoped', term !== null ? term : record.conclusion]));
662
+ });
663
+ for (const [name, count] of [['steps', report.steps], ['verified', report.verified], ['recomputed', report.redecided],
664
+ ['composed', report.composed], ['trusted', report.trusted.length], ['claims', report.claims]]) {
665
+ facts.push(new Struct(name, [BigInt(count)]));
666
+ }
667
+ facts.push(new Struct('verdict', [verdict(report)]));
668
+ return facts.map((fact) => `${text(lettered(fact))}\n`).join('');
669
+ }
670
+
671
+ export function verdictText(report) {
672
+ return `${text(new Struct('verdict', [verdict(report)]))}\n`;
673
+ }
674
+
675
+ // The report without its term-valued internals, ready for JSON.
676
+ export function publicReport(report) {
677
+ const { failureTerms, trustedTerms, ...rest } = report; // eslint-disable-line no-unused-vars
678
+ return rest;
679
+ }