#!/usr/bin/env node
// RML — minimal relative meta-logic over LiNo (Links Notation)
// Supports many-valued logics from unary (1-valued) through continuous probabilistic (∞-valued).
// See: https://en.wikipedia.org/wiki/Many-valued_logic
//
// - Uses official links-notation parser to parse links
// - Terms are defined via (x: x is x)
// - Probabilities are assigned ONLY via: ((<expr>) has probability <p>)
// - Redefinable ops: (=: ...), (!=: not =), (and: avg|min|max|product|probabilistic_sum), (or: ...), (not: ...), (both: ...), (neither: ...)
// - Range: (range: 0 1) for [0,1] or (range: -1 1) for [-1,1] (balanced/symmetric)
// - Valence: (valence: N) to restrict truth values to N discrete levels (N=2 → Boolean, N=3 → ternary, etc.)
// - Query: (? <expr>)
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { Parser } from 'links-notation';
// ---------- Structured Diagnostics ----------
// Every parser/evaluator error is reported as a `Diagnostic` with an error
// code, human-readable message, and source span (file/line/col, 1-based).
// See `docs/DIAGNOSTICS.md` for the full code list.
/**
* Structured parser, evaluator, or type-checker diagnostic with a stable code
* and 1-based source span.
*/
class Diagnostic {
constructor({ code, message, span }) {
this.code = code;
this.message = message;
this.span = span || { file: null, line: 1, col: 1, length: 0 };
}
}
// Internal error class used to carry a code + span across throw sites so the
// outer `evaluate()` boundary can convert them into Diagnostics.
class RmlError extends Error {
constructor(code, message, span) {
super(message);
this.name = 'RmlError';
this.code = code;
this.span = span || null;
}
}
// ---------- Trace events ----------
// When `trace: true` is passed to `evaluate()` the evaluator records a
// deterministic sequence of `TraceEvent` objects describing operator
// resolutions, assignment lookups, and reduction steps. The CLI's `--trace`
// flag prints each one as `[span <file>:<line>:<col>] <kind> <details>`.
/**
* Trace record emitted when `evaluate()` or the CLI runs with tracing enabled.
*/
class TraceEvent {
constructor({ kind, detail, span }) {
this.kind = kind;
this.detail = detail;
this.span = span || { file: null, line: 1, col: 1, length: 0 };
}
}
function formatTraceEvent(event) {
const span = event.span || { file: null, line: 1, col: 1, length: 0 };
const file = span.file || '<input>';
return `[span ${file}:${span.line}:${span.col}] ${event.kind} ${event.detail}`;
}
// Format a diagnostic for human-readable CLI output:
// <file>:<line>:<col>: <CODE>: <message>
// <source line>
// ^
function formatDiagnostic(diag, sourceText) {
const span = diag.span || { file: null, line: 1, col: 1, length: 0 };
const file = span.file || '<input>';
const lines = [`${file}:${span.line}:${span.col}: ${diag.code}: ${diag.message}`];
if (typeof sourceText === 'string') {
const srcLines = sourceText.split('\n');
const lineText = srcLines[span.line - 1];
if (lineText !== undefined) {
lines.push(lineText);
const caretCount = Math.max(1, span.length || 1);
lines.push(' '.repeat(Math.max(0, span.col - 1)) + '^'.repeat(caretCount));
}
}
return lines.join('\n');
}
// ---------- helpers: canonical keys & tokenization of a single link string ----------
function tokenizeOne(s) {
// s is a single-link string like "( (a = a) has probability 1 )"
// Strip inline comments (everything after #) but balance parens
const commentIdx = s.indexOf('#');
if (commentIdx !== -1) {
s = s.substring(0, commentIdx);
// Count unmatched opening parens and add closing parens to balance
let depth = 0;
for (let i = 0; i < s.length; i++) {
if (s[i] === '(') depth++;
else if (s[i] === ')') depth--;
}
// Add missing closing parens
while (depth > 0) {
s += ')';
depth--;
}
}
const out = [];
let i = 0;
const isWS = c => /\s/.test(c);
while (i < s.length) {
const c = s[i];
if (isWS(c)) { i++; continue; }
if (c === '(' || c === ')') { out.push(c); i++; continue; }
let j = i;
while (j < s.length && !isWS(s[j]) && s[j] !== '(' && s[j] !== ')') j++;
out.push(s.slice(i, j));
i = j;
}
return out;
}
function parseOne(tokens) {
let i = 0;
function read() {
if (tokens[i] !== '(') throw new RmlError('E002', 'expected "("');
i++;
const arr = [];
while (i < tokens.length && tokens[i] !== ')') {
if (tokens[i] === '(') arr.push(read());
else { arr.push(tokens[i]); i++; }
}
if (tokens[i] !== ')') throw new RmlError('E002', 'expected ")"');
i++;
return arr;
}
const ast = read();
if (i !== tokens.length) throw new RmlError('E002', 'extra tokens after link');
return ast;
}
const isNum = s => /^-?(\d+(\.\d+)?|\.\d+)$/.test(s);
const clamp01 = x => Math.max(0, Math.min(1, x));
// ---------- Decimal-precision arithmetic ----------
// Round to at most `digits` significant decimal places to eliminate
// IEEE-754 floating-point artefacts (e.g. 0.1+0.2 → 0.3, not 0.30000000000000004).
const DECIMAL_PRECISION = 12;
function decRound(x) {
if (!Number.isFinite(x)) return x;
return +(Math.round(x + 'e' + DECIMAL_PRECISION) + 'e-' + DECIMAL_PRECISION);
}
function keyOf(node) {
if (Array.isArray(node)) return '(' + node.map(keyOf).join(' ') + ')';
return String(node);
}
function isStructurallySame(a,b){
if (Array.isArray(a) && Array.isArray(b)){
if (a.length !== b.length) return false;
for (let i=0;i<a.length;i++) if (!isStructurallySame(a[i],b[i])) return false;
return true;
}
return String(a) === String(b);
}
function parseUniverseLevelToken(token) {
if (typeof token !== 'string' || !/^(0|[1-9]\d*)$/.test(token)) return null;
const level = Number(token);
return Number.isSafeInteger(level) ? level : null;
}
function universeTypeKey(node) {
if (!Array.isArray(node) || node.length !== 2 || node[0] !== 'Type') return null;
const level = parseUniverseLevelToken(node[1]);
return level === null ? null : `(Type ${level + 1})`;
}
function inferTypeKey(node, env) {
const recorded = env.getType(node);
if (recorded) return recorded;
const universeType = universeTypeKey(node);
if (universeType) {
env.setType(node, universeType);
return universeType;
}
return null;
}
// ---------- Quantization for N-valued logics ----------
// Given N discrete levels and a range [lo, hi], quantize a value to the nearest level.
// For N=2 (Boolean): levels are {lo, hi} (e.g. {0, 1} or {-1, 1})
// For N=3 (ternary): levels are {lo, mid, hi} (e.g. {0, 0.5, 1} or {-1, 0, 1})
// For N=0 or Infinity (continuous): no quantization
// See: https://en.wikipedia.org/wiki/Many-valued_logic
function quantize(x, valence, lo, hi) {
if (valence < 2) return x; // unary or continuous — no quantization
const step = (hi - lo) / (valence - 1);
const level = Math.round((x - lo) / step);
return lo + Math.max(0, Math.min(valence - 1, level)) * step;
}
// ---------- Environment ----------
/**
* Mutable evaluator environment that stores terms, assignments, operators,
* namespaces, imports, and type-checking context.
*/
class Env {
constructor(options){
const opts = options || {};
this.terms = new Set(); // declared terms (via (x: x is x))
this.assign = new Map(); // key(expr) -> truth value
this.symbolProb = new Map(); // optional symbol priors if you want (x: 0.7)
this.types = new Map(); // key(expr) -> type expression (as string)
this.lambdas = new Map(); // name -> { param, paramType, body } for named lambdas
this.templates = new Map(); // name -> { name, params, body } for pre-evaluation templates
// Mode declarations (issue #43, D15): each relation may declare an
// argument mode pattern via `(mode <name> +input -output ...)`. The
// map records the per-argument flag list (`'in'`, `'out'`, `'either'`)
// used by the call-site checker to reject mode mismatches.
this.modes = new Map(); // name -> [flag, flag, ...]
// Relation declarations (issue #44, D12): a relation is a list of
// clauses, each shaped `(<name> arg1 arg2 ... result)`. The totality
// checker reads the clauses to verify structural decrease on recursive
// calls. Stored as `name -> [clauseNode, clauseNode, ...]`, where each
// clauseNode is the original AST list including the head symbol.
this.relations = new Map(); // name -> [clause, clause, ...]
// World declarations (issue #54, D16): each relation may declare an
// allow-list of constants permitted to appear free in its arguments
// via `(world <name> (<const>...))`. Relations without a recorded
// world are unconstrained (the feature is opt-in per relation).
this.worlds = new Map(); // name -> [const, const, ...]
// Inductive declarations (issue #45, D10): `(inductive Name (constructor ...) ...)`
// records a first-class inductive datatype. Each entry stores the type
// name, the ordered list of constructors (each `{ name, type }`), and
// the name and Pi-type of the generated eliminator (`Name-rec`). The
// declaration form also installs the type, every constructor, and the
// eliminator into the standard term/type/lambda maps so existing kernel
// forms (`type of`, `of`, `apply`) work without further plumbing.
this.inductives = new Map(); // name -> { name, constructors, elimName, elimType }
// Definition declarations (issue #49, D13): `(define <name> [(measure ...)] (case <pat> <body>) ...)`
// records a recursive definition with case-clause-based pattern matching.
// The termination checker (`isTerminating`) reads each entry to verify
// that recursive calls structurally decrease either the implicit
// first-argument structural order or, when supplied, an explicit
// lexicographic measure.
this.definitions = new Map(); // name -> { name, measure, clauses }
// Coinductive declarations (issue #53, D11): `(coinductive Name (constructor ...) ...)`
// records a first-class coinductive datatype dual to the inductive form.
// Each entry stores the type name, the ordered list of constructors, and
// the name and Pi-type of the generated corecursor (`Name-corec`). The
// declaration also installs the type, every constructor, and the
// corecursor into the standard term/type maps so existing kernel forms
// (`type of`, `of`, `apply`) work without further plumbing. The kernel
// additionally enforces a syntactic productivity check: at least one
// constructor must take a recursive argument so non-productive types
// (which cannot generate any infinite values) are rejected at declaration
// time.
this.coinductives = new Map(); // name -> { name, constructors, corecName, corecType }
// Domain plugins (issue #63): domain-specific decision procedures keyed
// by `(domain <name> ...)`. The default registry ships the
// automatic-sequences plugin below; callers may register additional
// plugin functions on their Env instance.
this.domainPlugins = new Map(); // name -> (forms, env) => number
this.automaticSequenceDecisions = new Map();// theorem -> decision record
// Namespace state (issue #34): a file can declare `(namespace foo)`, which
// prefixes every name it subsequently introduces with `foo.`. Imports can
// be aliased via `(import "x.lino" as a)`, which records `a` -> the
// imported file's declared namespace so `a.name` resolves to that name.
// `imported` tracks names that came from an import (not declared in the
// importing file) so we can emit a shadowing warning (E008) when a later
// top-level definition rebinds them.
this.namespace = null;
this.aliases = new Map();
this.imported = new Set();
// Root-construct registry (issue #97): every construct the host evaluator,
// type checker, proof replay checker, tactic engine, or metatheorem
// checker relies on can carry a machine-readable descriptor declaring its
// status (`host-primitive`, `host-derived`, `links-encoded`,
// `links-defined`, `user-configurable`, `external-trusted`, `planned`),
// its kind, the host symbol that implements it, the constructs it depends
// on, and whether it is "pure-links-ready". Descriptors are *data only*;
// they never change evaluator behaviour. They are consumed by the
// foundation report (`(foundation-report)`) and the trust audit so users
// can inspect what the prover is actually trusting.
this.rootConstructs = new Map(); // name -> descriptor record
// Foundation registry (issue #97): a foundation bundles a coherent set of
// root-construct interpretations. `default-rml` is preregistered with the
// current host-implemented semantics; user files can register alternative
// foundations (e.g. `boolean-links`) and select them with
// `(with-foundation <name> ...)`. Foundations may rebind operators inside
// their scope; backward compatibility is preserved by always defaulting
// to `default-rml` and restoring the previous bindings on exit.
this.foundations = new Map();
this.activeFoundation = 'default-rml';
this._foundationStack = []; // for nested (with-foundation ...)
this.activeImplementations = new Map(); // construct -> active scoped implementation descriptor
// Proof-object substrate (issue #97, Phase 3 of netkeep80's punch-list).
// `proofRules` maps a declared rule name to its pattern (an array of
// premise nodes + a conclusion node, with `?meta` leaves as
// metavariables). `proofObjects` maps a derivation name to the rule it
// applies and the concrete premise/conclusion judgements. Both are
// data-only: declaring a rule never changes evaluator behaviour. They
// are consumed by `(check-proof <name>)` and surfaced on
// `foundationReport()` so the trust audit can inspect them.
this.proofRules = new Map();
this.proofAssumptions = new Map();
this.proofObjects = new Map();
// Carrier enforcement state (issue #97, Section 2). Off by default so
// legacy programs are not constrained; flipped on by an enclosing
// `(with-foundation <name>)` whose descriptor includes `(strict-carrier)`.
this._strictCarrier = false;
this._carrier = null;
this._carrierLabel = null;
// Pure-links strict mode (issue #97, Phase 6 of netkeep80's punch-list).
// When enabled, every queried expression is scanned and any operator leaf
// resolving to a root construct whose status is `host-primitive` or
// `host-derived` triggers an E065 diagnostic — unless the construct name
// is in the explicit allow list. Off by default so legacy programs run
// unchanged.
this.strictPureLinks = false;
this.allowedHostPrimitives = new Set();
this._registerDefaultFoundation();
// Optional tracer: when set, key evaluation events (operator resolutions,
// assignment lookups, top-level reductions) are pushed via `trace(kind, detail)`.
// The current top-level form span is stashed on the Env so leaf hooks can
// attach a location without threading spans through every helper.
this._tracer = null;
this._currentSpan = null;
// Range: [lo, hi] — default [0, 1] (standard probabilistic)
// Use [-1, 1] for balanced/symmetric range
// See: https://en.wikipedia.org/wiki/Balanced_ternary
this.lo = opts.lo !== undefined ? opts.lo : 0;
this.hi = opts.hi !== undefined ? opts.hi : 1;
// Valence: number of discrete truth values (0 or Infinity = continuous)
// N=1: unary logic (trivial, only one truth value)
// N=2: binary/Boolean logic — https://en.wikipedia.org/wiki/Boolean_algebra
// N=3: ternary logic — https://en.wikipedia.org/wiki/Three-valued_logic
// N=4+: N-valued logic — https://en.wikipedia.org/wiki/Many-valued_logic
// N=0/Infinity: continuous probabilistic / fuzzy logic — https://en.wikipedia.org/wiki/Fuzzy_logic
this.valence = opts.valence !== undefined ? opts.valence : 0;
// ops (redefinable)
this.ops = new Map(Object.entries({
'not': (x)=> this.hi - (x - this.lo), // negation: mirrors around midpoint
'and': (...xs)=> xs.length ? xs.reduce((a,b)=>a+b,0)/xs.length : this.lo, // avg
'or' : (...xs)=> xs.length ? Math.max(...xs) : this.lo,
// Belnap operators: AND-altering operators for four-valued logic
// "both" (gullibility): avg — contradiction resolves to midpoint
'both': (...xs)=> xs.length ? decRound(xs.reduce((a,b)=>a+b,0)/xs.length) : this.lo,
// "neither" (consensus): product — gap resolves to zero (no info propagates)
'neither': (...xs)=> xs.length ? decRound(xs.reduce((a,b)=>a*b,1)) : this.lo,
'=' : (L,R,ctx)=> {
// If assigned explicitly, use that (check both prefix and infix key forms)
const kPrefix = keyOf(['=',L,R]);
if (this.assign.has(kPrefix)) {
const v = this.assign.get(kPrefix);
this.trace('lookup', `${kPrefix} → ${formatTraceValue(v)}`);
return v;
}
const kInfix = keyOf([L,'=',R]);
if (this.assign.has(kInfix)) {
const v = this.assign.get(kInfix);
this.trace('lookup', `${kInfix} → ${formatTraceValue(v)}`);
return v;
}
// Default: syntactic equality of terms/trees
return isStructurallySame(L,R) ? this.hi : this.lo;
},
}));
// sugar: "!=" as not of "=" (can be redefined)
this.defineOp('!=', (...args)=> this.getOp('not')( this.getOp('=')(...args) ));
// Arithmetic operators (decimal-precision by default)
this.defineOp('+', (a,b)=> decRound(a + b));
this.defineOp('-', (a,b)=> decRound(a - b));
this.defineOp('*', (a,b)=> decRound(a * b));
this.defineOp('/', (a,b)=> b === 0 ? 0 : decRound(a / b));
this.defineOp('<', (a,b)=> a < b ? this.hi : this.lo);
this.defineOp('<=', (a,b)=> a <= b ? this.hi : this.lo);
// Initialize truth constants: true, false, unknown, undefined
// These are predefined symbol probabilities based on the current range.
// By default: (false: min(range)), (true: max(range)),
// (unknown: mid(range)), (undefined: mid(range))
// They can be redefined by the user via (true: <value>), (false: <value>), etc.
this._initTruthConstants();
this.registerDomainPlugin('automatic-sequences', automaticSequencesDomainPlugin);
}
// Clamp and optionally quantize a value to the valid range
clamp(x) {
const clamped = Math.max(this.lo, Math.min(this.hi, x));
if (this.valence >= 2) return quantize(clamped, this.valence, this.lo, this.hi);
return clamped;
}
// Parse a numeric string respecting current range
toNum(s) {
return this.clamp(parseFloat(s));
}
// Midpoint of the range (useful for paradox resolution, default symbol prob, etc.)
get mid() { return (this.lo + this.hi) / 2; }
// Initialize truth constants based on current range.
// (false: min(range)), (true: max(range)),
// (unknown: mid(range)), (undefined: mid(range))
_initTruthConstants() {
this.symbolProb.set('true', this.hi);
this.symbolProb.set('false', this.lo);
this.symbolProb.set('unknown', this.mid);
this.symbolProb.set('undefined', this.mid);
// Belnap's four-valued logic operators:
// "both" and "neither" are AND-altering operators (not constants).
// "both" = conjunction under contradiction (gullibility) — default: avg
// (true both false) = 0.5 (both true and false → contradiction/paradox)
// "neither" = conjunction under gap (consensus) — default: product
// (true neither false) = 0 (neither true nor false → gap/no info)
// Both are redefinable via (both: min), (neither: max), etc.
// See: https://en.wikipedia.org/wiki/Four-valued_logic#Belnap
}
getOp(name){
if (this.ops.has(name)) return this.ops.get(name);
const resolved = this._resolveQualified(name);
if (resolved !== name && this.ops.has(resolved)) return this.ops.get(resolved);
throw new RmlError('E001', `Unknown op: ${name}`);
}
hasOp(name){
if (this.ops.has(name)) return true;
const resolved = this._resolveQualified(name);
return resolved !== name && this.ops.has(resolved);
}
defineOp(name, fn){ this.ops.set(name, fn); }
registerDomainPlugin(name, plugin) {
if (typeof name !== 'string' || name.length === 0 || typeof plugin !== 'function') {
throw new RmlError('E041', 'Domain plugin registration requires a name and function');
}
this.domainPlugins.set(name, plugin);
}
getDomainPlugin(name) {
return this.domainPlugins.get(name) || null;
}
setExprProb(exprNode, p){
this.assign.set(keyOf(exprNode), this.clamp(p));
}
setType(exprNode, typeExpr){
const key = typeof exprNode === 'string' ? exprNode : keyOf(exprNode);
this.types.set(key, typeof typeExpr === 'string' ? typeExpr : keyOf(typeExpr));
}
getType(exprNode){
const key = typeof exprNode === 'string' ? exprNode : keyOf(exprNode);
if (this.types.has(key)) return this.types.get(key);
const resolved = this._resolveQualified(key);
if (resolved !== key && this.types.has(resolved)) return this.types.get(resolved);
return null;
}
setLambda(name, param, paramType, body){
this.lambdas.set(name, { param, paramType, body });
}
getLambda(name){
return this.lambdas.get(name) || null;
}
setSymbolProb(sym, p){ this.symbolProb.set(sym, this.clamp(p)); }
getSymbolProb(sym){
if (this.symbolProb.has(sym)) return this.symbolProb.get(sym);
const resolved = this._resolveQualified(sym);
if (resolved !== sym && this.symbolProb.has(resolved)) {
return this.symbolProb.get(resolved);
}
return this.mid;
}
trace(kind, detail){
if (this._tracer) this._tracer(kind, detail, this._currentSpan);
}
// ---------- Namespace helpers (issue #34) ----------
// Apply the active namespace to a freshly declared name, e.g. inside
// `(namespace classical)` the form `(and: min)` registers `classical.and`,
// not `and`. Names that already contain a `.` are passed through.
qualifyName(name) {
if (typeof name !== 'string') return name;
if (this.namespace && !name.includes('.')) return `${this.namespace}.${name}`;
return name;
}
// Resolve a possibly-qualified name to its canonical storage key. Order:
// 1. Alias prefix: `cl.foo` with alias `cl -> classical` becomes
// `classical.foo`.
// 2. Active namespace: an unqualified name lives in `<ns>.<name>`.
// 3. Bare name: returned unchanged.
// Used by lookup helpers (operators, symbol probabilities) to find
// namespaced bindings without forcing every call site to spell them out.
_resolveQualified(name) {
if (typeof name !== 'string') return name;
const dotIdx = name.indexOf('.');
if (dotIdx > 0) {
const prefix = name.slice(0, dotIdx);
const rest = name.slice(dotIdx + 1);
if (this.aliases.has(prefix)) {
return `${this.aliases.get(prefix)}.${rest}`;
}
return name;
}
if (this.namespace) {
const qualified = `${this.namespace}.${name}`;
if (
this.ops.has(qualified) ||
this.symbolProb.has(qualified) ||
this.terms.has(qualified) ||
this.types.has(qualified) ||
this.lambdas.has(qualified) ||
this.templates.has(qualified)
) {
return qualified;
}
}
return name;
}
// ---------- Foundation / root-construct registry (issue #97) ----------
// Preregister the default `default-rml` foundation and bake in the
// built-in root-construct descriptors that describe the current host
// implementation. These are *data only*; they never change behaviour.
_registerDefaultFoundation() {
this.foundations.set('default-rml', {
name: 'default-rml',
description: 'Default RML foundation: host-implemented configurable kernel',
uses: [],
defines: new Map(),
extends: null,
numericDomain: 'decimal-12',
truthDomain: 'default-truth',
});
// Pre-seed the experimental MTC/anum foundation (issue #97, Phase 9).
// Opt-in only — never activated implicitly. Selecting it via
// `(with-foundation mtc-anum ...)` does NOT rewire host arithmetic; the
// profile is descriptive metadata plus a serialization alphabet. Its
// four abits (`[`, `]`, `0`, `1`) are exposed through `foundationReport`
// so the trust audit can show what the experimental profile carries.
this.foundations.set('mtc-anum', {
name: 'mtc-anum',
description: 'experimental metatheory-of-links foundation (anum serialization)',
uses: [],
defines: new Map(),
extends: null,
numericDomain: null,
truthDomain: 'mtc-abits',
carrier: null,
strictCarrier: false,
truthTables: null,
experimental: true,
root: '∞',
abits: [
{ symbol: '[', meaning: 'start-of-meaning' },
{ symbol: ']', meaning: 'end-of-meaning' },
{ symbol: '1', meaning: 'unit-of-meaning' },
{ symbol: '0', meaning: 'zero-of-meaning' },
],
});
this.foundations.set('boolean-links', {
name: 'boolean-links',
description: 'links-defined two-valued Boolean logic via finite truth tables',
uses: [],
defines: new Map(),
extends: null,
numericDomain: 'boolean-zero-one',
truthDomain: 'boolean-two-valued',
carrier: ['0', '1'],
strictCarrier: true,
truthTables: new Map([
['and', [
{ inputs: ['1', '1'], output: '1' },
{ inputs: ['1', '0'], output: '0' },
{ inputs: ['0', '1'], output: '0' },
{ inputs: ['0', '0'], output: '0' },
]],
['or', [
{ inputs: ['1', '1'], output: '1' },
{ inputs: ['1', '0'], output: '1' },
{ inputs: ['0', '1'], output: '1' },
{ inputs: ['0', '0'], output: '0' },
]],
['not', [
{ inputs: ['1'], output: '0' },
{ inputs: ['0'], output: '1' },
]],
]),
experimental: false,
root: null,
abits: null,
});
// Pre-seed the links-defined typed-kernel foundation (issue #97, Phase 5).
// Selecting it via `(with-foundation typed-kernel-links ...)` records the
// proof-substrate rules `pi-formation`, `lambda-introduction`,
// `application-elimination`, and `beta-conversion` as the canonical
// links-defined replacements for the host kernel's typing judgements.
// The host kernel still runs evaluation; the foundation is selected so
// the trust audit can list the four rules as the active derivations.
this.foundations.set('typed-kernel-links', {
name: 'typed-kernel-links',
description: 'links-defined typed-kernel fragment (Pi/lambda/apply/beta as proof rules)',
uses: [
'pi-formation',
'lambda-introduction',
'application-elimination',
'beta-conversion',
],
defines: new Map(),
extends: 'default-rml',
numericDomain: 'decimal-12',
truthDomain: 'default-truth',
carrier: null,
strictCarrier: false,
truthTables: null,
experimental: false,
root: null,
abits: null,
});
// Pre-seed the links-defined Peano naturals foundation (issue #97,
// Phase 12). Selecting it via `(with-foundation nat-links ...)`
// records the Nat proof-substrate rules, the dedicated `nat-equality`
// layer, and the rule-driven `eval-nat` normalizer as active
// foundation dependencies. The host's decimal numeric domain and
// default equality layers are unaffected.
this.foundations.set('nat-links', {
name: 'nat-links',
description: 'links-defined Peano naturals (zero/succ formation, add by recursion, induction with explicit forall/implication/predicate-application, nat-equality with reflexivity and successor congruence, nat-recursion/nat-eliminator, multiplication, rule-driven eval-nat normalizer)',
uses: [
'nat-zero-formation',
'nat-succ-formation',
'nat-add-zero',
'nat-add-succ',
'nat-induction',
'nat-equality',
'nat-refl',
'nat-cong-succ',
'forall',
'implication',
'predicate-application',
'nat-recursion',
'nat-eliminator',
'nat-rec-zero',
'nat-rec-succ',
'mul',
'nat-mul-zero',
'nat-mul-succ',
'eval-nat-normalize',
'eval-nat',
'nat-normal-form-to-host-number',
],
defines: new Map(),
extends: 'default-rml',
numericDomain: 'decimal-12',
truthDomain: 'default-truth',
carrier: null,
strictCarrier: false,
truthTables: null,
experimental: false,
root: null,
abits: null,
});
seedBuiltinRootConstructs(this);
}
registerRootConstruct(descriptor) {
if (!descriptor || typeof descriptor.name !== 'string' || !descriptor.name) {
throw new RmlError('E060', 'root-construct descriptor requires a name');
}
const previous = this.rootConstructs.get(descriptor.name) || null;
const merged = mergeRootConstructDescriptors(previous, descriptor);
this.rootConstructs.set(merged.name, merged);
return merged;
}
getRootConstruct(name) {
return this.rootConstructs.get(name) || null;
}
listRootConstructs() {
return [...this.rootConstructs.values()].sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
}
registerFoundation(foundation) {
if (!foundation || typeof foundation.name !== 'string' || !foundation.name) {
throw new RmlError('E061', 'foundation declaration requires a name');
}
const previous = this.foundations.get(foundation.name) || null;
const merged = mergeFoundationDescriptors(previous, foundation);
this.foundations.set(merged.name, merged);
return merged;
}
getFoundation(name) {
return this.foundations.get(name) || null;
}
enterFoundation(name) {
const foundation = this.foundations.get(name);
if (!foundation) {
throw new RmlError('E062', `Unknown foundation: ${name}`);
}
// Snapshot the operators that this foundation rebinds so `exitFoundation`
// can restore them. Only `(defines <op> <aggregator>)` entries that name
// a known truth aggregator are applied (avg, min, max, product,
// probabilistic_sum); other entries are data-only.
const snapshot = new Map();
const implementationSnapshot = new Map();
const snapshotImplementation = (opName) => {
if (implementationSnapshot.has(opName)) return;
const current = this.activeImplementations.get(opName);
implementationSnapshot.set(opName, current ? {
...current,
dependsOn: Array.isArray(current.dependsOn) ? current.dependsOn.slice() : [],
} : null);
};
if (foundation.defines && foundation.defines.size > 0) {
for (const [opName, implName] of foundation.defines.entries()) {
const fn = aggregatorOpFromName(this, implName);
if (fn === null) continue;
snapshotImplementation(opName);
snapshot.set(opName, this.ops.has(opName) ? this.ops.get(opName) : null);
this.ops.set(opName, fn);
this.activeImplementations.set(opName, {
construct: opName,
foundation: name,
implementation: implName,
status: 'host-primitive',
semanticStatus: 'host-trusted',
dependsOn: [implName],
});
}
}
// Truth tables apply on top of `(defines ...)` bindings so a foundation
// can pin a finite slice of an operator and leave the rest to the
// aggregator-based default that was just installed.
if (foundation.truthTables instanceof Map && foundation.truthTables.size > 0) {
for (const [opName, rows] of foundation.truthTables.entries()) {
if (!snapshot.has(opName)) {
snapshot.set(opName, this.ops.has(opName) ? this.ops.get(opName) : null);
}
const previous = this.ops.has(opName) ? this.ops.get(opName) : null;
const previousImpl = this.activeImplementations.get(opName) || null;
const fn = truthTableOpFromRows(this, opName, rows, previous);
if (fn === null) continue;
snapshotImplementation(opName);
const isTotal = truthTableRowsCompleteForCarrier(this, rows, foundation);
const fallbackDeps = isTotal ? [] : truthTableFallbackDependencies(this, opName, previousImpl);
this.ops.set(opName, fn);
this.activeImplementations.set(opName, {
construct: opName,
foundation: name,
implementation: `truth-table:${name}/${opName}`,
status: 'links-defined',
semanticStatus: 'links-checked',
dependsOn: fallbackDeps,
});
}
}
// Carrier snapshot for opt-in enforcement (issue #97, Section 2 of
// netkeep80's punch-list). `_strictCarrier` is what the evaluator hot
// path checks; `_carrier` is the resolved numeric set.
const carrierFrame = {
strictCarrier: this._strictCarrier === true,
carrier: this._carrier instanceof Set ? new Set(this._carrier) : null,
carrierLabel: this._carrierLabel || null,
};
if (foundation.strictCarrier === true && Array.isArray(foundation.carrier) && foundation.carrier.length > 0) {
this._strictCarrier = true;
this._carrier = new Set();
this._carrierLabel = foundation.carrier.join(' ');
for (const tok of foundation.carrier) {
const num = Number(tok);
if (Number.isFinite(num)) {
this._carrier.add(num);
continue;
}
// Symbolic carrier values (`true`, `false`, `unknown`, ...) resolve
// through `symbolProb` so user-defined truth constants flow in.
if (this.symbolProb.has(tok)) {
this._carrier.add(this.symbolProb.get(tok));
}
}
}
this._foundationStack.push({ name: this.activeFoundation, snapshot, implementationSnapshot, carrierFrame });
this.activeFoundation = name;
}
exitFoundation() {
if (this._foundationStack.length === 0) {
this.activeFoundation = 'default-rml';
this.activeImplementations.clear();
this._strictCarrier = false;
this._carrier = null;
this._carrierLabel = null;
return;
}
const frame = this._foundationStack.pop();
if (frame && frame.snapshot) {
for (const [opName, op] of frame.snapshot.entries()) {
if (op === null) {
this.ops.delete(opName);
} else {
this.ops.set(opName, op);
}
}
}
if (frame && frame.implementationSnapshot) {
for (const [opName, impl] of frame.implementationSnapshot.entries()) {
if (impl === null) {
this.activeImplementations.delete(opName);
} else {
this.activeImplementations.set(opName, impl);
}
}
}
if (frame && frame.carrierFrame) {
this._strictCarrier = frame.carrierFrame.strictCarrier === true;
this._carrier = frame.carrierFrame.carrier instanceof Set ? frame.carrierFrame.carrier : null;
this._carrierLabel = frame.carrierFrame.carrierLabel || null;
} else {
this._strictCarrier = false;
this._carrier = null;
this._carrierLabel = null;
}
this.activeFoundation = frame && typeof frame.name === 'string' ? frame.name : 'default-rml';
}
// Check `value` against the active foundation's carrier. Returns null when
// the carrier is inactive or the value is legal, or a human-readable
// message otherwise (consumed by the caller to build an E063 diagnostic).
checkCarrierValue(value) {
if (this._strictCarrier !== true || !(this._carrier instanceof Set) || this._carrier.size === 0) {
return null;
}
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
if (this._carrier.has(value)) return null;
const allowed = [...this._carrier].sort((a, b) => a - b).join(', ');
return `value ${formatTraceValue(value)} is not in active carrier {${allowed}}`;
}
// Build a structured trust / foundation report. The shape is intentionally
// plain so callers can stringify it (CLI, docs) or test against it (unit
// tests).
foundationReport() {
const active = this.activeFoundation || 'default-rml';
const foundation = this.foundations.get(active) || null;
const byStatus = new Map();
const bySemanticStatus = new Map();
for (const rc of this.listRootConstructs()) {
const status = rc.status || 'unknown';
if (!byStatus.has(status)) byStatus.set(status, []);
byStatus.get(status).push(rc.name);
const semanticStatus = semanticStatusForDescriptor(rc) || 'unknown';
if (!bySemanticStatus.has(semanticStatus)) bySemanticStatus.set(semanticStatus, []);
bySemanticStatus.get(semanticStatus).push(rc.name);
}
const buckets = {};
for (const [status, names] of byStatus.entries()) {
buckets[status] = names.slice().sort();
}
const semanticBuckets = {};
for (const [status, names] of bySemanticStatus.entries()) {
semanticBuckets[status] = names.slice().sort();
}
return {
activeFoundation: active,
description: foundation ? foundation.description : null,
numericDomain: foundation ? foundation.numericDomain : null,
truthDomain: foundation ? foundation.truthDomain : null,
rootConstructs: this.listRootConstructs().map(rc => ({
name: rc.name,
kind: rc.kind || null,
status: rc.status || null,
semanticStatus: semanticStatusForDescriptor(rc),
dependsOn: (rc.dependsOn || []).slice(),
encodedAs: rc.encodedAs || null,
pureLinksReady: typeof rc.pureLinksReady === 'boolean' ? rc.pureLinksReady : null,
override: rc.override || null,
plannedAs: rc.plannedAs || null,
})),
byStatus: buckets,
bySemanticStatus: semanticBuckets,
foundations: [...this.foundations.values()].map(f => ({
name: f.name,
description: f.description || null,
uses: (f.uses || []).slice(),
defines: [...(f.defines || new Map()).entries()].map(([k, v]) => ({ construct: k, implementation: v })),
extends: f.extends || null,
numericDomain: f.numericDomain || null,
truthDomain: f.truthDomain || null,
carrier: Array.isArray(f.carrier) ? f.carrier.slice() : null,
strictCarrier: f.strictCarrier === true,
truthTables: f.truthTables instanceof Map && f.truthTables.size > 0
? [...f.truthTables.entries()]
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
.map(([op, rows]) => ({
op,
rows: rows.map(r => ({ inputs: r.inputs.slice(), output: r.output })),
}))
: null,
experimental: f.experimental === true,
root: f.root || null,
abits: Array.isArray(f.abits) && f.abits.length > 0
? f.abits.map(a => ({ symbol: a.symbol, meaning: a.meaning }))
: null,
})).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0),
activeImplementations: [...this.activeImplementations.entries()]
.map(([construct, impl]) => ({
construct,
foundation: impl.foundation || null,
implementation: impl.implementation || null,
status: impl.status || null,
semanticStatus: impl.semanticStatus || semanticStatusForTrustStatus(impl.status) || null,
dependsOn: Array.isArray(impl.dependsOn) ? impl.dependsOn.slice() : [],
}))
.sort((a, b) => a.construct < b.construct ? -1 : a.construct > b.construct ? 1 : 0),
proofRules: [...this.proofRules.entries()]
.map(([name, r]) => ({
name,
premises: r.premises.map(p => keyOf(p)),
conclusion: keyOf(r.conclusion),
}))
.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0),
proofAssumptions: [...this.proofAssumptions.entries()]
.map(([name, a]) => ({
name,
kind: a.kind,
judgement: keyOf(a.judgement),
}))
.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0),
proofObjects: [...this.proofObjects.entries()]
.map(([name, po]) => ({
name,
rule: po.rule,
premises: po.premises.map(p => keyOf(p)),
premiseRefs: (po.premiseRefs || []).slice(),
conclusion: keyOf(po.conclusion),
}))
.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0),
strictPureLinks: this.strictPureLinks === true,
allowedHostPrimitives: [...this.allowedHostPrimitives].sort(),
dependencyGraph: buildDependencyGraph(this),
};
}
// Build a per-proof report (issue #97, Phase 13). The shape mirrors the
// foundation report so a CLI or test can stringify or assert against it.
// Reported fields:
// - kind: 'proof-report'
// - name, rule, conclusion, premises, premiseRefs
// - verdict { ok, error? }
// - dependencies: transitive list of (proof-object | axiom | assumption)
// names with their kinds, in topological order
// - rules: rule names that this proof transitively applies
// - rootConstructsUsed: registered root-construct names that appear as
// leaf operators in the proof's premises/conclusion/rule patterns
// - bySemanticStatus: rootConstructsUsed bucketed by semantic-status
// - byTrustStatus: rootConstructsUsed bucketed by trust status
// - activeFoundation
// - strictPureLinks
proofReport(name) {
if (typeof name !== 'string' || !name) {
return { kind: 'proof-report', name: null, verdict: { ok: false, error: 'proof name required' } };
}
const po = this.getProofObject(name);
if (!po) {
return {
kind: 'proof-report',
name,
verdict: { ok: false, error: `unknown proof-object ${name}` },
};
}
const verdict = checkProofObject(this, name);
const dependencies = [];
const seen = new Set();
const rules = new Set();
const walk = (refName) => {
if (seen.has(refName)) return;
seen.add(refName);
const ax = this.getProofAssumption(refName);
if (ax) {
dependencies.push({ name: ax.name, kind: ax.kind, judgement: keyOf(ax.judgement) });
return;
}
const dep = this.getProofObject(refName);
if (!dep) {
dependencies.push({ name: refName, kind: 'unknown', judgement: null });
return;
}
for (const sub of dep.premiseRefs || []) walk(sub);
if (dep.rule) rules.add(dep.rule);
dependencies.push({
name: dep.name,
kind: 'proof-object',
rule: dep.rule,
judgement: keyOf(dep.conclusion),
});
};
for (const ref of po.premiseRefs || []) walk(ref);
if (po.rule) rules.add(po.rule);
const rootNames = new Set([
'proof-replay',
'structural-equality',
'structural-matcher',
'substitution',
]);
const collectFromTerm = (term) => {
if (!Array.isArray(term)) {
if (typeof term === 'string' && this.rootConstructs.has(term)) rootNames.add(term);
return;
}
for (const t of term) collectFromTerm(t);
};
collectFromTerm(po.conclusion);
for (const p of po.premises || []) collectFromTerm(p);
for (const ruleName of rules) {
const rule = this.getProofRule(ruleName);
if (!rule) continue;
collectFromTerm(rule.conclusion);
for (const prem of rule.premises || []) collectFromTerm(prem);
}
const rootConstructsUsed = [...rootNames].sort();
const bySemanticStatus = {};
const byTrustStatus = {};
for (const rcName of rootConstructsUsed) {
const rc = this.rootConstructs.get(rcName);
if (!rc) continue;
const semantic = semanticStatusForDescriptor(rc) || 'unknown';
const trust = rc.status || 'unknown';
if (!bySemanticStatus[semantic]) bySemanticStatus[semantic] = [];
bySemanticStatus[semantic].push(rcName);
if (!byTrustStatus[trust]) byTrustStatus[trust] = [];
byTrustStatus[trust].push(rcName);
}
for (const key of Object.keys(bySemanticStatus)) bySemanticStatus[key].sort();
for (const key of Object.keys(byTrustStatus)) byTrustStatus[key].sort();
return {
kind: 'proof-report',
name,
rule: po.rule,
conclusion: keyOf(po.conclusion),
premises: (po.premises || []).map(keyOf),
premiseRefs: (po.premiseRefs || []).slice(),
verdict: verdict.ok ? { ok: true } : { ok: false, error: verdict.error },
dependencies,
rules: [...rules].sort(),
rootConstructsUsed,
bySemanticStatus,
byTrustStatus,
activeFoundation: this.activeFoundation || 'default-rml',
strictPureLinks: this.strictPureLinks === true,
};
}
// Return the transitive closure of a construct's dependencies, breadth-first
// and deterministically sorted at every level. Unknown construct names
// return `null`. Missing intermediate deps are silently skipped so a
// foundation that references a construct it has not yet registered can
// still report cleanly.
dependencyClosure(name) {
if (typeof name !== 'string' || !name) return null;
if (!this.rootConstructs.has(name)) return null;
return closureFor(this, name);
}
// ---------- Proof-object substrate (issue #97, Phase 3) ----------
registerProofRule(rule) {
if (!rule || typeof rule.name !== 'string' || !rule.name) {
throw new RmlError('E064', 'rule declaration requires a name');
}
this.proofRules.set(rule.name, {
name: rule.name,
premises: rule.premises ? rule.premises.slice() : [],
conclusion: rule.conclusion,
});
return this.proofRules.get(rule.name);
}
getProofRule(name) {
return this.proofRules.get(name) || null;
}
registerProofAssumption(assumption) {
if (!assumption || typeof assumption.name !== 'string' || !assumption.name) {
throw new RmlError('E064', 'proof assumption declaration requires a name');
}
if (assumption.judgement === null || assumption.judgement === undefined) {
throw new RmlError('E064', `${assumption.kind || 'assumption'} ${assumption.name} requires a judgement`);
}
this.proofAssumptions.set(assumption.name, {
name: assumption.name,
kind: assumption.kind || 'assumption',
judgement: assumption.judgement,
});
return this.proofAssumptions.get(assumption.name);
}
getProofAssumption(name) {
return this.proofAssumptions.get(name) || null;
}
registerProofObject(po) {
if (!po || typeof po.name !== 'string' || !po.name) {
throw new RmlError('E064', 'proof-object declaration requires a name');
}
if (typeof po.rule !== 'string' || !po.rule) {
throw new RmlError('E064', `proof-object ${po.name} must include (applies <rule>)`);
}
this.proofObjects.set(po.name, {
name: po.name,
rule: po.rule,
premises: po.premises ? po.premises.slice() : [],
premiseRefs: po.premiseRefs ? po.premiseRefs.slice() : [],
conclusion: po.conclusion,
});
return this.proofObjects.get(po.name);
}
getProofObject(name) {
return this.proofObjects.get(name) || null;
}
}
// Merge a new root-construct descriptor with the previously stored one. The
// merge prefers explicitly set fields from the new descriptor but preserves
// information that the new descriptor leaves unspecified, so multiple
// `(root-construct foo ...)` forms can build the record incrementally without
// clobbering already-known fields.
function mergeRootConstructDescriptors(previous, next) {
const base = previous ? { ...previous } : {
name: next.name,
status: null,
semanticStatus: null,
kind: null,
dependsOn: [],
encodedAs: null,
pureLinksReady: null,
override: null,
plannedAs: null,
foundation: null,
};
base.name = next.name;
if (next.status !== undefined && next.status !== null) base.status = next.status;
if (next.semanticStatus !== undefined && next.semanticStatus !== null) base.semanticStatus = next.semanticStatus;
if (next.kind !== undefined && next.kind !== null) base.kind = next.kind;
if (Array.isArray(next.dependsOn) && next.dependsOn.length > 0) {
const seen = new Set(base.dependsOn || []);
const deps = (base.dependsOn || []).slice();
for (const dep of next.dependsOn) {
if (!seen.has(dep)) {
seen.add(dep);
deps.push(dep);
}
}
base.dependsOn = deps;
} else if (!Array.isArray(base.dependsOn)) {
base.dependsOn = [];
}
if (next.encodedAs !== undefined && next.encodedAs !== null) base.encodedAs = next.encodedAs;
if (typeof next.pureLinksReady === 'boolean') base.pureLinksReady = next.pureLinksReady;
if (next.override !== undefined && next.override !== null) base.override = next.override;
if (next.plannedAs !== undefined && next.plannedAs !== null) base.plannedAs = next.plannedAs;
if (next.foundation !== undefined && next.foundation !== null) base.foundation = next.foundation;
return base;
}
const SEMANTIC_STATUS_ORDER = [
'host-trusted',
'links-described',
'links-checked',
'links-evaluated',
'self-hosted',
];
function semanticStatusForTrustStatus(status) {
switch (status) {
case 'host-primitive':
case 'host-derived':
case 'external-trusted':
case 'user-configurable':
case 'user-overridden':
return 'host-trusted';
case 'links-encoded':
case 'planned':
return 'links-described';
case 'links-defined':
return 'links-checked';
default:
return null;
}
}
function semanticStatusForDescriptor(descriptor) {
if (!descriptor) return null;
return descriptor.semanticStatus || semanticStatusForTrustStatus(descriptor.status) || null;
}
// ---------- Dependency graph traversal (issue #97, Phase 7) ----------
//
// Compute the transitive closure of a single root-construct's dependencies,
// breadth-first. Missing intermediate deps are silently skipped so a
// foundation that names a construct it has not yet registered still reports
// a clean closure for everything it has registered. Returns `null` if the
// root itself is unknown.
function closureFor(env, name) {
if (!env.rootConstructs.has(name)) return null;
const seen = new Set();
const order = [];
const queue = [name];
while (queue.length > 0) {
const next = queue.shift();
if (seen.has(next)) continue;
seen.add(next);
if (next !== name) order.push(next);
const rc = env.rootConstructs.get(next);
if (!rc) continue;
const deps = Array.isArray(rc.dependsOn) ? rc.dependsOn.slice().sort() : [];
for (const dep of deps) {
if (!seen.has(dep)) queue.push(dep);
}
}
return order.sort();
}
// Build a `{ <name>: [<dep>, ...] }` map covering every registered
// root-construct in deterministic, sorted order at every level. Constructs
// with no dependencies map to an empty array so the trust audit can still
// see them in the closure (callers can ignore them or trust them at face
// value). This is the global dependency view, complementing
// `dependencyClosure(name)` which gives a per-construct slice.
function buildDependencyGraph(env) {
const entries = [...env.rootConstructs.keys()].sort();
const out = {};
for (const name of entries) {
out[name] = closureFor(env, name) || [];
}
return out;
}
// ---------- MTC/anum serialization (issue #97, Phase 9) ----------
//
// Encode a link expression into a string using only the four abits of the
// experimental `mtc-anum` foundation: `[`, `]`, `0`, `1`. Each Node is
// wrapped in `[ ... ]`; the first character after `[` is a tag — `0` for a
// leaf, `1` for a list. A leaf's payload is its UTF-8 bytes encoded
// most-significant-bit-first, 8 bits per byte. A list's payload is the
// concatenated encoding of its children. Round-trippable via `decodeAnum`.
function encodeAnum(node) {
if (Array.isArray(node)) {
let out = '[1';
for (const child of node) out += encodeAnum(child);
return out + ']';
}
if (typeof node === 'string') {
return '[0' + stringToBitstring(node) + ']';
}
if (typeof node === 'number') {
return '[0' + stringToBitstring(String(node)) + ']';
}
throw new RmlError('E066', `cannot anum-encode value of type ${typeof node}`);
}
function stringToBitstring(s) {
const bytes = new TextEncoder().encode(s);
let bits = '';
for (const byte of bytes) bits += byte.toString(2).padStart(8, '0');
return bits;
}
function bitstringToString(bits) {
if (bits.length % 8 !== 0) {
throw new RmlError(
'E066',
`anum-decode: leaf bit-count ${bits.length} is not byte-aligned`,
);
}
const bytes = new Uint8Array(bits.length / 8);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(bits.substring(i * 8, i * 8 + 8), 2);
}
return new TextDecoder().decode(bytes);
}
// Decode an anum-encoded string into a Node. Strictly enforces the
// `[tag payload]` shape; any character outside the four-abit alphabet
// raises an E066 diagnostic. Returns the decoded Node; throws if trailing
// content remains after the top-level frame.
function decodeAnum(s) {
if (typeof s !== 'string') {
throw new RmlError('E066', 'anum-decode requires a string input');
}
const { node, pos } = decodeAnumAt(s, 0);
if (pos !== s.length) {
throw new RmlError('E066', `anum-decode: trailing data at position ${pos}`);
}
return node;
}
function decodeAnumAt(s, pos) {
if (s[pos] !== '[') {
throw new RmlError('E066', `anum-decode: expected '[' at position ${pos}`);
}
pos++;
const tag = s[pos];
if (tag === '0') {
pos++;
let bits = '';
while (pos < s.length && s[pos] !== ']') {
if (s[pos] !== '0' && s[pos] !== '1') {
throw new RmlError(
'E066',
`anum-decode: leaf payload may only contain '0' or '1' (got '${s[pos]}' at ${pos})`,
);
}
bits += s[pos];
pos++;
}
if (s[pos] !== ']') {
throw new RmlError('E066', `anum-decode: unterminated leaf starting before position ${pos}`);
}
pos++;
return { node: bitstringToString(bits), pos };
}
if (tag === '1') {
pos++;
const items = [];
while (pos < s.length && s[pos] !== ']') {
if (s[pos] !== '[') {
throw new RmlError(
'E066',
`anum-decode: list child must start with '[' (got '${s[pos]}' at ${pos})`,
);
}
const { node, pos: next } = decodeAnumAt(s, pos);
items.push(node);
pos = next;
}
if (s[pos] !== ']') {
throw new RmlError('E066', `anum-decode: unterminated list starting before position ${pos}`);
}
pos++;
return { node: items, pos };
}
throw new RmlError(
'E066',
`anum-decode: expected tag '0' or '1' after '[' at position ${pos}`,
);
}
function mergeFoundationDescriptors(previous, next) {
const base = previous ? {
name: previous.name,
description: previous.description || null,
uses: (previous.uses || []).slice(),
defines: new Map(previous.defines || []),
extends: previous.extends || null,
numericDomain: previous.numericDomain || null,
truthDomain: previous.truthDomain || null,
carrier: Array.isArray(previous.carrier) ? previous.carrier.slice() : null,
strictCarrier: previous.strictCarrier === true,
truthTables: previous.truthTables instanceof Map
? new Map([...previous.truthTables.entries()].map(([k, rows]) => [k, rows.map(r => ({ inputs: r.inputs.slice(), output: r.output }))]))
: null,
experimental: previous.experimental === true,
root: previous.root || null,
abits: Array.isArray(previous.abits)
? previous.abits.map(a => ({ symbol: a.symbol, meaning: a.meaning }))
: null,
} : {
name: next.name,
description: null,
uses: [],
defines: new Map(),
extends: null,
numericDomain: null,
truthDomain: null,
carrier: null,
strictCarrier: false,
truthTables: null,
experimental: false,
root: null,
abits: null,
};
base.name = next.name;
if (next.description) base.description = next.description;
if (Array.isArray(next.uses) && next.uses.length > 0) {
const seen = new Set(base.uses);
for (const u of next.uses) {
if (!seen.has(u)) { seen.add(u); base.uses.push(u); }
}
}
if (next.defines instanceof Map) {
for (const [k, v] of next.defines.entries()) base.defines.set(k, v);
}
if (next.extends) base.extends = next.extends;
if (next.numericDomain) base.numericDomain = next.numericDomain;
if (next.truthDomain) base.truthDomain = next.truthDomain;
// Carrier (issue #97): the explicit set of values legal inside the
// foundation; opt-in enforcement is controlled by `strictCarrier`. A
// later registration with the same name replaces the carrier list but
// only flips `strictCarrier` to true (never silently back off).
if (Array.isArray(next.carrier) && next.carrier.length > 0) {
base.carrier = next.carrier.slice();
}
if (next.strictCarrier === true) base.strictCarrier = true;
// Truth tables (issue #97, Section 3 of netkeep80's punch-list). A later
// registration adds/overwrites table entries operator-by-operator so the
// user can extend a previously declared foundation with more tables.
if (next.truthTables instanceof Map && next.truthTables.size > 0) {
if (!(base.truthTables instanceof Map)) base.truthTables = new Map();
for (const [k, rows] of next.truthTables.entries()) {
base.truthTables.set(k, rows.map(r => ({ inputs: r.inputs.slice(), output: r.output })));
}
}
// Experimental foundation profile metadata (issue #97, Phase 9). A later
// registration can flip `experimental` to true (never silently back to
// false), set or replace the root symbol, and append additional abits.
if (next.experimental === true) base.experimental = true;
if (next.root) base.root = next.root;
if (Array.isArray(next.abits) && next.abits.length > 0) {
if (!Array.isArray(base.abits)) base.abits = [];
const seen = new Set(base.abits.map(a => a.symbol));
for (const a of next.abits) {
if (!seen.has(a.symbol)) {
seen.add(a.symbol);
base.abits.push({ symbol: a.symbol, meaning: a.meaning });
}
}
}
return base;
}
// Build a truth-aggregator operator function from its canonical name
// (`avg`, `min`, `max`, `product`/`prod`, `probabilistic_sum`/`ps`). Returns
// `null` for unrecognized names so callers can skip data-only `(defines ...)`
// entries silently. Mirrors the `(<op>: <aggregator>)` resolution path so
// foundation-driven rebindings produce the same semantics as explicit
// operator assignments.
function aggregatorOpFromName(env, sel) {
if (typeof sel !== 'string') return null;
const lo = env.lo;
const agg =
sel === 'avg' ? xs => xs.reduce((a, b) => a + b, 0) / xs.length :
sel === 'min' ? xs => xs.length ? Math.min(...xs) : lo :
sel === 'max' ? xs => xs.length ? Math.max(...xs) : lo :
sel === 'product' || sel === 'prod' ? xs => xs.reduce((a, b) => a * b, 1) :
sel === 'probabilistic_sum' || sel === 'ps' ? xs => 1 - xs.reduce((a, b) => a * (1 - b), 1) :
null;
if (!agg) return null;
return (...xs) => xs.length ? agg(xs) : lo;
}
// Resolve a token from a truth-table row (input or output) to a numeric value.
// Numeric literals stay numeric; symbolic constants flow through
// `env.symbolProb` so user-declared truth constants like `(true: 1)` or
// `(unknown: 0.5)` are honoured. Returns `null` when the token cannot be
// resolved so the caller can skip the row gracefully.
function resolveTruthTableValue(env, tok) {
if (typeof tok !== 'string') return null;
const num = Number(tok);
if (Number.isFinite(num)) return num;
if (env.symbolProb.has(tok)) return env.symbolProb.get(tok);
return null;
}
// Build a host `Op` function from a finite truth table. When invoked with
// argument vector `xs`, the function looks up the first row whose inputs
// match (within ±1e-12 float tolerance). If no row matches, the function
// delegates to the `previous` op (the snapshot captured at activation),
// which keeps partial tables backward-compatible: a foundation that only
// pins down a few rows still falls through to the host default for the rest.
function truthTableOpFromRows(env, opName, rows, previous) {
const resolved = [];
for (const row of rows) {
const inputs = row.inputs.map(t => resolveTruthTableValue(env, t));
const output = resolveTruthTableValue(env, row.output);
if (inputs.some(v => v === null) || output === null) {
// Skip rows that reference unknown symbols. The user can always
// re-declare the row once the symbol is bound.
continue;
}
resolved.push({ inputs, output });
}
if (resolved.length === 0) return null;
const fallback = typeof previous === 'function' ? previous : null;
return (...xs) => {
for (const row of resolved) {
if (row.inputs.length !== xs.length) continue;
let match = true;
for (let i = 0; i < xs.length; i++) {
if (typeof xs[i] !== 'number' || !Number.isFinite(xs[i])) { match = false; break; }
if (Math.abs(xs[i] - row.inputs[i]) > 1e-12) { match = false; break; }
}
if (match) return row.output;
}
if (fallback) return fallback(...xs);
// No table row matched and no host fallback exists. Treat as bottom.
return env.lo;
};
}
function _truthTableKey(values) {
return values.map(v => Number(v).toPrecision(15)).join('\u0001');
}
function _resolvedCarrierValues(env, foundation) {
if (!foundation || foundation.strictCarrier !== true || !Array.isArray(foundation.carrier) || foundation.carrier.length === 0) {
return null;
}
const values = [];
const seen = new Set();
for (const tok of foundation.carrier) {
const value = resolveTruthTableValue(env, tok);
if (value === null) return null;
const key = _truthTableKey([value]);
if (!seen.has(key)) {
seen.add(key);
values.push(value);
}
}
return values.length > 0 ? values : null;
}
function truthTableRowsCompleteForCarrier(env, rows, foundation) {
const carrier = _resolvedCarrierValues(env, foundation);
if (carrier === null) return false;
let arity = null;
const seenRows = new Set();
for (const row of rows) {
if (!row || !Array.isArray(row.inputs)) return false;
if (arity === null) arity = row.inputs.length;
if (row.inputs.length !== arity) return false;
const inputs = row.inputs.map(t => resolveTruthTableValue(env, t));
const output = resolveTruthTableValue(env, row.output);
if (inputs.some(v => v === null) || output === null) return false;
seenRows.add(_truthTableKey(inputs));
}
if (arity === null) return false;
const required = carrier.length ** arity;
if (seenRows.size < required) return false;
const visit = (prefix, depth) => {
if (depth === arity) return seenRows.has(_truthTableKey(prefix));
for (const value of carrier) {
if (!visit(prefix.concat([value]), depth + 1)) return false;
}
return true;
};
return visit([], 0);
}
function truthTableFallbackDependencies(env, opName, previousImpl) {
const deps = [];
if (previousImpl && Array.isArray(previousImpl.dependsOn) && previousImpl.dependsOn.length > 0) {
deps.push(...previousImpl.dependsOn);
} else {
const rc = env.getRootConstruct(opName);
if (rc && Array.isArray(rc.dependsOn) && rc.dependsOn.length > 0) {
deps.push(...rc.dependsOn);
}
}
deps.push('truth-table-fallback');
return [...new Set(deps)];
}
// Seed the registry with the built-in descriptors that describe what the
// current host implementation actually trusts. Every field is data-only and
// matches the existing implementation: changing this list does not change
// runtime behaviour, only the trust report.
function seedBuiltinRootConstructs(env) {
const seeds = [
// Parsing / LiNo layer
{ name: 'lino-parser', kind: 'parser', status: 'external-trusted', encodedAs: 'links-notation', pureLinksReady: false },
{ name: 'canonical-printer', kind: 'printer', status: 'host-primitive', encodedAs: 'keyOf' },
{ name: 'structural-equality', kind: 'equality-layer', status: 'host-primitive', encodedAs: 'isStructurallySame' },
{ name: 'structural-matcher', kind: 'matcher', status: 'external-trusted', semanticStatus: 'host-trusted', encodedAs: 'matchProofPattern' },
// Numeric layer
{ name: 'decimal-12-arithmetic', kind: 'numeric-domain', status: 'host-primitive', encodedAs: 'decRound', pureLinksReady: false },
{ name: '+', kind: 'arithmetic-operator', status: 'host-primitive', dependsOn: ['decimal-12-arithmetic'] },
{ name: '-', kind: 'arithmetic-operator', status: 'host-primitive', dependsOn: ['decimal-12-arithmetic'] },
{ name: '*', kind: 'arithmetic-operator', status: 'host-primitive', dependsOn: ['decimal-12-arithmetic'] },
{ name: '/', kind: 'arithmetic-operator', status: 'host-primitive', dependsOn: ['decimal-12-arithmetic'] },
{ name: '<', kind: 'comparison-operator', status: 'host-primitive', dependsOn: ['decimal-12-arithmetic'] },
{ name: '<=', kind: 'comparison-operator', status: 'host-primitive', dependsOn: ['decimal-12-arithmetic'] },
// Truth / aggregator layer
{ name: 'truth-range', kind: 'truth-domain', status: 'user-configurable', encodedAs: 'Env.lo/Env.hi' },
{ name: 'valence', kind: 'truth-domain', status: 'user-configurable', encodedAs: 'Env.valence' },
{ name: 'clamp', kind: 'truth-normalization', status: 'host-primitive', encodedAs: 'Env.clamp' },
{ name: 'quantize', kind: 'truth-normalization', status: 'host-primitive', encodedAs: 'quantize' },
{ name: 'true', kind: 'truth-constant', status: 'user-configurable' },
{ name: 'false', kind: 'truth-constant', status: 'user-configurable' },
{ name: 'unknown', kind: 'truth-constant', status: 'user-configurable' },
{ name: 'undefined', kind: 'truth-constant', status: 'user-configurable' },
{ name: 'avg', kind: 'aggregator', status: 'host-primitive' },
{ name: 'min', kind: 'aggregator', status: 'host-primitive' },
{ name: 'max', kind: 'aggregator', status: 'host-primitive' },
{ name: 'product', kind: 'aggregator', status: 'host-primitive' },
{ name: 'probabilistic_sum', kind: 'aggregator', status: 'host-primitive' },
{ name: 'truth-table-fallback', kind: 'truth-table-fallback', status: 'host-derived' },
// Logical layer
{ name: 'not', kind: 'truth-operator', status: 'user-configurable', dependsOn: ['truth-range', 'decimal-12-arithmetic'] },
{ name: 'and', kind: 'truth-operator', status: 'user-configurable', dependsOn: ['avg'] },
{ name: 'or', kind: 'truth-operator', status: 'user-configurable', dependsOn: ['max'] },
{ name: 'both', kind: 'truth-operator', status: 'user-configurable', dependsOn: ['avg'] },
{ name: 'neither', kind: 'truth-operator', status: 'user-configurable', dependsOn: ['product'] },
// Equality layer
{ name: '=', kind: 'equality-layer', status: 'host-primitive', dependsOn: ['structural-equality', 'decimal-12-arithmetic'] },
{ name: '!=', kind: 'equality-layer', status: 'host-derived', dependsOn: ['=', 'not'] },
{ name: 'assigned-equality', kind: 'equality-layer', status: 'host-primitive' },
{ name: 'numeric-equality', kind: 'equality-layer', status: 'host-primitive', dependsOn: ['decimal-12-arithmetic'] },
{ name: 'definitional-equality', kind: 'equality-layer', status: 'host-primitive', dependsOn: ['beta-reduction', 'structural-equality'] },
// Typed kernel layer
{ name: 'Type', kind: 'universe-form', status: 'host-primitive', pureLinksReady: false, plannedAs: 'links-defined' },
{ name: 'Prop', kind: 'universe-form', status: 'host-primitive', dependsOn: ['Type'] },
{ name: 'Pi', kind: 'binder', status: 'host-primitive', dependsOn: ['Type', 'substitution', 'freshness'] },
{ name: 'lambda', kind: 'binder', status: 'host-primitive', dependsOn: ['Pi', 'substitution'] },
{ name: 'apply', kind: 'eliminator', status: 'host-primitive', dependsOn: ['lambda', 'beta-reduction'] },
{ name: 'beta-reduction', kind: 'reduction-rule', status: 'host-primitive', dependsOn: ['substitution', 'freshness', 'alpha-renaming'] },
{ name: 'substitution', kind: 'meta-operation', status: 'host-primitive', encodedAs: 'substitute' },
{ name: 'freshness', kind: 'meta-operation', status: 'host-primitive', encodedAs: 'evalFresh' },
{ name: 'alpha-renaming', kind: 'meta-operation', status: 'host-primitive' },
{ name: 'normalization', kind: 'reduction-rule', status: 'host-primitive', encodedAs: 'normalizeTerm', dependsOn: ['beta-reduction'] },
{ name: 'whnf', kind: 'reduction-rule', status: 'host-primitive', encodedAs: 'whnfTerm', dependsOn: ['beta-reduction'] },
{ name: 'conversion', kind: 'equality-layer', status: 'host-primitive', dependsOn: ['beta-reduction', 'normalization', 'structural-equality'] },
// Phase 5 typed-kernel-links proof-substrate rules: links-defined
// mirrors of `Pi`, `lambda`, `apply`, `beta-reduction` that the
// `typed-kernel-links` foundation selects. Pre-seeded so the trust
// audit reports them as `links-defined`/`links-checked` immediately,
// matching the corresponding declarations in `lib/self/foundations.lino`.
{ name: 'pi-formation', kind: 'typing-rule', status: 'links-defined', dependsOn: ['Pi'] },
{ name: 'lambda-introduction', kind: 'typing-rule', status: 'links-defined', dependsOn: ['lambda'] },
{ name: 'application-elimination', kind: 'typing-rule', status: 'links-defined', dependsOn: ['apply'] },
{ name: 'beta-conversion', kind: 'reduction-rule', status: 'links-defined', dependsOn: ['beta-reduction'] },
// Inductive / coinductive
{ name: 'inductive', kind: 'declaration', status: 'host-primitive', dependsOn: ['Type', 'Pi'] },
{ name: 'coinductive', kind: 'declaration', status: 'host-primitive', dependsOn: ['Type', 'Pi'] },
// Proof / tactics layer
{ name: 'proof-replay', kind: 'replay-checker', status: 'host-primitive', encodedAs: 'check.mjs' },
{ name: 'proof-object', kind: 'proof-data', status: 'links-encoded', encodedAs: 'proof-object' },
{ name: 'proof-rule-declaration', kind: 'proof-data', status: 'links-encoded', encodedAs: 'rule' },
{ name: 'proof-checking-relation', kind: 'checking-relation', status: 'links-defined', dependsOn: ['proof-replay', 'structural-equality', 'proof-object'] },
{ name: 'rule-application-check', kind: 'checking-relation', status: 'links-defined', dependsOn: ['proof-replay', 'structural-equality', 'proof-rule-declaration'] },
{ name: 'by', kind: 'proof-rule', status: 'host-primitive' },
// Links-defined Nat fragment (issue #97). These built-in registry
// entries let strict mode audit `(eval-nat ...)` even when a program has
// not loaded `lib/self/foundations.lino`.
{ name: 'Nat', kind: 'inductive-type', status: 'links-defined', semanticStatus: 'links-checked' },
{ name: 'zero', kind: 'constructor', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['Nat'] },
{ name: 'succ', kind: 'constructor', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['Nat'] },
{ name: 'nat-equality', kind: 'equality-layer', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['Nat', 'structural-equality'], encodedAs: 'nat-equals' },
{ name: 'nat-recursion', kind: 'recursor', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['Nat', 'zero', 'succ', 'nat-equality', 'proof-replay', 'structural-equality'] },
{ name: 'add', kind: 'derived-operation', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['Nat', 'zero', 'succ', 'nat-recursion', 'nat-equality'] },
{ name: 'nat-add-zero', kind: 'computation-rule', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['add', 'zero', 'nat-recursion', 'nat-equality'] },
{ name: 'nat-add-succ', kind: 'computation-rule', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['add', 'succ', 'nat-recursion', 'nat-equality'] },
{ name: 'nat-zero-formation', kind: 'typing-rule', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['Nat', 'zero'] },
{ name: 'nat-succ-formation', kind: 'typing-rule', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['Nat', 'succ'] },
{ name: 'forall', kind: 'universal-quantifier', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['Nat'] },
{ name: 'implication', kind: 'logical-connective', status: 'links-defined', semanticStatus: 'links-checked', encodedAs: 'implies' },
{ name: 'predicate-application', kind: 'logical-form', status: 'links-defined', semanticStatus: 'links-checked', encodedAs: 'at' },
{ name: 'nat-induction', kind: 'proof-principle', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['Nat', 'forall', 'implication', 'predicate-application', 'substitution', 'freshness', 'proof-replay', 'structural-equality'] },
{ name: 'nat-refl', kind: 'equality-rule', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['Nat', 'nat-equality'] },
{ name: 'nat-cong-succ', kind: 'equality-rule', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['Nat', 'succ', 'nat-equality'] },
{ name: 'nat-eliminator', kind: 'eliminator', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['Nat', 'nat-recursion', 'nat-induction'] },
{ name: 'nat-rec-zero', kind: 'computation-rule', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['nat-recursion', 'zero', 'nat-equality'] },
{ name: 'nat-rec-succ', kind: 'computation-rule', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['nat-recursion', 'succ', 'nat-equality'] },
{ name: 'mul', kind: 'derived-operation', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['Nat', 'zero', 'succ', 'add', 'nat-recursion', 'nat-equality'] },
{ name: 'nat-mul-zero', kind: 'computation-rule', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['mul', 'zero', 'nat-recursion', 'nat-equality'] },
{ name: 'nat-mul-succ', kind: 'computation-rule', status: 'links-defined', semanticStatus: 'links-checked', dependsOn: ['mul', 'succ', 'add', 'nat-recursion', 'nat-equality'] },
{ name: 'eval-nat-normalize', kind: 'evaluator-fragment', status: 'links-defined', semanticStatus: 'links-evaluated', dependsOn: ['Nat', 'zero', 'succ', 'add', 'mul', 'nat-add-zero', 'nat-add-succ', 'nat-mul-zero', 'nat-mul-succ', 'structural-matcher'] },
{ name: 'eval-nat', kind: 'evaluator', status: 'links-defined', semanticStatus: 'links-evaluated', dependsOn: ['eval-nat-normalize', 'nat-normal-form-to-host-number'] },
{ name: 'nat-normal-form-to-host-number', kind: 'renderer', status: 'host-derived', semanticStatus: 'host-trusted', dependsOn: ['eval-nat-normalize'] },
{ name: 'smt-trusted', kind: 'external-decision', status: 'external-trusted' },
{ name: 'atp-trusted', kind: 'external-decision', status: 'external-trusted' },
// Metatheorem layer
{ name: 'mode', kind: 'mode-declaration', status: 'host-primitive' },
{ name: 'totality-check', kind: 'metatheorem', status: 'host-primitive' },
{ name: 'coverage-check', kind: 'metatheorem', status: 'host-primitive' },
{ name: 'termination-check', kind: 'metatheorem', status: 'host-primitive' },
// Self-bootstrap layer
{ name: 'self.evaluator', kind: 'self-bootstrap', status: 'links-encoded', encodedAs: 'lib/self/evaluator.lino' },
{ name: 'self.grammar', kind: 'self-bootstrap', status: 'links-encoded', encodedAs: 'lib/self/grammar.lino' },
{ name: 'self.types', kind: 'self-bootstrap', status: 'links-encoded', encodedAs: 'lib/self/types.lino' },
{ name: 'self.operators', kind: 'self-bootstrap', status: 'links-encoded', encodedAs: 'lib/self/operators.lino' },
{ name: 'self.metatheorem', kind: 'self-bootstrap', status: 'links-encoded', encodedAs: 'lib/self/metatheorem.lino' },
];
for (const seed of seeds) {
env.rootConstructs.set(seed.name, mergeRootConstructDescriptors(null, seed));
}
}
// Parse a `(root-construct <name> (status ...) (kind ...) ...)` form into a
// descriptor record. Returns the descriptor or throws an RmlError(E060).
function parseRootConstructForm(node) {
if (!Array.isArray(node) || node[0] !== 'root-construct' || node.length < 2) {
throw new RmlError('E060', 'root-construct form must be `(root-construct <name> ...)`');
}
const rawName = node[1];
if (typeof rawName !== 'string' || !rawName) {
throw new RmlError('E060', 'root-construct name must be a non-empty identifier');
}
const descriptor = { name: rawName };
for (let i = 2; i < node.length; i++) {
const child = node[i];
if (!Array.isArray(child) || child.length < 1 || typeof child[0] !== 'string') {
throw new RmlError('E060', 'root-construct child clauses must be lists led by a keyword');
}
const key = child[0];
const rest = child.slice(1);
switch (key) {
case 'status':
if (rest.length !== 1 || typeof rest[0] !== 'string') {
throw new RmlError('E060', '(status ...) requires one symbol');
}
descriptor.status = rest[0];
break;
case 'semantic-status':
if (rest.length !== 1 || typeof rest[0] !== 'string') {
throw new RmlError('E060', '(semantic-status ...) requires one symbol');
}
descriptor.semanticStatus = rest[0];
break;
case 'kind':
if (rest.length !== 1 || typeof rest[0] !== 'string') {
throw new RmlError('E060', '(kind ...) requires one symbol');
}
descriptor.kind = rest[0];
break;
case 'depends-on':
descriptor.dependsOn = rest.map(t => Array.isArray(t) ? keyOf(t) : String(t));
break;
case 'encoded-as':
descriptor.encodedAs = rest.map(t => Array.isArray(t) ? keyOf(t) : String(t)).join(' ');
break;
case 'pure-links-ready':
if (rest.length !== 1 || (rest[0] !== 'yes' && rest[0] !== 'no')) {
throw new RmlError('E060', '(pure-links-ready ...) must be `yes` or `no`');
}
descriptor.pureLinksReady = rest[0] === 'yes';
break;
case 'override':
descriptor.override = rest.map(t => Array.isArray(t) ? keyOf(t) : String(t)).join(' ');
break;
case 'planned-as':
descriptor.plannedAs = rest.map(t => Array.isArray(t) ? keyOf(t) : String(t)).join(' ');
break;
case 'foundation':
if (rest.length !== 1 || typeof rest[0] !== 'string') {
throw new RmlError('E060', '(foundation ...) must be a single name');
}
descriptor.foundation = rest[0];
break;
case 'implemented-by':
descriptor.encodedAs = rest.map(t => Array.isArray(t) ? keyOf(t) : String(t)).join(' ');
break;
case 'surface':
case 'description':
case 'used-by':
// free-form annotation; ignore for now but accept syntactically
break;
default:
// Unknown clause: accept silently to allow forward compatibility.
break;
}
}
return descriptor;
}
function parseFoundationForm(node) {
if (!Array.isArray(node) || node[0] !== 'foundation' || node.length < 2) {
throw new RmlError('E061', 'foundation form must be `(foundation <name> ...)`');
}
const rawName = node[1];
if (typeof rawName !== 'string' || !rawName) {
throw new RmlError('E061', 'foundation name must be a non-empty identifier');
}
const foundation = { name: rawName, uses: [], defines: new Map() };
for (let i = 2; i < node.length; i++) {
const child = node[i];
if (!Array.isArray(child) || child.length < 1 || typeof child[0] !== 'string') {
throw new RmlError('E061', 'foundation child clauses must be lists led by a keyword');
}
const key = child[0];
const rest = child.slice(1);
switch (key) {
case 'uses':
for (const u of rest) foundation.uses.push(Array.isArray(u) ? keyOf(u) : String(u));
break;
case 'defines':
if (rest.length < 1 || typeof rest[0] !== 'string') {
throw new RmlError('E061', '(defines <construct> <implementation>) requires a construct name');
}
foundation.defines.set(rest[0], rest.slice(1).map(t => Array.isArray(t) ? keyOf(t) : String(t)).join(' ') || 'links-defined');
break;
case 'extends':
if (rest.length !== 1 || typeof rest[0] !== 'string') {
throw new RmlError('E061', '(extends ...) requires one foundation name');
}
foundation.extends = rest[0];
break;
case 'numeric-domain':
if (rest.length !== 1 || typeof rest[0] !== 'string') {
throw new RmlError('E061', '(numeric-domain ...) requires one name');
}
foundation.numericDomain = rest[0];
break;
case 'truth-domain':
if (rest.length !== 1 || typeof rest[0] !== 'string') {
throw new RmlError('E061', '(truth-domain ...) requires one name');
}
foundation.truthDomain = rest[0];
break;
case 'carrier':
// `(carrier <val1> <val2> ...)` — list the values that the active
// foundation considers legal. Each value is kept as a string so
// `enterFoundation` can resolve symbolic constants (`true`, `false`,
// `unknown`) through `env.symbolProb` at activation time. Numeric
// literals stay literal.
if (rest.length === 0) {
throw new RmlError('E061', '(carrier ...) requires at least one value');
}
foundation.carrier = rest.map(t => Array.isArray(t) ? keyOf(t) : String(t));
break;
case 'strict-carrier':
// `(strict-carrier)` opts the foundation into runtime enforcement.
// Without this clause, `(carrier ...)` is informational only and the
// evaluator stays backward-compatible.
foundation.strictCarrier = true;
break;
case 'truth-table': {
// `(truth-table <op> (in1 in2 ... -> out) ...)` — links-defined finite
// truth table that rebinds `<op>` for the duration of the foundation.
// Each row's inputs and output may be numeric literals or symbolic
// truth constants (`true`, `false`, `unknown`); the latter are
// resolved through `env.symbolProb` when the foundation is entered.
if (rest.length < 1 || typeof rest[0] !== 'string') {
throw new RmlError('E061', '(truth-table <op> ...) requires an operator name');
}
const tableOp = rest[0];
const rows = [];
for (const raw of rest.slice(1)) {
if (!Array.isArray(raw)) {
throw new RmlError(
'E061',
`(truth-table ${tableOp} ...) rows must be lists like (in1 in2 -> out)`,
);
}
const arrowAt = raw.findIndex(t => t === '->');
if (arrowAt < 1 || arrowAt !== raw.length - 2) {
throw new RmlError(
'E061',
`(truth-table ${tableOp} ...) row must be (input ... -> output)`,
);
}
const inputs = raw.slice(0, arrowAt).map(t => Array.isArray(t) ? keyOf(t) : String(t));
const output = Array.isArray(raw[arrowAt + 1]) ? keyOf(raw[arrowAt + 1]) : String(raw[arrowAt + 1]);
rows.push({ inputs, output });
}
if (rows.length === 0) {
throw new RmlError(
'E061',
`(truth-table ${tableOp} ...) requires at least one row`,
);
}
if (!(foundation.truthTables instanceof Map)) foundation.truthTables = new Map();
foundation.truthTables.set(tableOp, rows);
break;
}
case 'description':
foundation.description = rest.map(t => Array.isArray(t) ? keyOf(t) : String(t)).join(' ');
break;
case 'experimental':
// `(experimental)` flags the foundation as experimental so the trust
// audit can call it out (issue #97, Phase 9). Data-only.
foundation.experimental = true;
break;
case 'root':
// `(root <symbol>)` records the foundation's root concept (e.g. `∞`
// for mtc-anum). Informational; surfaced on the report.
if (rest.length !== 1) {
throw new RmlError('E061', '(root <symbol>) requires exactly one value');
}
foundation.root = Array.isArray(rest[0]) ? keyOf(rest[0]) : String(rest[0]);
break;
case 'abit': {
// `(abit <symbol> <meaning...>)` records one atomic bit of the
// foundation's alphabet. The mtc-anum profile lists four abits:
// `[`, `]`, `1`, `0`. Informational; surfaced on the report.
if (rest.length < 1) {
throw new RmlError('E061', '(abit <symbol> <meaning>) requires a symbol');
}
const symbol = Array.isArray(rest[0]) ? keyOf(rest[0]) : String(rest[0]);
const meaning = rest.slice(1).map(t => Array.isArray(t) ? keyOf(t) : String(t)).join(' ');
if (!Array.isArray(foundation.abits)) foundation.abits = [];
foundation.abits.push({ symbol, meaning });
break;
}
default:
break;
}
}
return foundation;
}
// Render the foundation report as a human-readable text block. Used by the
// CLI's `--foundation-report` flag and by the test suite.
function formatFoundationReport(report) {
const lines = [];
lines.push(`Foundation report:`);
lines.push(` active foundation: ${report.activeFoundation}`);
if (report.description) {
lines.push(` description: ${report.description}`);
}
if (report.numericDomain) {
lines.push(` numeric domain: ${report.numericDomain}`);
}
if (report.truthDomain) {
lines.push(` truth domain: ${report.truthDomain}`);
}
const orderedStatuses = [
'host-primitive',
'host-derived',
'external-trusted',
'user-configurable',
'links-encoded',
'links-defined',
'user-overridden',
'planned',
];
const seen = new Set();
for (const status of orderedStatuses) {
const names = report.byStatus[status];
if (Array.isArray(names) && names.length > 0) {
lines.push('');
lines.push(`${status}:`);
for (const name of names) lines.push(` - ${name}`);
seen.add(status);
}
}
for (const [status, names] of Object.entries(report.byStatus)) {
if (seen.has(status)) continue;
if (Array.isArray(names) && names.length > 0) {
lines.push('');
lines.push(`${status}:`);
for (const name of names) lines.push(` - ${name}`);
}
}
if (report.bySemanticStatus && Object.keys(report.bySemanticStatus).length > 0) {
lines.push('');
lines.push('semantic statuses:');
const seenSemantic = new Set();
for (const status of SEMANTIC_STATUS_ORDER) {
const names = report.bySemanticStatus[status];
if (Array.isArray(names) && names.length > 0) {
lines.push(` ${status}: ${names.join(', ')}`);
seenSemantic.add(status);
}
}
for (const [status, names] of Object.entries(report.bySemanticStatus)) {
if (seenSemantic.has(status)) continue;
if (Array.isArray(names) && names.length > 0) {
lines.push(` ${status}: ${names.join(', ')}`);
}
}
}
if (report.foundations && report.foundations.length > 0) {
lines.push('');
lines.push('foundations:');
for (const f of report.foundations) {
const tag = f.experimental === true ? ' [experimental]' : '';
lines.push(` - ${f.name}${tag}${f.description ? ` — ${f.description}` : ''}`);
if (f.numericDomain) lines.push(` numeric domain: ${f.numericDomain}`);
if (f.truthDomain) lines.push(` truth domain: ${f.truthDomain}`);
if (f.root) lines.push(` root: ${f.root}`);
if (Array.isArray(f.abits) && f.abits.length > 0) {
const abitStrs = f.abits.map(a => `${a.symbol}=${a.meaning}`);
lines.push(` abits: ${abitStrs.join(', ')}`);
}
if (f.uses && f.uses.length) lines.push(` uses: ${f.uses.join(', ')}`);
if (f.defines && f.defines.length) {
const defStrs = f.defines.map(d => `${d.construct}=${d.implementation}`);
lines.push(` defines: ${defStrs.join(', ')}`);
}
if (Array.isArray(f.truthTables) && f.truthTables.length > 0) {
const tt = f.truthTables.map(t => `${t.op}(${t.rows.length} rows)`);
lines.push(` truth tables: ${tt.join(', ')}`);
}
}
}
if (Array.isArray(report.activeImplementations) && report.activeImplementations.length > 0) {
lines.push('');
lines.push('active implementations:');
for (const impl of report.activeImplementations) {
const parts = [];
if (impl.status) parts.push(impl.status);
if (impl.semanticStatus) parts.push(`semantic ${impl.semanticStatus}`);
if (impl.implementation) parts.push(`via ${impl.implementation}`);
if (impl.foundation) parts.push(`foundation ${impl.foundation}`);
if (Array.isArray(impl.dependsOn) && impl.dependsOn.length > 0) {
parts.push(`depends on ${impl.dependsOn.join(', ')}`);
}
lines.push(` - ${impl.construct}: ${parts.join('; ')}`);
}
}
if (Array.isArray(report.proofRules) && report.proofRules.length > 0) {
lines.push('');
lines.push('proof rules:');
for (const r of report.proofRules) {
lines.push(` - ${r.name} (${r.premises.length} premises → ${r.conclusion})`);
}
}
if (Array.isArray(report.proofAssumptions) && report.proofAssumptions.length > 0) {
lines.push('');
lines.push('proof assumptions:');
for (const a of report.proofAssumptions) {
lines.push(` - ${a.name} [${a.kind}] : ${a.judgement}`);
}
}
if (Array.isArray(report.proofObjects) && report.proofObjects.length > 0) {
lines.push('');
lines.push('proof objects:');
for (const po of report.proofObjects) {
const refs = Array.isArray(po.premiseRefs) && po.premiseRefs.length > 0
? ` using ${po.premiseRefs.join(', ')}`
: '';
lines.push(` - ${po.name} : applies ${po.rule} (${po.premises.length} premises${refs} → ${po.conclusion})`);
}
}
if (report.strictPureLinks === true) {
lines.push('');
lines.push('pure-links strict mode: on');
if (Array.isArray(report.allowedHostPrimitives) && report.allowedHostPrimitives.length > 0) {
lines.push(` allowed host primitives: ${report.allowedHostPrimitives.join(', ')}`);
}
}
if (report.dependencyGraph && typeof report.dependencyGraph === 'object') {
const names = Object.keys(report.dependencyGraph).sort();
const nonEmpty = names.filter(n => {
const deps = report.dependencyGraph[n];
return Array.isArray(deps) && deps.length > 0;
});
if (nonEmpty.length > 0) {
lines.push('');
lines.push('dependency graph (transitive):');
for (const name of nonEmpty) {
lines.push(` - ${name} → ${report.dependencyGraph[name].join(', ')}`);
}
}
}
return lines.join('\n');
}
// ---------- Proof-object substrate parsers (issue #97, Phase 3) ----------
//
// Data-only self-bootstrap files also use `(rule ...)` forms. Route to the
// proof substrate only when every child clause is a proof premise/conclusion
// and at least one conclusion exists.
function isProofRuleShape(node) {
return Array.isArray(node) &&
node[0] === 'rule' &&
typeof node[1] === 'string' &&
node[1] &&
node.length >= 3 &&
node.slice(2).every(c => Array.isArray(c) && (c[0] === 'premise' || c[0] === 'conclusion')) &&
node.slice(2).some(c => c[0] === 'conclusion');
}
// Parse a `(rule <name> (premise <pat>)... (conclusion <pat>))` declaration.
// Patterns are plain link nodes; leaves whose token starts with `?` are
// metavariables and bind during `(check-proof <name>)` matching. Repeated
// metavariables inside a pattern must structurally match. Any other leaf is
// a literal that must equal the candidate node exactly.
function parseRuleForm(node) {
if (!Array.isArray(node) || node[0] !== 'rule' || node.length < 2) {
throw new RmlError('E064', 'rule form must be `(rule <name> (premise <pat>)... (conclusion <pat>))`');
}
if (typeof node[1] !== 'string' || !node[1]) {
throw new RmlError('E064', 'rule name must be a non-empty identifier');
}
const rule = { name: node[1], premises: [], conclusion: null };
for (const child of node.slice(2)) {
if (!Array.isArray(child) || child.length < 1 || typeof child[0] !== 'string') {
throw new RmlError('E064', `rule ${rule.name}: clauses must be lists led by a keyword`);
}
const key = child[0];
if (key === 'premise') {
if (child.length !== 2) {
throw new RmlError('E064', `rule ${rule.name}: (premise <pat>) requires exactly one pattern`);
}
rule.premises.push(child[1]);
} else if (key === 'conclusion') {
if (child.length !== 2) {
throw new RmlError('E064', `rule ${rule.name}: (conclusion <pat>) requires exactly one pattern`);
}
if (rule.conclusion !== null) {
throw new RmlError('E064', `rule ${rule.name}: only one (conclusion ...) clause is allowed`);
}
rule.conclusion = child[1];
} else {
throw new RmlError('E064', `rule ${rule.name}: unknown clause keyword ${key}`);
}
}
if (rule.conclusion === null) {
throw new RmlError('E064', `rule ${rule.name}: at least one (conclusion <pat>) clause is required`);
}
return rule;
}
// Parse `(assumption <name> (judgement <judgement>))` and
// `(axiom <name> (judgement <judgement>))` proof leaves. These are explicit
// proof dependencies: a proof object may cite them with `(premise-by <name>)`
// or `(uses <name>)`, making assumptions visible instead of letting arbitrary
// premises appear inside a proof object without justification.
function parseProofAssumptionForm(node) {
if (!Array.isArray(node) || (node[0] !== 'assumption' && node[0] !== 'axiom') || node.length < 2) {
throw new RmlError('E064', 'proof assumption form must be `(assumption <name> (judgement <j>))` or `(axiom <name> (judgement <j>))`');
}
const kind = node[0];
if (typeof node[1] !== 'string' || !node[1]) {
throw new RmlError('E064', `${kind} name must be a non-empty identifier`);
}
const assumption = { name: node[1], kind, judgement: null };
for (const child of node.slice(2)) {
if (!Array.isArray(child) || child.length < 1 || typeof child[0] !== 'string') {
throw new RmlError('E064', `${kind} ${assumption.name}: clauses must be lists led by a keyword`);
}
if (child[0] !== 'judgement') {
throw new RmlError('E064', `${kind} ${assumption.name}: unknown clause keyword ${child[0]}`);
}
if (child.length !== 2) {
throw new RmlError('E064', `${kind} ${assumption.name}: (judgement <j>) requires one argument`);
}
if (assumption.judgement !== null) {
throw new RmlError('E064', `${kind} ${assumption.name}: only one (judgement ...) clause is allowed`);
}
assumption.judgement = child[1];
}
if (assumption.judgement === null) {
throw new RmlError('E064', `${kind} ${assumption.name}: (judgement <j>) clause is required`);
}
return assumption;
}
// Parse a `(proof-object <name> (applies <rule>) (premise <judgement>)...
// (premise-by <dependency>)... (conclusion <judgement>))` declaration into a
// descriptor stored on the env.
function parseProofObjectForm(node) {
if (!Array.isArray(node) || node[0] !== 'proof-object' || node.length < 2) {
throw new RmlError('E064', 'proof-object form must be `(proof-object <name> (applies <rule>) ...)`');
}
if (typeof node[1] !== 'string' || !node[1]) {
throw new RmlError('E064', 'proof-object name must be a non-empty identifier');
}
const po = { name: node[1], rule: null, premises: [], premiseRefs: [], conclusion: null };
for (const child of node.slice(2)) {
if (!Array.isArray(child) || child.length < 1 || typeof child[0] !== 'string') {
throw new RmlError('E064', `proof-object ${po.name}: clauses must be lists led by a keyword`);
}
const key = child[0];
if (key === 'applies') {
if (child.length !== 2 || typeof child[1] !== 'string') {
throw new RmlError('E064', `proof-object ${po.name}: (applies <rule>) requires a rule name`);
}
po.rule = child[1];
} else if (key === 'premise') {
if (child.length !== 2) {
throw new RmlError('E064', `proof-object ${po.name}: (premise <judgement>) requires one argument`);
}
po.premises.push(child[1]);
} else if (key === 'premise-by') {
if (child.length !== 2 || typeof child[1] !== 'string' || !child[1]) {
throw new RmlError('E064', `proof-object ${po.name}: (premise-by <name>) requires a dependency name`);
}
po.premiseRefs.push(child[1]);
} else if (key === 'uses') {
if (child.length < 2) {
throw new RmlError('E064', `proof-object ${po.name}: (uses <name>...) requires at least one dependency name`);
}
for (const ref of child.slice(1)) {
if (typeof ref !== 'string' || !ref) {
throw new RmlError('E064', `proof-object ${po.name}: (uses ...) dependencies must be names`);
}
po.premiseRefs.push(ref);
}
} else if (key === 'conclusion') {
if (child.length !== 2) {
throw new RmlError('E064', `proof-object ${po.name}: (conclusion <judgement>) requires one argument`);
}
if (po.conclusion !== null) {
throw new RmlError('E064', `proof-object ${po.name}: only one (conclusion ...) clause is allowed`);
}
po.conclusion = child[1];
} else {
throw new RmlError('E064', `proof-object ${po.name}: unknown clause keyword ${key}`);
}
}
if (po.rule === null) {
throw new RmlError('E064', `proof-object ${po.name}: (applies <rule>) clause is required`);
}
if (po.conclusion === null) {
throw new RmlError('E064', `proof-object ${po.name}: (conclusion <judgement>) clause is required`);
}
return po;
}
// Structural matcher: walks `pattern` against `candidate` in parallel. Leaves
// whose token starts with `?` are metavariables and bind into `subs`. Repeated
// metavariables must structurally match. Lists must have equal length and
// match pair-wise. Returns true on success and mutates `subs` in place.
function matchProofPattern(pattern, candidate, subs) {
if (typeof pattern === 'string') {
if (pattern.startsWith('?')) {
if (Object.prototype.hasOwnProperty.call(subs, pattern)) {
return keyOf(subs[pattern]) === keyOf(candidate);
}
subs[pattern] = candidate;
return true;
}
return typeof candidate === 'string' && candidate === pattern;
}
if (!Array.isArray(pattern) || !Array.isArray(candidate)) return false;
if (pattern.length !== candidate.length) return false;
for (let i = 0; i < pattern.length; i++) {
if (!matchProofPattern(pattern[i], candidate[i], subs)) return false;
}
return true;
}
function _resolveProofDependency(env, ref, stack) {
const assumption = env.getProofAssumption(ref);
if (assumption) {
return { ok: true, judgement: assumption.judgement, kind: assumption.kind };
}
const po = env.getProofObject(ref);
if (!po) {
return { ok: false, error: `unknown proof dependency ${ref}` };
}
const verdict = checkProofObject(env, ref, stack);
if (!verdict.ok) return verdict;
return { ok: true, judgement: po.conclusion, kind: 'proof-object' };
}
// Validate a `(proof-object <name>)` against its declared rule and recursively
// verify every `(premise-by ...)` / `(uses ...)` dependency. Returns
// `{ ok: true }` on success or `{ ok: false, error: '...' }` on failure so the
// caller can decide whether to emit E064 or surface the result differently.
function checkProofObject(env, name, stack = []) {
if (stack.includes(name)) {
return { ok: false, error: `cyclic proof dependency: ${stack.concat([name]).join(' -> ')}` };
}
const po = env.getProofObject(name);
if (!po) return { ok: false, error: `unknown proof-object ${name}` };
const rule = env.getProofRule(po.rule);
if (!rule) return { ok: false, error: `proof-object ${name} references unknown rule ${po.rule}` };
const refs = Array.isArray(po.premiseRefs) ? po.premiseRefs : [];
let effectivePremises = po.premises.slice();
const dependencyStack = stack.concat([name]);
if (refs.length > 0) {
effectivePremises = [];
for (let i = 0; i < refs.length; i++) {
const resolved = _resolveProofDependency(env, refs[i], dependencyStack);
if (!resolved.ok) return resolved;
effectivePremises.push(resolved.judgement);
if (po.premises.length > 0 && po.premises[i] !== undefined && !isStructurallySame(po.premises[i], resolved.judgement)) {
return {
ok: false,
error: `proof-object ${name}: premise ${i + 1} does not match referenced judgement ${refs[i]}`,
};
}
}
if (po.premises.length > 0 && po.premises.length !== refs.length) {
return {
ok: false,
error: `proof-object ${name}: has ${po.premises.length} explicit premise(s) but ${refs.length} proof dependency reference(s)`,
};
}
} else if (po.premises.length > 0) {
return {
ok: false,
error: `proof-object ${name}: premise 1 is unjustified; use (premise-by <name>) or declare an assumption/axiom`,
};
}
if (effectivePremises.length !== rule.premises.length) {
return {
ok: false,
error: `proof-object ${name}: expected ${rule.premises.length} premise(s) for rule ${po.rule}, got ${effectivePremises.length}`,
};
}
const subs = {};
for (let i = 0; i < rule.premises.length; i++) {
if (!matchProofPattern(rule.premises[i], effectivePremises[i], subs)) {
return {
ok: false,
error: `proof-object ${name}: premise ${i + 1} does not match rule ${po.rule}`,
};
}
}
if (!matchProofPattern(rule.conclusion, po.conclusion, subs)) {
return { ok: false, error: `proof-object ${name}: conclusion does not match rule ${po.rule}` };
}
return { ok: true, substitution: subs, dependencies: refs.slice() };
}
// ---------- Links-evaluated Peano fragment (issue #97, Phase 14) ----------
//
// `(eval-nat <term>)` normalizes a closed Peano term by consulting the
// active links-level computation rules. The semantic result is the Peano
// normal form; the host number returned to the legacy result stream is only
// a renderer for that normal form. Custom `(rule nat-add-zero ...)` /
// `(rule nat-add-succ ...)` declarations therefore change evaluation, and a
// scoped foundation that omits a needed rule makes evaluation fail.
const DEFAULT_EVAL_NAT_RULES = new Map([
['nat-add-zero', {
name: 'nat-add-zero',
premises: [['?n', 'has-type', 'Nat']],
conclusion: [['add', 'zero', '?n'], 'nat-equals', '?n'],
}],
['nat-add-succ', {
name: 'nat-add-succ',
premises: [[['add', '?m', '?n'], 'nat-equals', '?k']],
conclusion: [['add', ['succ', '?m'], '?n'], 'nat-equals', ['succ', '?k']],
}],
['nat-mul-zero', {
name: 'nat-mul-zero',
premises: [['?n', 'has-type', 'Nat']],
conclusion: [['mul', 'zero', '?n'], 'nat-equals', 'zero'],
}],
['nat-mul-succ', {
name: 'nat-mul-succ',
premises: [
[['mul', '?m', '?n'], 'nat-equals', '?k'],
[['add', '?n', '?k'], 'nat-equals', '?s'],
],
conclusion: [['mul', ['succ', '?m'], '?n'], 'nat-equals', '?s'],
}],
]);
function cloneProofTerm(term) {
return Array.isArray(term) ? term.map(cloneProofTerm) : term;
}
function instantiateProofPattern(pattern, subs) {
if (typeof pattern === 'string' && pattern.startsWith('?')) {
return Object.prototype.hasOwnProperty.call(subs, pattern)
? cloneProofTerm(subs[pattern])
: pattern;
}
if (Array.isArray(pattern)) return pattern.map(p => instantiateProofPattern(p, subs));
return pattern;
}
function evalNatFoundationUses(env, foundationName, ruleName, seen = new Set()) {
if (seen.has(foundationName)) return false;
seen.add(foundationName);
const foundation = env && env.getFoundation(foundationName);
if (!foundation) return false;
if (Array.isArray(foundation.uses) && foundation.uses.includes(ruleName)) return true;
return foundation.extends ? evalNatFoundationUses(env, foundation.extends, ruleName, seen) : false;
}
function evalNatActiveFoundationUses(env, name) {
const active = env && env.activeFoundation ? env.activeFoundation : 'default-rml';
if (active === 'default-rml') return true;
return evalNatFoundationUses(env, active, name);
}
function evalNatRule(env, name) {
if (!evalNatActiveFoundationUses(env, name)) {
throw new RmlError('E067', `eval-nat requires ${name}, but it is not available in active foundation ${env.activeFoundation || 'default-rml'}`);
}
return (env && env.getProofRule(name)) || DEFAULT_EVAL_NAT_RULES.get(name) || null;
}
function evalNatEqualityConclusion(rule, ruleName) {
const conclusion = rule && rule.conclusion;
if (!Array.isArray(conclusion) || conclusion.length !== 3 || conclusion[1] !== 'nat-equals') {
throw new RmlError('E067', `eval-nat rule ${ruleName} must conclude (<term> nat-equals <term>)`);
}
return { left: conclusion[0], right: conclusion[2] };
}
function processEvalNatPremises(env, rule, subs, normalize, depth) {
for (const premise of rule.premises || []) {
if (Array.isArray(premise) && premise.length === 3 && premise[1] === 'nat-equals') {
const premiseInput = instantiateProofPattern(premise[0], subs);
const premiseNormal = normalize(premiseInput, depth + 1);
if (!matchProofPattern(premise[2], premiseNormal, subs)) {
throw new RmlError(
'E067',
`eval-nat rule ${rule.name} premise ${keyOf(premise)} did not match normal form ${keyOf(premiseNormal)}`,
);
}
continue;
}
if (Array.isArray(premise) && premise.length === 3 && premise[1] === 'has-type' && premise[2] === 'Nat') {
continue;
}
throw new RmlError('E067', `eval-nat rule ${rule.name} has unsupported premise ${keyOf(premise)}`);
}
}
function applyEvalNatRule(env, ruleName, term, steps, normalize, depth) {
const rule = evalNatRule(env, ruleName);
if (!rule) throw new RmlError('E067', `eval-nat requires ${ruleName}, but no links-level rule is registered`);
const { left, right } = evalNatEqualityConclusion(rule, ruleName);
const subs = {};
if (!matchProofPattern(left, term, subs)) {
throw new RmlError('E067', `eval-nat rule ${ruleName} does not apply to ${keyOf(term)}`);
}
steps.push(rule.name);
processEvalNatPremises(env, rule, subs, normalize, depth);
return normalize(instantiateProofPattern(right, subs), depth + 1);
}
function peanoNormalFormToHostNumber(term) {
if (term === 'zero') return 0;
if (Array.isArray(term) && term.length === 2 && term[0] === 'succ') {
return 1 + peanoNormalFormToHostNumber(term[1]);
}
throw new RmlError('E067', `eval-nat produced a non-Peano normal form: ${formatTraceValue(term)}`);
}
function evalNatTerm(env, node) {
const steps = [];
const normalize = (t, depth = 0) => {
if (depth > 10000) {
throw new RmlError('E067', 'eval-nat exceeded its structural rewrite limit');
}
if (t === 'zero') return 'zero';
if (Array.isArray(t)) {
if (t.length === 2 && t[0] === 'succ') {
return ['succ', normalize(t[1], depth + 1)];
}
if (t.length === 3 && t[0] === 'add') {
const left = normalize(t[1], depth + 1);
const current = ['add', left, cloneProofTerm(t[2])];
if (left === 'zero') {
return applyEvalNatRule(env, 'nat-add-zero', current, steps, normalize, depth + 1);
}
if (Array.isArray(left) && left.length === 2 && left[0] === 'succ') {
return applyEvalNatRule(env, 'nat-add-succ', current, steps, normalize, depth + 1);
}
}
if (t.length === 3 && t[0] === 'mul') {
const left = normalize(t[1], depth + 1);
const current = ['mul', left, cloneProofTerm(t[2])];
if (left === 'zero') {
return applyEvalNatRule(env, 'nat-mul-zero', current, steps, normalize, depth + 1);
}
if (Array.isArray(left) && left.length === 2 && left[0] === 'succ') {
return applyEvalNatRule(env, 'nat-mul-succ', current, steps, normalize, depth + 1);
}
}
}
throw new RmlError(
'E067',
`eval-nat: not a closed Peano term: ${formatTraceValue(t)}`,
);
};
const normalForm = normalize(node);
const value = peanoNormalFormToHostNumber(normalForm);
return { value, normalForm, steps };
}
// ---------- Pure-links strict mode (issue #97, Phase 6) ----------
//
// `(strict-foundation pure-links)` turns the strict mode on; everything inside
// a queried form is then audited against the root-construct registry. Any
// operator leaf whose status is `host-primitive` or `host-derived` produces
// an E065 diagnostic — unless the construct name has been explicitly allow-
// listed with `(allow-host-primitive <name>...)`. The mode is sticky for the
// remainder of the file, which mirrors the way `(strict-carrier)` works
// inside `(with-foundation ...)`.
function parseStrictFoundationForm(node) {
if (!Array.isArray(node) || node[0] !== 'strict-foundation') {
throw new RmlError('E065', '(strict-foundation <profile>) is required');
}
if (node.length !== 2 || typeof node[1] !== 'string' || !node[1]) {
throw new RmlError('E065', '(strict-foundation <profile>) requires a single profile name');
}
if (node[1] !== 'pure-links') {
throw new RmlError('E065', `unknown strict-foundation profile: ${node[1]} (expected pure-links)`);
}
return { profile: node[1] };
}
function parseAllowHostPrimitiveForm(node) {
if (!Array.isArray(node) || node[0] !== 'allow-host-primitive') {
throw new RmlError('E065', '(allow-host-primitive <name>...) is required');
}
if (node.length < 2) {
throw new RmlError('E065', '(allow-host-primitive <name>...) requires at least one construct name');
}
const names = [];
for (const tok of node.slice(1)) {
if (typeof tok !== 'string' || !tok) {
throw new RmlError('E065', '(allow-host-primitive ...) names must be non-empty identifiers');
}
names.push(tok);
}
return { names };
}
// Operator leaves that the strict scanner is *not* meant to audit. These are
// either internal proof markers (`by`, `because`, ...) or surface keywords
// that have nothing to do with the host-primitive substrate (`with`, `proof`,
// query head `?`). Adding them here keeps the scanner from emitting false
// positives for forms that are not really "operators" in the registry sense.
const PURE_LINKS_SCANNER_IGNORED = new Set([
'?', 'with', 'proof',
'by', 'because',
'let', 'in', 'where',
':', '::',
'has', 'probability',
'is', 'a', 'an',
'sequence', 'normalizes-to',
'applies', 'premise', 'premise-by', 'conclusion', 'uses', 'judgement',
'assumption', 'axiom', 'rule', 'proof-object', 'check-proof', 'proof-report',
'foundation', 'with-foundation', 'foundation-report', 'foundation-report?',
'root-construct', 'strict-carrier', 'truth-table',
'strict-foundation', 'allow-host-primitive',
]);
function _isStrictlyOffendingStatus(status) {
return status === 'host-primitive' || status === 'host-derived';
}
function _strictDependencyOffenders(env, name, path = []) {
const allow = env.allowedHostPrimitives instanceof Set ? env.allowedHostPrimitives : new Set();
if (allow.has(name)) return [];
if (path.includes(name)) return [];
const currentPath = path.concat([name]);
const active = env.activeImplementations instanceof Map ? env.activeImplementations.get(name) : null;
const rc = env.getRootConstruct(name);
const status = active && active.status ? active.status : (rc && rc.status);
const deps = active && Array.isArray(active.dependsOn)
? active.dependsOn
: (rc && Array.isArray(rc.dependsOn) ? rc.dependsOn : []);
if (active && active.status === 'links-defined' && deps.length === 0) {
return [];
}
if (_isStrictlyOffendingStatus(status) && deps.length === 0) {
return [`${currentPath.join(' -> ')} -> ${status}`];
}
const offenders = [];
for (const dep of deps) {
if (allow.has(dep)) continue;
offenders.push(..._strictDependencyOffenders(env, dep, currentPath));
}
if (_isStrictlyOffendingStatus(status) && offenders.length === 0) {
offenders.push(`${currentPath.join(' -> ')} -> ${status}`);
}
return offenders;
}
// Walk the queried AST, look up every operator-head leaf in the root-construct
// registry and active-foundation implementation map, and collect transitive
// dependency paths that end at an unallowed host primitive/derived construct.
// Returns a sorted, deduplicated array of paths such as
// `and -> avg -> host-primitive`.
function scanPureLinksOffenders(node, env) {
if (env.strictPureLinks !== true) return [];
const offenders = new Set();
const allow = env.allowedHostPrimitives instanceof Set ? env.allowedHostPrimitives : new Set();
const check = (name) => {
if (PURE_LINKS_SCANNER_IGNORED.has(name) || allow.has(name)) return;
for (const offender of _strictDependencyOffenders(env, name)) offenders.add(offender);
};
const visit = (n) => {
if (Array.isArray(n)) {
const head = n[0];
if (typeof head === 'string') check(head);
// Inspect every list child too — the head may live deeper inside a
// sublist (e.g. an infix `(L op R)` shape).
for (let i = 0; i < n.length; i++) visit(n[i]);
// For infix `(L op R)` the operator is the middle element; check it
// explicitly so we don't miss it.
if (n.length === 3 && typeof n[1] === 'string') {
check(n[1]);
}
return;
}
if (typeof n === 'string') {
check(n);
}
};
visit(node);
return [...offenders].sort();
}
// ---------- HOAS desugarer ----------
// Higher-order abstract syntax (issue #51, D7): the surface keyword `forall`
// is sugar for `Pi`. Both binders share identical structure
// `(<binder> (Type x) body)`, so the desugarer walks the AST and rewrites the
// head leaf in place. Object-language binders are encoded as host-language
// `lambda` and `Pi`/`forall` so substitution and capture-avoidance reuse the
// kernel primitives — no separate object-level binder representation is
// required.
function desugarHoas(node) {
if (!Array.isArray(node)) return node;
const mapped = node.map(desugarHoas);
// Rewrite `(forall (T x) body)` → `(Pi (T x) body)` only when the binder is
// a pair (HOAS synonym). A bare uppercase name, e.g. `(forall A body)`, is
// prenex-polymorphism sugar and must reach `synth`/`_isForallNode` intact.
if (mapped.length === 3 && mapped[0] === 'forall' && Array.isArray(mapped[1])) {
return ['Pi', mapped[1], mapped[2]];
}
return mapped;
}
// ---------- Binding parser ----------
// Parse a binding form in three supported syntaxes:
// 1. Colon form: (x: A) as ['x:', A] — standard LiNo link definition syntax
// 2. Prefix type form: (A x) as ['A', 'x'] — type-first notation for lambda/Pi bindings
// e.g. (Natural x), used in (lambda (Natural x) body)
// 3. Prefix complex-type form: ((Pi (A x) B) f) — type-first with a list type expression,
// needed for higher-order parameters such as polymorphic apply / compose where a
// function parameter is itself function-typed.
// Returns { paramName, paramType } or null if not a valid binding.
function parseBinding(binding) {
if (!Array.isArray(binding)) return null;
// ['x:', A] — two elements where first ends with colon (standard LiNo link definition)
if (binding.length === 2 && typeof binding[0] === 'string' && binding[0].endsWith(':')) {
return { paramName: binding[0].slice(0, -1), paramType: binding[1] };
}
// ['A', 'x'] — prefix type form: type name first, then variable name
// Type names must start with uppercase (convention from Lean/Rocq)
if (binding.length === 2 && typeof binding[0] === 'string' && typeof binding[1] === 'string'
&& /^[A-Z]/.test(binding[0]) && !binding[1].endsWith(':')) {
return { paramName: binding[1], paramType: binding[0] };
}
// [<type-expr>, 'x'] — prefix complex-type form: the type is a list expression
// such as (Pi (A x) B), (Type 0), or (forall A T). The variable name must be a
// plain identifier (no trailing colon, must not look like a type name itself).
if (binding.length === 2 && Array.isArray(binding[0]) && typeof binding[1] === 'string'
&& !binding[1].endsWith(':')) {
return { paramName: binding[1], paramType: binding[0] };
}
return null;
}
// ---------- Multi-binding parser ----------
// Parse comma-separated bindings: (Natural x, Natural y) → [{paramName:'x', paramType:'Natural'}, ...]
// Tokens arrive as ['Natural', 'x,', 'Natural', 'y'] or ['Natural', 'x'] (single binding)
function parseBindings(binding) {
if (!Array.isArray(binding)) return null;
// Try single binding first
const single = parseBinding(binding);
if (single) return [single];
// Try comma-separated: flatten tokens, split by commas
const tokens = [];
for (const tok of binding) {
if (typeof tok === 'string') {
// Split tokens that end with comma: 'x,' → 'x', separator
if (tok.endsWith(',')) {
tokens.push(tok.slice(0, -1));
tokens.push(',');
} else {
tokens.push(tok);
}
} else {
tokens.push(tok);
}
}
// Group into pairs separated by commas
const bindings = [];
let i = 0;
while (i < tokens.length) {
if (tokens[i] === ',') { i++; continue; }
if (i + 1 < tokens.length && typeof tokens[i] === 'string' && typeof tokens[i+1] === 'string' && tokens[i+1] !== ',') {
const pair = parseBinding([tokens[i], tokens[i+1]]);
if (pair) {
bindings.push(pair);
i += 2;
continue;
}
}
return null; // invalid binding format
}
return bindings.length > 0 ? bindings : null;
}
// ---------- Substitution (for beta-reduction) ----------
// Capture-avoiding substitution for kernel terms. Both expr and replacement
// can be strings or arrays (AST nodes). The public primitive is `subst`;
// `substitute` remains as the backwards-compatible helper name.
const NON_VARIABLE_TOKENS = new Set([
'lambda', 'Pi', 'fresh', 'in', 'subst', 'apply', 'type', 'of',
'has', 'probability', 'with', 'proof', 'range', 'valence',
'namespace', 'import', 'as', 'is', '?', 'mode', 'relation', 'total', 'coverage', 'world',
'inductive', 'coinductive', 'constructor',
'define', 'case', 'measure', 'lex', 'terminating',
'whnf', 'nf', 'normal-form',
'template',
'+', '-', '*', '/', '<', '<=', '=', '!=', 'and', 'or', 'not', 'both', 'neither', 'nor',
]);
function cloneTerm(node) {
return Array.isArray(node) ? node.map(cloneTerm) : node;
}
function tokenBaseName(token) {
if (typeof token !== 'string') return null;
return token.replace(/[:,]+$/g, '');
}
function isVariableToken(token) {
if (typeof token !== 'string') return false;
const base = tokenBaseName(token);
return !!base && base === token && !isNum(base) && !NON_VARIABLE_TOKENS.has(base);
}
function bindingParamNames(binding) {
const parsed = parseBindings(binding);
return parsed ? parsed.map(b => b.paramName) : [];
}
function binderInfo(expr) {
if (!Array.isArray(expr)) return null;
if (expr.length === 3 && (expr[0] === 'lambda' || expr[0] === 'Pi')) {
const params = bindingParamNames(expr[1]);
if (params.length > 0) {
return { kind: expr[0], params, bodyIndex: 2, bindingIndex: 1 };
}
}
if (expr.length === 4 && expr[0] === 'fresh' && expr[2] === 'in' && typeof expr[1] === 'string') {
return { kind: 'fresh', params: [expr[1]], bodyIndex: 3, bindingIndex: 1 };
}
return null;
}
function freeVariables(expr, bound = new Set()) {
const out = new Set();
function addAll(set) {
for (const v of set) out.add(v);
}
if (typeof expr === 'string') {
if (isVariableToken(expr) && !bound.has(expr)) out.add(expr);
return out;
}
if (!Array.isArray(expr)) return out;
const binder = binderInfo(expr);
if (binder) {
const nested = new Set(bound);
for (const param of binder.params) nested.add(param);
if (binder.kind !== 'fresh') {
const paramSet = new Set(binder.params);
for (const child of expr[binder.bindingIndex]) {
if (typeof child === 'string' && paramSet.has(tokenBaseName(child))) continue;
addAll(freeVariables(child, bound));
}
}
addAll(freeVariables(expr[binder.bodyIndex], nested));
return out;
}
for (const child of expr) addAll(freeVariables(child, bound));
return out;
}
function containsFree(expr, name) {
return freeVariables(expr).has(name);
}
function envCanEvaluateName(env, name) {
if (
env.symbolProb.has(name) ||
env.terms.has(name) ||
env.types.has(name) ||
env.lambdas.has(name) ||
env.ops.has(name) ||
env.templates.has(name)
) {
return true;
}
const resolved = env._resolveQualified(name);
return resolved !== name && (
env.symbolProb.has(resolved) ||
env.terms.has(resolved) ||
env.types.has(resolved) ||
env.lambdas.has(resolved) ||
env.ops.has(resolved) ||
env.templates.has(resolved)
);
}
function hasUnresolvedFreeVariables(expr, env) {
for (const name of freeVariables(expr)) {
if (!envCanEvaluateName(env, name)) return true;
}
return false;
}
function collectNames(expr, out = new Set()) {
if (typeof expr === 'string') {
const base = tokenBaseName(expr);
if (base && !isNum(base) && !NON_VARIABLE_TOKENS.has(base)) out.add(base);
return out;
}
if (Array.isArray(expr)) {
for (const child of expr) collectNames(child, out);
}
return out;
}
function freshName(base, avoid) {
let i = 1;
let candidate = `${base}_${i}`;
while (avoid.has(candidate)) {
i++;
candidate = `${base}_${i}`;
}
return candidate;
}
function renameBindingParam(binding, oldName, newName) {
if (!Array.isArray(binding)) return binding;
return binding.map(child => {
if (typeof child !== 'string') return cloneTerm(child);
if (child === oldName) return newName;
if (child === `${oldName},`) return `${newName},`;
if (child === `${oldName}:`) return `${newName}:`;
return child;
});
}
function renameBoundOccurrences(expr, oldName, newName) {
if (typeof expr === 'string') return expr === oldName ? newName : expr;
if (!Array.isArray(expr)) return expr;
const binder = binderInfo(expr);
if (binder && binder.params.includes(oldName)) {
return cloneTerm(expr);
}
return expr.map(child => renameBoundOccurrences(child, oldName, newName));
}
function renameBinder(expr, binder, oldName, newName) {
const out = expr.map(cloneTerm);
if (binder.kind === 'fresh') {
out[binder.bindingIndex] = newName;
} else {
out[binder.bindingIndex] = renameBindingParam(out[binder.bindingIndex], oldName, newName);
}
out[binder.bodyIndex] = renameBoundOccurrences(out[binder.bodyIndex], oldName, newName);
return out;
}
function subst(expr, name, replacement) {
if (typeof expr === 'string') {
return expr === name ? replacement : expr;
}
if (Array.isArray(expr)) {
const binder = binderInfo(expr);
if (binder) {
if (binder.params.includes(name)) return expr; // shadowed
let current = expr.map(cloneTerm);
const replacementFree = freeVariables(replacement);
if (containsFree(expr[binder.bodyIndex], name)) {
const avoid = collectNames(current);
collectNames(replacement, avoid);
avoid.add(name);
for (const param of binder.params) {
if (replacementFree.has(param)) {
const next = freshName(param, avoid);
avoid.add(next);
current = renameBinder(current, binderInfo(current), param, next);
}
}
}
return current.map(child => subst(child, name, replacement));
}
return expr.map(child => subst(child, name, replacement));
}
return expr;
}
function substitute(expr, name, replacement) {
return subst(expr, name, replacement);
}
// ---------- Template expansion (issue #59) ----------
// `(template (<name> <param>...) <body>)` registers a reusable link shape.
// Uses like `(<name> arg...)` are expanded before evaluation, recursively, so
// template bodies can call other templates. Placeholder substitution is
// simultaneous and capture-avoiding: first replace free placeholders with
// fresh sentinels, then use the existing hygienic `subst` primitive to insert
// arguments without letting template-introduced binders capture them.
function _templateKeyFor(env, name) {
if (env.templates.has(name)) return name;
const resolved = env._resolveQualified(name);
if (resolved !== name && env.templates.has(resolved)) return resolved;
return null;
}
function _validateTemplatePattern(pattern) {
if (!Array.isArray(pattern) || pattern.length < 1 || typeof pattern[0] !== 'string') {
throw new RmlError('E040', 'Template declaration must be `(template (<name> <param>...) <body>)`');
}
const name = pattern[0];
if (!isVariableToken(name)) {
throw new RmlError('E040', `Template name must be a bare identifier (got "${name}")`);
}
const params = pattern.slice(1);
const seen = new Set();
for (const param of params) {
if (typeof param !== 'string' || !isVariableToken(param)) {
throw new RmlError('E040', `Template parameter must be a bare identifier (got "${keyOf(param)}")`);
}
if (seen.has(param)) {
throw new RmlError('E040', `Template parameter "${param}" is declared more than once`);
}
seen.add(param);
}
return { name, params };
}
function registerTemplateForm(form, env) {
if (!Array.isArray(form) || form.length !== 3 || form[0] !== 'template') {
throw new RmlError('E040', 'Template declaration must be `(template (<name> <param>...) <body>)`');
}
const { name, params } = _validateTemplatePattern(form[1]);
const storeName = env.qualifyName(name);
_maybeWarnShadow(env, storeName);
env.templates.set(storeName, {
name: storeName,
params,
body: cloneTerm(form[2]),
});
return storeName;
}
function substituteTemplatePlaceholders(body, params, args) {
let current = cloneTerm(body);
const avoid = collectNames(current);
for (const arg of args) collectNames(arg, avoid);
const sentinels = params.map(param => {
const next = freshName(`__template_${param}`, avoid);
avoid.add(next);
return next;
});
for (let i = 0; i < params.length; i++) {
current = subst(current, params[i], sentinels[i]);
}
for (let i = 0; i < sentinels.length; i++) {
current = subst(current, sentinels[i], args[i]);
}
return current;
}
function expandTemplates(node, env, stack = []) {
if (!Array.isArray(node)) return cloneTerm(node);
if (node.length === 0) return [];
const head = node[0];
if (typeof head === 'string') {
const key = _templateKeyFor(env, head);
if (key) {
const decl = env.templates.get(key);
const argCount = node.length - 1;
if (argCount !== decl.params.length) {
throw new RmlError(
'E040',
`Template "${head}" expects ${decl.params.length} argument${decl.params.length === 1 ? '' : 's'}, got ${argCount}`,
);
}
const cycleStart = stack.indexOf(key);
if (cycleStart !== -1) {
const cycle = stack.slice(cycleStart).concat([key]).join(' -> ');
throw new RmlError('E040', `Template expansion cycle detected: ${cycle}`);
}
const expandedArgs = node.slice(1).map(arg => expandTemplates(arg, env, stack));
stack.push(key);
try {
const instantiated = substituteTemplatePlaceholders(decl.body, decl.params, expandedArgs);
return expandTemplates(instantiated, env, stack);
} finally {
stack.pop();
}
}
}
return node.map(child => expandTemplates(child, env, stack));
}
// ---------- Eval ----------
// Format a numeric value for trace output — strips trailing zeros so
// `1.000000` reads as `1` and `0.5` stays `0.5`. Used both for assignment
// values and for reduction results to keep trace lines reproducible.
function formatTraceValue(v) {
if (typeof v !== 'number') return String(v);
if (!Number.isFinite(v)) return String(v);
const rounded = +v.toFixed(6);
const s = String(rounded);
return s;
}
// ---------- Proof derivations (issue #35) ----------
// A derivation is a Node tree of the form `(by <rule> <subderivation>...)`,
// expressed as a JS array so it round-trips through the existing `keyOf`
// (print) / `parseOne(tokenizeOne(...))` (parse) helpers without needing a
// new format. `buildProof` is invoked by `evaluate()` (and the inline
// `(? expr with proof)` query form) once a top-level form has been
// evaluated; it walks the same structural cases as `evalNode` to attach the
// rule that fired at each step.
//
// The walker is intentionally read-only — it never mutates the env beyond
// the lookups that `evalNode` would have performed during evaluation, so
// enabling proofs cannot change query results. Sub-derivations recurse
// through `buildProof` so every sub-expression carries its own witness
// rather than collapsing into the literal value.
function _wrap(rule, ...subs) {
return ['by', rule, ...subs];
}
// Pretty-print a numeric value the same way `formatTraceValue` does so
// proof-result links such as `(eq L R 1)` stay stable across runs.
function _proofValue(v) {
if (typeof v === 'number' && Number.isFinite(v)) {
const s = formatTraceValue(v);
return s;
}
return String(v);
}
// Equality-layer provenance (issue #97). Centralises the precedence used by
// `buildProof()` (and surfaced verbatim through `out.provenance`) so JS and
// Rust agree on which equality layer fires for a given `(L = R)` pair.
// Precedence: assigned > structural > definitional > numeric.
function _containsLambdaOrApply(node) {
if (!Array.isArray(node)) return false;
if (node[0] === 'lambda' || node[0] === 'apply') return true;
for (const child of node) if (_containsLambdaOrApply(child)) return true;
return false;
}
function classifyEqualityRule(L, R, op, env) {
const isInequality = op === '!=';
const kPrefix = keyOf(['=', L, R]);
const kInfix = keyOf([L, '=', R]);
if (env.assign.has(kPrefix) || env.assign.has(kInfix)) {
return isInequality ? 'assigned-inequality' : 'assigned-equality';
}
if (isStructurallySame(L, R)) {
return isInequality ? 'structural-inequality' : 'structural-equality';
}
// Definitional equality: if one side contains a lambda/apply and both
// sides normalize to structurally-identical terms, the equality holds by
// beta-reduction. Wrapped in a try/catch so a pathological normalization
// never breaks classification (worst case we fall through to numeric).
if (_containsLambdaOrApply(L) || _containsLambdaOrApply(R)) {
try {
const Ln = normalizeTerm(L, env);
const Rn = normalizeTerm(R, env);
if (isStructurallySame(Ln, Rn) && !isStructurallySame(L, R)) {
return isInequality ? 'definitional-inequality' : 'definitional-equality';
}
} catch (_) { /* fall through */ }
}
return isInequality ? 'numeric-inequality' : 'numeric-equality';
}
// Strip an optional `with proof` keyword pair, then unwrap a singleton
// container so `(? (a = b))` and `(? ((a = b)))` both yield `(a = b)`.
function _queryBody(queryForm) {
if (!Array.isArray(queryForm) || queryForm[0] !== '?') return null;
const stripped = _stripWithProof(queryForm.slice(1));
let body = stripped.length === 1 ? stripped[0] : stripped;
while (Array.isArray(body) && body.length === 1 && Array.isArray(body[0])) {
body = body[0];
}
return body;
}
// Return the equality-layer rule for a query whose body is a direct
// equality, or null for any other query shape. Composite queries like
// `((a = true) and (b = true))` are intentionally returned as `null`: the
// per-equality rules still appear in the proof witness, but the surface
// provenance describes the query itself.
function equalityProvenanceForQuery(queryForm, env) {
const body = _queryBody(queryForm);
if (!Array.isArray(body)) return null;
if (body.length === 3 && typeof body[1] === 'string' && (body[1] === '=' || body[1] === '!=')) {
return classifyEqualityRule(body[0], body[2], body[1], env);
}
return null;
}
function buildProof(node, env) {
// Literals
if (typeof node === 'string') {
if (isNum(node)) {
return _wrap('literal', node);
}
// Bare symbol: either a declared term/symbol prior or unknown — both are
// axiomatic at this level so we record the symbol as a leaf witness.
return _wrap('symbol', node);
}
if (!Array.isArray(node)) return _wrap('literal', String(node));
// Definitions and operator redefs: (head: ...)
if (typeof node[0] === 'string' && node[0].endsWith(':')) {
return _wrap('definition', node);
}
// Assignment: ((expr) has probability p)
if (node.length === 4 && node[1] === 'has' && node[2] === 'probability' && isNum(node[3])) {
return _wrap('assigned-probability', node[0], node[3]);
}
// Range / valence configuration directives.
if (node.length === 3 && node[0] === 'range' && isNum(node[1]) && isNum(node[2])) {
return _wrap('configuration', 'range', node[1], node[2]);
}
if (node.length === 2 && node[0] === 'valence' && isNum(node[1])) {
return _wrap('configuration', 'valence', node[1]);
}
// Query: (? expr) and the per-query proof form (? expr with proof)
if (node[0] === '?') {
const inner = _stripWithProof(node.slice(1));
const target = inner.length === 1 ? inner[0] : inner;
return _wrap('query', buildProof(target, env));
}
// Infix arithmetic: (A + B), (A - B), (A * B), (A / B)
if (node.length === 3 && typeof node[1] === 'string' && ['+','-','*','/'].includes(node[1])) {
const ruleByOp = { '+': 'sum', '-': 'difference', '*': 'product', '/': 'quotient' };
return _wrap(ruleByOp[node[1]], buildProof(node[0], env), buildProof(node[2], env));
}
// Infix AND/OR/BOTH/NEITHER
if (node.length === 3 && typeof node[1] === 'string' && (node[1]==='and' || node[1]==='or' || node[1]==='both' || node[1]==='neither')) {
return _wrap(node[1], buildProof(node[0], env), buildProof(node[2], env));
}
// Composite both/neither chains: (both A and B [and C ...]), (neither A nor B [nor C ...])
if (node.length >= 4 && typeof node[0] === 'string' && (node[0]==='both' || node[0]==='neither')) {
const sep = node[0]==='both' ? 'and' : 'nor';
let valid = node.length % 2 === 0;
if (valid) {
for (let i = 2; i < node.length; i += 2) {
if (node[i] !== sep) { valid = false; break; }
}
}
if (valid) {
const subs = [];
for (let i = 1; i < node.length; i += 2) subs.push(buildProof(node[i], env));
return _wrap(node[0], ...subs);
}
}
// Infix equality / inequality: (L = R), (L != R)
if (node.length === 3 && typeof node[1] === 'string' && (node[1]==='=' || node[1]==='!=')) {
const L = node[0];
const R = node[2];
const rule = classifyEqualityRule(L, R, node[1], env);
// Sub-derivations of equality preserve the original operands as links
// so the witness reads (by structural-equality (a a)) per the issue.
return _wrap(rule, [L, R]);
}
// ---------- Type system witnesses ----------
if (node.length === 2 && node[0] === 'Type') {
return _wrap('type-universe', node[1]);
}
if (node.length === 1 && node[0] === 'Prop') {
return _wrap('prop');
}
if (node.length === 3 && node[0] === 'Pi') {
return _wrap('pi-formation', node[1], node[2]);
}
if (node.length === 3 && node[0] === 'lambda') {
return _wrap('lambda-formation', node[1], node[2]);
}
if (node.length === 3 && node[0] === 'apply') {
return _wrap('beta-reduction', buildProof(node[1], env), buildProof(node[2], env));
}
if (node.length === 4 && node[0] === 'subst') {
return _wrap('substitution', node[1], node[2], node[3]);
}
if (node.length === 2 && node[0] === 'whnf') {
return _wrap('whnf-reduction', node[1]);
}
if (node.length === 2 && (node[0] === 'nf' || node[0] === 'normal-form')) {
return _wrap('nf-reduction', node[1]);
}
if (node.length === 4 && node[0] === 'fresh' && node[2] === 'in') {
return _wrap('fresh', node[1], node[3]);
}
if (node.length === 3 && node[0] === 'type' && node[1] === 'of') {
return _wrap('type-query', node[2]);
}
if (node.length === 3 && node[1] === 'of') {
return _wrap('type-check', node[0], node[2]);
}
// Prefix operator: (op X Y ...)
const head = node[0];
if (typeof head === 'string' && env.hasOp(head)) {
return _wrap(head, ...node.slice(1).map(arg => buildProof(arg, env)));
}
// Fallback for prefix application of named lambdas / unrecognised heads.
return _wrap('reduce', node);
}
// Strip an optional `with proof` suffix from a query body. Both
// `(? expr with proof)` and `(? (expr) with proof)` are accepted.
function _stripWithProof(parts) {
if (parts.length >= 3 && parts[parts.length - 2] === 'with' && parts[parts.length - 1] === 'proof') {
return parts.slice(0, -2);
}
return parts;
}
// Detect whether a query body explicitly requested a proof via the inline
// `with proof` keyword pair. Used to populate the per-query proof slot even
// when the global `withProofs` option is off.
function _queryRequestsProof(node) {
if (!Array.isArray(node) || node[0] !== '?') return false;
const parts = node.slice(1);
return parts.length >= 3 && parts[parts.length - 2] === 'with' && parts[parts.length - 1] === 'proof';
}
// ---------- Tactic engine (issues #55 and #56) ----------
// Tactics are represented as ordinary links and operate on an explicit proof
// state. The state shape is intentionally small and serialisable:
// { goals: [{ goal: Node, context: Node[] }], proof: Node[] }
// Callers may pass bare goal nodes in `goals`; `runTactics` normalises them
// into goal objects before applying tactics.
const DEFAULT_SIMPLIFY_MAX_STEPS = 100;
const DEFAULT_ATP_TIMEOUT_MS = 5000;
const DEFAULT_SMT_TIMEOUT_MS = 5000;
const ATP_PROVED_STATUSES = new Set([
'Theorem',
'Unsatisfiable',
'ContradictoryAxioms',
]);
const ATP_UNKNOWN_STATUSES = new Set([
'Unknown',
'GaveUp',
]);
const ATP_TIMEOUT_STATUSES = new Set([
'Timeout',
'ResourceOut',
]);
function _normaliseProofGoal(rawGoal, inheritedContext = []) {
if (
rawGoal &&
typeof rawGoal === 'object' &&
!Array.isArray(rawGoal) &&
Object.prototype.hasOwnProperty.call(rawGoal, 'goal')
) {
return {
goal: cloneTerm(rawGoal.goal),
context: Array.isArray(rawGoal.context)
? rawGoal.context.map(cloneTerm)
: inheritedContext.map(cloneTerm),
};
}
return {
goal: cloneTerm(rawGoal),
context: inheritedContext.map(cloneTerm),
};
}
function _normaliseProofState(state = {}) {
const inheritedContext = Array.isArray(state.context)
? state.context.map(cloneTerm)
: [];
const goals = Array.isArray(state.goals)
? state.goals.map(goal => _normaliseProofGoal(goal, inheritedContext))
: [];
const proof = Array.isArray(state.proof) ? state.proof.map(cloneTerm) : [];
return { goals, proof };
}
function _cloneProofState(state) {
return {
goals: state.goals.map(goal => ({
goal: cloneTerm(goal.goal),
context: goal.context.map(cloneTerm),
})),
proof: state.proof.map(cloneTerm),
};
}
function _isTacticNode(value) {
return Array.isArray(value) && value.length > 0 && typeof value[0] === 'string';
}
function _normaliseTacticList(tactics) {
if (typeof tactics === 'string') return parseLinoForms(tactics);
if (tactics === undefined || tactics === null) return [];
if (_isTacticNode(tactics) || typeof tactics === 'string') return [tactics];
return Array.isArray(tactics) ? tactics : [tactics];
}
function _tacticName(tactic) {
if (typeof tactic === 'string') return tactic;
if (Array.isArray(tactic) && typeof tactic[0] === 'string') return tactic[0];
return null;
}
function _tacticArgs(tactic) {
return Array.isArray(tactic) ? tactic.slice(1) : [];
}
function _asEquality(node) {
if (Array.isArray(node) && node.length === 3 && node[1] === '=') {
return { left: node[0], right: node[2] };
}
return null;
}
function _goalKey(goal) {
return goal ? keyOf(goal.goal) : '<none>';
}
function _tacticDiagnostic(tactic, goal, reason) {
return new Diagnostic({
code: 'E039',
message: `Tactic ${keyOf(tactic)} failed: ${reason}; current goal: ${_goalKey(goal)}`,
span: { file: null, line: 1, col: 1, length: 0 },
});
}
function _replaceCurrentGoal(state, replacementGoals, recordTactic) {
return {
goals: [...replacementGoals, ...state.goals.slice(1)],
proof: [...state.proof, cloneTerm(recordTactic)],
};
}
function _goalWithContext(current, goal) {
return {
goal: cloneTerm(goal),
context: current.context.map(cloneTerm),
};
}
function _rewriteError(message) {
return new RmlError('E039', message);
}
function _normaliseSmtSolverArgs(rawArgs) {
if (rawArgs === undefined || rawArgs === null) return [];
if (Array.isArray(rawArgs)) return rawArgs.map(String);
if (typeof rawArgs === 'string') {
const trimmed = rawArgs.trim();
return trimmed ? trimmed.split(/\s+/) : [];
}
throw _rewriteError('SMT solver args must be an array or whitespace-separated string');
}
function _normaliseSmtTimeoutMs(rawTimeout) {
const timeout = rawTimeout === undefined || rawTimeout === null || rawTimeout === ''
? DEFAULT_SMT_TIMEOUT_MS
: Number(String(rawTimeout));
if (!Number.isSafeInteger(timeout) || timeout < 0) {
throw _rewriteError(`SMT timeout must be a non-negative integer in milliseconds (got ${String(rawTimeout)})`);
}
return timeout;
}
function _normaliseSmtOptions(options = {}) {
const solver =
options.smtSolverPath ??
options.smtSolver ??
process.env.RML_SMT_SOLVER ??
null;
const solverArgs =
options.smtSolverArgs ??
options.smtArgs ??
process.env.RML_SMT_ARGS ??
[];
const timeout =
options.smtTimeoutMs ??
process.env.RML_SMT_TIMEOUT_MS ??
DEFAULT_SMT_TIMEOUT_MS;
return {
solver: solver === null || solver === undefined || String(solver).trim() === ''
? null
: String(solver),
args: _normaliseSmtSolverArgs(solverArgs),
timeoutMs: _normaliseSmtTimeoutMs(timeout),
};
}
function _smtSolverProofName(smtOptions) {
if (!smtOptions || !smtOptions.solver) return 'unconfigured';
const base = path.basename(String(smtOptions.solver)) || String(smtOptions.solver);
const safe = base.replace(/\s+/g, '_');
return safe || 'solver';
}
function _smtTrustedNode(smtOptions) {
return ['by', 'smt-trusted', _smtSolverProofName(smtOptions)];
}
function _smtEscapeSymbol(raw) {
return `|${String(raw).replace(/\\/g, '\\\\').replace(/\|/g, '\\|')}|`;
}
function _smtDeclare(ctx, raw, sort) {
const key = String(raw);
const existing = ctx.declarations.get(key);
if (existing && existing !== sort) {
throw _rewriteError(`SMT symbol ${keyOf(key)} is used as both ${existing} and ${sort}`);
}
ctx.declarations.set(key, sort);
return _smtEscapeSymbol(key);
}
function _smtNumber(raw) {
const text = String(raw);
if (text.startsWith('-')) return `(- ${text.slice(1)})`;
return text;
}
function _smtLeaf(node, value) {
return typeof node === 'string' && node === value;
}
function _smtInfix(node, operators) {
return Array.isArray(node) &&
node.length === 3 &&
typeof node[1] === 'string' &&
operators.includes(node[1])
? node[1]
: null;
}
function _smtIsBoolish(node) {
if (typeof node === 'string') return node === 'true' || node === 'false';
if (!Array.isArray(node) || node.length === 0) return false;
if (_smtInfix(node, ['=', '!=', 'and', 'or', '=>', 'implies'])) return true;
return typeof node[0] === 'string' && ['not', 'and', 'or', '=>', 'implies'].includes(node[0]);
}
function _smtTerm(node, ctx) {
if (typeof node === 'string') {
if (isNum(node)) return _smtNumber(node);
if (node === 'true' || node === 'false') {
throw _rewriteError(`SMT bridge cannot use Boolean constant ${node} as a Real term`);
}
return _smtDeclare(ctx, node, 'Real');
}
if (!Array.isArray(node) || node.length === 0) {
throw _rewriteError(`SMT bridge cannot translate term ${keyOf(node)}`);
}
const infix = _smtInfix(node, ['+', '-', '*', '/']);
if (infix) {
return `(${infix} ${_smtTerm(node[0], ctx)} ${_smtTerm(node[2], ctx)})`;
}
const head = node[0];
if (typeof head === 'string' && ['+', '-', '*', '/'].includes(head) && node.length >= 3) {
return `(${head} ${node.slice(1).map(arg => _smtTerm(arg, ctx)).join(' ')})`;
}
return _smtDeclare(ctx, keyOf(node), 'Real');
}
function _smtEquality(left, right, ctx) {
if (_smtIsBoolish(left) || _smtIsBoolish(right)) {
return `(= ${_smtFormula(left, ctx)} ${_smtFormula(right, ctx)})`;
}
return `(= ${_smtTerm(left, ctx)} ${_smtTerm(right, ctx)})`;
}
function _smtFormula(node, ctx) {
if (typeof node === 'string') {
if (node === 'true') return 'true';
if (node === 'false') return 'false';
if (isNum(node)) throw _rewriteError(`SMT bridge cannot use numeric literal ${node} as a Boolean formula`);
return _smtDeclare(ctx, node, 'Bool');
}
if (!Array.isArray(node) || node.length === 0) {
throw _rewriteError(`SMT bridge cannot translate formula ${keyOf(node)}`);
}
const infix = _smtInfix(node, ['=', '!=', 'and', 'or', '=>', 'implies']);
if (infix === '=') return _smtEquality(node[0], node[2], ctx);
if (infix === '!=') return `(not ${_smtEquality(node[0], node[2], ctx)})`;
if (infix === 'and' || infix === 'or') {
return `(${infix} ${_smtFormula(node[0], ctx)} ${_smtFormula(node[2], ctx)})`;
}
if (infix === '=>' || infix === 'implies') {
return `(=> ${_smtFormula(node[0], ctx)} ${_smtFormula(node[2], ctx)})`;
}
const head = node[0];
if (head === 'not' && node.length === 2) return `(not ${_smtFormula(node[1], ctx)})`;
if ((head === 'and' || head === 'or') && node.length >= 1) {
if (node.length === 1) return head === 'and' ? 'true' : 'false';
return `(${head} ${node.slice(1).map(arg => _smtFormula(arg, ctx)).join(' ')})`;
}
if ((head === '=>' || head === 'implies') && node.length === 3) {
return `(=> ${_smtFormula(node[1], ctx)} ${_smtFormula(node[2], ctx)})`;
}
return _smtDeclare(ctx, keyOf(node), 'Bool');
}
function smtLibForGoal(goal) {
const ctx = { declarations: new Map() };
const formula = _smtFormula(goal, ctx);
const declarations = [...ctx.declarations.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, sort]) => `(declare-const ${_smtEscapeSymbol(name)} ${sort})`);
return [
...declarations,
`(assert (not ${formula}))`,
'(check-sat)',
'(exit)',
'',
].join('\n');
}
function _smtProcessSummary(output) {
const text = String(output || '').trim();
if (!text) return '<no output>';
const firstLine = text.split(/\r?\n/)[0];
return firstLine.length > 200 ? `${firstLine.slice(0, 200)}...` : firstLine;
}
function _parseSmtCheckSat(stdout, stderr) {
const combined = `${stdout || ''}\n${stderr || ''}`;
for (const line of combined.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed === 'unsat' || trimmed === 'sat' || trimmed === 'unknown') return trimmed;
}
return null;
}
function _runSmtSolver(smtLib, smtOptions) {
if (!smtOptions.solver) {
return { status: 'error', reason: 'SMT solver path is not configured' };
}
const solverName = _smtSolverProofName(smtOptions);
const child = spawnSync(smtOptions.solver, smtOptions.args, {
input: smtLib,
encoding: 'utf8',
timeout: smtOptions.timeoutMs,
maxBuffer: 1024 * 1024,
});
if (child.error) {
if (child.error.code === 'ETIMEDOUT') {
return {
status: 'timeout',
reason: `SMT solver ${solverName} timed out after ${smtOptions.timeoutMs} ms`,
};
}
return {
status: 'error',
reason: `SMT solver ${solverName} failed to start: ${child.error.message}`,
};
}
if (child.status !== 0) {
return {
status: 'error',
reason: `SMT solver ${solverName} exited with status ${child.status}: ${_smtProcessSummary(child.stderr || child.stdout)}`,
};
}
const result = _parseSmtCheckSat(child.stdout, child.stderr);
if (!result) {
return {
status: 'error',
reason: `SMT solver ${solverName} did not return sat, unsat, or unknown`,
};
}
return { status: result, reason: `SMT solver ${solverName} returned ${result}` };
}
function _tptpIdentifier(raw, role) {
let cleaned = String(raw).replace(/[^A-Za-z0-9_]/g, '_');
if (!cleaned) cleaned = role === 'var' ? 'X' : 'rml_symbol';
if (role === 'var') {
cleaned = cleaned[0].toUpperCase() + cleaned.slice(1);
if (!/^[A-Z]/.test(cleaned)) cleaned = `V_${cleaned}`;
return cleaned;
}
cleaned = cleaned.toLowerCase();
if (!/^[a-z]/.test(cleaned)) cleaned = `rml_${cleaned}`;
return cleaned;
}
function _tptpTerm(node, boundVars) {
if (typeof node === 'string') {
if (boundVars.has(node)) return _tptpIdentifier(node, 'var');
if (isNum(node)) return _tptpIdentifier(`num_${node}`, 'term');
return _tptpIdentifier(node, 'term');
}
if (!Array.isArray(node) || node.length === 0 || typeof node[0] !== 'string') {
throw _rewriteError(`TPTP export supports first-order terms only (got ${keyOf(node)})`);
}
const head = _tptpIdentifier(node[0], 'term');
const args = node.slice(1).map(arg => _tptpTerm(arg, boundVars)).join(', ');
return `${head}(${args})`;
}
function _infixOperands(node, op) {
if (!Array.isArray(node) || node.length < 3 || node.length % 2 === 0) return null;
const operands = [];
for (let i = 0; i < node.length; i += 2) {
if (i > 0 && node[i - 1] !== op) return null;
operands.push(node[i]);
}
return operands;
}
function _tptpJoinFormula(op, operands, boundVars) {
return operands.map(part => `(${_tptpFormula(part, boundVars)})`).join(` ${op} `);
}
function _quantifierParts(node) {
if (!Array.isArray(node) || node.length !== 3) return null;
const [head, binder, body] = node;
if (head !== 'forall' && head !== 'exists' && head !== 'Pi') return null;
const parsed = parseBinding(binder);
if (!parsed) {
throw _rewriteError(`TPTP export could not parse quantifier binder ${keyOf(binder)}`);
}
return {
quantifier: head === 'exists' ? '?' : '!',
variable: parsed.paramName,
body,
};
}
function _tptpFormula(node, boundVars = new Set()) {
if (typeof node === 'string') {
if (node === 'true') return '$true';
if (node === 'false') return '$false';
if (boundVars.has(node)) return _tptpIdentifier(node, 'var');
return _tptpIdentifier(node, 'pred');
}
if (!Array.isArray(node) || node.length === 0) {
throw _rewriteError(`TPTP export supports first-order formulas only (got ${keyOf(node)})`);
}
const quantified = _quantifierParts(node);
if (quantified) {
const nextBound = new Set(boundVars);
nextBound.add(quantified.variable);
const variable = _tptpIdentifier(quantified.variable, 'var');
return `${quantified.quantifier}[${variable}] : (${_tptpFormula(quantified.body, nextBound)})`;
}
const ascription = _typeAscription(node);
if (ascription) {
return `${_tptpIdentifier(keyOf(ascription.type), 'pred')}(${_tptpTerm(ascription.term, boundVars)})`;
}
const equality = _asEquality(node);
if (equality) {
return `${_tptpTerm(equality.left, boundVars)} = ${_tptpTerm(equality.right, boundVars)}`;
}
if (node.length === 3 && node[1] === '!=') {
return `${_tptpTerm(node[0], boundVars)} != ${_tptpTerm(node[2], boundVars)}`;
}
const conjunction = _infixOperands(node, 'and');
if (conjunction) return _tptpJoinFormula('&', conjunction, boundVars);
const disjunction = _infixOperands(node, 'or');
if (disjunction) return _tptpJoinFormula('|', disjunction, boundVars);
const implication = _infixOperands(node, '=>') ?? _infixOperands(node, 'implies');
if (implication && implication.length === 2) return _tptpJoinFormula('=>', implication, boundVars);
const equivalence = _infixOperands(node, '<=>') ?? _infixOperands(node, 'iff');
if (equivalence && equivalence.length === 2) return _tptpJoinFormula('<=>', equivalence, boundVars);
if (typeof node[0] === 'string') {
const head = node[0];
if (head === 'not' && node.length === 2) return `~(${_tptpFormula(node[1], boundVars)})`;
if (head === 'and' && node.length >= 2) return _tptpJoinFormula('&', node.slice(1), boundVars);
if (head === 'or' && node.length >= 2) return _tptpJoinFormula('|', node.slice(1), boundVars);
if ((head === '=>' || head === 'implies') && node.length === 3) {
return _tptpJoinFormula('=>', node.slice(1), boundVars);
}
if ((head === '<=>' || head === 'iff') && node.length === 3) {
return _tptpJoinFormula('<=>', node.slice(1), boundVars);
}
const predicate = _tptpIdentifier(head, 'pred');
if (node.length === 1) return predicate;
const args = node.slice(1).map(arg => _tptpTerm(arg, boundVars)).join(', ');
return `${predicate}(${args})`;
}
throw _rewriteError(`TPTP export supports first-order formulas only (got ${keyOf(node)})`);
}
function goalToTptp(goal, context = []) {
const proofGoal =
goal &&
typeof goal === 'object' &&
!Array.isArray(goal) &&
Object.prototype.hasOwnProperty.call(goal, 'goal')
? _normaliseProofGoal(goal)
: _normaliseProofGoal({ goal, context });
const lines = proofGoal.context.map((ctx, index) =>
`fof(rml_context_${index + 1}, axiom, (${_tptpFormula(ctx)})).`);
lines.push(`fof(rml_goal, conjecture, (${_tptpFormula(proofGoal.goal)})).`);
return `${lines.join('\n')}\n`;
}
function parseAtpStatus(output) {
const match = String(output || '').match(/\bSZS\s+status\s+([A-Za-z][A-Za-z0-9_]*)\b/);
if (!match) return null;
const status = match[1];
let kind = 'failure';
if (ATP_PROVED_STATUSES.has(status)) kind = 'proved';
else if (ATP_UNKNOWN_STATUSES.has(status)) kind = 'unknown';
else if (ATP_TIMEOUT_STATUSES.has(status)) kind = 'timeout';
return { status, kind };
}
function _normaliseAtpOptions(options = {}) {
const atp = options.atp && typeof options.atp === 'object' ? options.atp : {};
const rawArgs = atp.args ?? options.atpArgs ?? [];
let args;
if (typeof rawArgs === 'string') {
args = rawArgs.trim().length === 0 ? [] : rawArgs.trim().split(/\s+/);
} else {
args = Array.isArray(rawArgs) ? rawArgs.map(String) : [];
}
const timeoutRaw = atp.timeoutMs ?? options.atpTimeoutMs ?? DEFAULT_ATP_TIMEOUT_MS;
const timeoutMs = Number(timeoutRaw);
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
throw _rewriteError(`ATP timeout must be a positive integer (got ${String(timeoutRaw)})`);
}
const atpPath = atp.path ?? options.atpPath ?? null;
const name = atp.name ?? options.atpName ?? (atpPath ? path.basename(String(atpPath)) : 'atp');
return {
path: atpPath === null || atpPath === undefined || String(atpPath).length === 0
? null
: String(atpPath),
args,
name: String(name || 'atp').replace(/[()\s]+/g, '_'),
timeoutMs,
maxBuffer: atp.maxBuffer ?? options.atpMaxBuffer ?? 1024 * 1024,
};
}
function _runAtpProcess(tptp, atpOptions) {
if (!atpOptions.path) {
return { ok: false, reason: 'ATP path is not configured' };
}
const child = spawnSync(atpOptions.path, atpOptions.args, {
input: tptp,
encoding: 'utf8',
timeout: atpOptions.timeoutMs,
maxBuffer: atpOptions.maxBuffer,
});
const stdout = child.stdout || '';
const stderr = child.stderr || '';
const combined = `${stdout}\n${stderr}`;
if (child.error) {
if (child.error.code === 'ETIMEDOUT') {
return { ok: false, reason: `ATP timed out after ${atpOptions.timeoutMs} ms` };
}
return { ok: false, reason: `ATP invocation failed: ${child.error.message}` };
}
if (child.status !== 0) {
const detail = stderr.trim() || stdout.trim() || `exit status ${child.status}`;
return { ok: false, reason: `ATP exited with status ${child.status}: ${detail}` };
}
const parsed = parseAtpStatus(combined);
if (!parsed) {
return { ok: false, reason: 'ATP output did not contain an SZS status' };
}
if (parsed.kind === 'proved') {
return { ok: true, status: parsed.status, solver: atpOptions.name };
}
if (parsed.kind === 'timeout') {
return { ok: false, reason: `ATP returned ${parsed.status}` };
}
if (parsed.kind === 'unknown') {
return { ok: false, reason: `ATP returned ${parsed.status}` };
}
return { ok: false, reason: `ATP returned non-proving status ${parsed.status}` };
}
function _normaliseRewriteDirection(direction = 'forward') {
if (direction === undefined || direction === null) return 'forward';
const raw = String(direction);
if (raw === 'forward' || raw === 'left-to-right' || raw === '->') return 'forward';
if (raw === 'backward' || raw === 'right-to-left' || raw === '<-' || raw === 'reverse') {
return 'backward';
}
throw _rewriteError(`unknown rewrite direction "${raw}"`);
}
function _normaliseRewriteOccurrence(occurrence = 'all') {
if (occurrence === undefined || occurrence === null || occurrence === 'all') {
return { kind: 'all' };
}
if (occurrence === 'first') return { kind: 'index', index: 1 };
const index = typeof occurrence === 'number' ? occurrence : Number(String(occurrence));
if (Number.isSafeInteger(index) && index >= 1) return { kind: 'index', index };
throw _rewriteError(`rewrite occurrence must be "all", "first", or a positive integer (got ${keyOf(occurrence)})`);
}
function _rewriteSides(eqNode, direction) {
const eq = _asEquality(eqNode);
if (!eq) throw _rewriteError('rewrite expects an equality link');
if (_normaliseRewriteDirection(direction) === 'backward') {
return { from: eq.right, to: eq.left };
}
return { from: eq.left, to: eq.right };
}
function _rewriteNode(node, from, to, occurrence) {
const selected = _normaliseRewriteOccurrence(occurrence);
let seen = 0;
let count = 0;
function walk(current) {
if (isStructurallySame(current, from)) {
seen += 1;
if (selected.kind === 'all' || seen === selected.index) {
count += 1;
return cloneTerm(to);
}
}
if (!Array.isArray(current)) return cloneTerm(current);
return current.map(walk);
}
const rewritten = walk(node);
return { node: rewritten, changed: count > 0, count, seen };
}
function _rewriteDetailed(goal, eq, options = {}) {
const goalNode = parseTermInput(goal);
const eqNode = parseTermInput(eq);
const { from, to } = _rewriteSides(eqNode, options.direction);
return _rewriteNode(goalNode, from, to, options.occurrence);
}
function rewrite(goal, eq, options = {}) {
return _rewriteDetailed(goal, eq, options).node;
}
function _normaliseRewriteRules(rules) {
if (rules === undefined || rules === null) return [];
if (typeof rules === 'string') return parseLinoForms(rules);
const parsed = parseTermInput(rules);
if (_asEquality(parsed)) return [parsed];
if (!Array.isArray(rules)) return [parsed];
return rules.map(rule => {
const node = parseTermInput(rule);
if (!_asEquality(node)) {
throw _rewriteError(`simplify expects equality rewrite rules (got ${keyOf(node)})`);
}
return node;
});
}
function _normaliseSimplifyMaxSteps(options = {}) {
const raw = options.maxSteps ?? options.simplifyMaxSteps ?? DEFAULT_SIMPLIFY_MAX_STEPS;
const maxSteps = typeof raw === 'number' ? raw : Number(String(raw));
if (!Number.isSafeInteger(maxSteps) || maxSteps < 0) {
throw _rewriteError(`simplify maxSteps must be a non-negative integer (got ${String(raw)})`);
}
return maxSteps;
}
function _simplifyDetailed(goal, rules, options = {}) {
const ruleNodes = _normaliseRewriteRules(rules);
const maxSteps = _normaliseSimplifyMaxSteps(options);
let node = parseTermInput(goal);
let changed = false;
let steps = 0;
while (true) {
let applied = false;
for (const rule of ruleNodes) {
const rewritten = _rewriteDetailed(node, rule, { direction: options.direction });
if (!rewritten.changed) continue;
if (steps >= maxSteps) {
throw _rewriteError(`simplify termination guard reached after ${maxSteps} rewrite steps`);
}
node = rewritten.node;
steps += 1;
changed = true;
applied = true;
break;
}
if (!applied) return { node, changed, steps };
}
}
function simplify(goal, rules, options = {}) {
return _simplifyDetailed(goal, rules, options).node;
}
function _normaliseTacticOptions(options = {}) {
return {
rewriteRules: _normaliseRewriteRules(options.rewriteRules ?? options.rules ?? []),
simplifyMaxSteps: _normaliseSimplifyMaxSteps(options),
atp: _normaliseAtpOptions(options),
smt: _normaliseSmtOptions(options),
};
}
function _parseRewriteTactic(args) {
let index = 0;
let direction = 'forward';
if (args[index] === '->' || args[index] === '<-') {
direction = args[index];
index += 1;
}
if (args.length < index + 3 || args[index + 1] !== 'in' || args[index + 2] !== 'goal') {
throw _rewriteError('rewrite expects `(rewrite [->|<-] (L = R) in goal [at N])`');
}
const eq = args[index];
index += 3;
let occurrence = 'all';
if (index < args.length) {
if (args[index] !== 'at' || index + 2 !== args.length) {
throw _rewriteError('rewrite expects optional occurrence selector `at N`');
}
occurrence = args[index + 1];
}
return { eq, direction, occurrence };
}
function _parseSimplifyTactic(args) {
if (args.length < 2 || args[0] !== 'in' || args[1] !== 'goal') {
throw _rewriteError('simplify expects `(simplify in goal)`');
}
let index = 2;
let rules = null;
let maxSteps = null;
while (index < args.length) {
if (args[index] === 'using' && index + 1 < args.length) {
rules = _normaliseRewriteRules(args[index + 1]);
index += 2;
continue;
}
if ((args[index] === 'max' || args[index] === 'limit') && index + 1 < args.length) {
maxSteps = Number(String(args[index + 1]));
index += 2;
continue;
}
throw _rewriteError('simplify expects optional `using <rules>` and `max <steps>` clauses');
}
return { rules, maxSteps };
}
function _typeAscription(node) {
if (Array.isArray(node) && node.length === 3 && node[1] === 'of') {
return { term: node[0], type: node[2] };
}
return null;
}
function _exactClosesGoal(arg, goal) {
if (isStructurallySame(arg, goal.goal)) return true;
const ascription = _typeAscription(arg);
if (ascription && isStructurallySame(ascription.type, goal.goal)) return true;
return goal.context.some(ctx => {
if (isStructurallySame(ctx, arg) && isStructurallySame(arg, goal.goal)) return true;
if (isStructurallySame(ctx, goal.goal) && isStructurallySame(arg, goal.goal)) return true;
const ctxAscription = _typeAscription(ctx);
return !!ctxAscription &&
isStructurallySame(ctxAscription.term, arg) &&
isStructurallySame(ctxAscription.type, goal.goal);
});
}
function _applyTactic(state, tactic, recordTactic = tactic, tacticOptions = _normaliseTacticOptions()) {
const name = _tacticName(tactic);
const args = _tacticArgs(tactic);
if (name === 'by') {
if (args.length === 1) return _applyTactic(state, args[0], recordTactic, tacticOptions);
if (args.length > 1) return _applyTactic(state, args, recordTactic, tacticOptions);
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, state.goals[0] || null, '`by` requires an inner tactic'),
};
}
const current = state.goals[0] || null;
if (!current) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, null, 'no open goals'),
};
}
if (name === 'reflexivity') {
const eq = _asEquality(current.goal);
if (!eq) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, 'reflexivity expects an equality goal'),
};
}
if (!isStructurallySame(eq.left, eq.right)) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, 'both sides are not structurally equal'),
};
}
return { ok: true, state: _replaceCurrentGoal(state, [], recordTactic) };
}
if (name === 'symmetry') {
const eq = _asEquality(current.goal);
if (!eq) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, 'symmetry expects an equality goal'),
};
}
return {
ok: true,
state: _replaceCurrentGoal(
state,
[_goalWithContext(current, [eq.right, '=', eq.left])],
recordTactic,
),
};
}
if (name === 'transitivity') {
const eq = _asEquality(current.goal);
if (!eq || args.length !== 1) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, 'transitivity expects an equality goal and one intermediate term'),
};
}
const mid = args[0];
return {
ok: true,
state: _replaceCurrentGoal(
state,
[
_goalWithContext(current, [eq.left, '=', mid]),
_goalWithContext(current, [mid, '=', eq.right]),
],
recordTactic,
),
};
}
if (name === 'suppose') {
if (args.length !== 1) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, 'suppose expects one hypothesis link'),
};
}
const next = _cloneProofState(state);
next.goals[0].context.push(cloneTerm(args[0]));
next.proof.push(cloneTerm(recordTactic));
return { ok: true, state: next };
}
if (name === 'introduce') {
if (args.length !== 1 || typeof args[0] !== 'string') {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, 'introduce expects one variable name'),
};
}
if (!Array.isArray(current.goal) || current.goal.length !== 3 || current.goal[0] !== 'Pi') {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, 'introduce expects a Pi goal'),
};
}
const binding = parseBinding(current.goal[1]);
if (!binding) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, 'introduce could not parse the Pi binder'),
};
}
const variable = args[0];
const body = subst(current.goal[2], binding.paramName, variable);
const introduced = _goalWithContext(current, body);
introduced.context.push([variable, 'of', cloneTerm(binding.paramType)]);
return {
ok: true,
state: _replaceCurrentGoal(state, [introduced], recordTactic),
};
}
if (name === 'rewrite') {
let parsed;
try {
parsed = _parseRewriteTactic(args);
} catch (err) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, err.message),
};
}
let rewritten;
try {
rewritten = _rewriteDetailed(current.goal, parsed.eq, {
direction: parsed.direction,
occurrence: parsed.occurrence,
});
} catch (err) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, err.message),
};
}
if (!rewritten.changed) {
const { from } = _rewriteSides(parsed.eq, parsed.direction);
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, `rewrite did not find ${keyOf(from)} in the current goal`),
};
}
return {
ok: true,
state: _replaceCurrentGoal(state, [_goalWithContext(current, rewritten.node)], recordTactic),
};
}
if (name === 'simplify') {
let parsed;
try {
parsed = _parseSimplifyTactic(args);
} catch (err) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, err.message),
};
}
const rules = parsed.rules ?? tacticOptions.rewriteRules;
if (rules.length === 0) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, 'simplify expects at least one configured rewrite rule'),
};
}
let simplified;
try {
simplified = _simplifyDetailed(current.goal, rules, {
maxSteps: parsed.maxSteps ?? tacticOptions.simplifyMaxSteps,
});
} catch (err) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, err.message),
};
}
return {
ok: true,
state: _replaceCurrentGoal(state, [_goalWithContext(current, simplified.node)], recordTactic),
};
}
if (name === 'smt') {
if (args.length !== 0) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, 'smt expects no arguments; configure the solver through tactic options'),
};
}
let smtLib;
try {
smtLib = smtLibForGoal(current.goal);
} catch (err) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, err.message),
};
}
const checked = _runSmtSolver(smtLib, tacticOptions.smt);
if (checked.status !== 'unsat') {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, checked.reason),
};
}
return {
ok: true,
state: _replaceCurrentGoal(state, [], _smtTrustedNode(tacticOptions.smt)),
};
}
if (name === 'atp') {
if (args.length !== 0) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, 'atp expects no tactic arguments'),
};
}
let tptp;
try {
tptp = goalToTptp(current);
} catch (err) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, err.message),
};
}
const atp = _runAtpProcess(tptp, tacticOptions.atp);
if (!atp.ok) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, atp.reason),
};
}
return {
ok: true,
state: _replaceCurrentGoal(state, [], ['by', 'atp-trusted', atp.solver]),
};
}
if (name === 'exact') {
if (args.length !== 1) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, 'exact expects one term or hypothesis'),
};
}
if (!_exactClosesGoal(args[0], current)) {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, `${keyOf(args[0])} does not prove the current goal`),
};
}
return { ok: true, state: _replaceCurrentGoal(state, [], recordTactic) };
}
if (name === 'induction') {
if (args.length < 2 || typeof args[0] !== 'string') {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, 'induction expects a variable and at least one case'),
};
}
const variable = args[0];
const cases = args.slice(1);
const openGoals = [];
const nestedProofs = [];
for (const caseNode of cases) {
if (!Array.isArray(caseNode) || caseNode.length < 2 || caseNode[0] !== 'case') {
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, 'induction cases must be `(case <pattern> <tactic>...)` links'),
};
}
const pattern = caseNode[1];
const caseGoal = _goalWithContext(current, subst(current.goal, variable, pattern));
const caseTactics = caseNode.slice(2);
if (caseTactics.length === 0) {
openGoals.push(caseGoal);
continue;
}
const nested = _runTacticsInternal({ goals: [caseGoal], proof: [] }, caseTactics, tacticOptions);
if (nested.diagnostics.length > 0) {
return { ok: false, state, diagnostic: nested.diagnostics[0] };
}
openGoals.push(...nested.state.goals);
nestedProofs.push(...nested.state.proof);
}
return {
ok: true,
state: {
goals: [...openGoals, ...state.goals.slice(1)],
proof: [...state.proof, cloneTerm(recordTactic), ...nestedProofs.map(cloneTerm)],
},
};
}
return {
ok: false,
state,
diagnostic: _tacticDiagnostic(recordTactic, current, `unknown tactic "${String(name || keyOf(tactic))}"`),
};
}
function _runTacticsInternal(state, tactics, tacticOptions = _normaliseTacticOptions()) {
let next = _cloneProofState(state);
const diagnostics = [];
for (const tactic of _normaliseTacticList(tactics)) {
const applied = _applyTactic(next, tactic, tactic, tacticOptions);
if (!applied.ok) {
diagnostics.push(applied.diagnostic);
break;
}
next = applied.state;
}
return { state: next, diagnostics };
}
function runTactics(state, tactics, options = {}) {
return _runTacticsInternal(_normaliseProofState(state), tactics, _normaliseTacticOptions(options));
}
// Evaluate a node in arithmetic context — numeric literals are NOT clamped to the logic range.
function evalArith(node, env){
if (typeof node === 'string' && isNum(node)) return parseFloat(node);
const evaluated = evalNode(node, env);
if (isTermResult(evaluated)) return evalArith(evaluated.term, env);
return evaluated;
}
function isTermResult(value) {
return value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'term');
}
function evalTermNode(node, env) {
if (!Array.isArray(node)) return node;
if (node.length === 4 && node[0] === 'subst' && typeof node[2] === 'string') {
return evalTermNode(subst(evalTermNode(node[1], env), node[2], evalTermNode(node[3], env)), env);
}
if (node.length === 3 && node[0] === 'apply') {
const fn = node[1];
const arg = evalTermNode(node[2], env);
if (Array.isArray(fn) && fn.length === 3 && fn[0] === 'lambda') {
const parsed = parseBinding(fn[1]);
if (parsed) return evalTermNode(subst(fn[2], parsed.paramName, arg), env);
}
if (typeof fn === 'string') {
const lambda = env.getLambda(fn);
if (lambda) return evalTermNode(subst(lambda.body, lambda.param, arg), env);
}
}
if (Array.isArray(node[0]) && node[0].length === 3 && node[0][0] === 'lambda' && node.length >= 2) {
const parsed = parseBinding(node[0][1]);
if (parsed) {
const reduced = subst(node[0][2], parsed.paramName, evalTermNode(node[1], env));
return node.length === 2 ? evalTermNode(reduced, env) : evalTermNode([reduced, ...node.slice(2)], env);
}
}
return node;
}
function conversionOptionsFrom(ctx, options) {
const opts = options || {};
const ctxOpts = ctx && !(ctx instanceof Env) ? ctx : {};
return {
eta: Boolean(opts.eta || opts.etaConversion || ctxOpts.eta || ctxOpts.etaConversion),
};
}
function parseTermInput(term) {
if (Array.isArray(term)) return desugarHoas(term);
if (typeof term !== 'string') return String(term);
const trimmed = term.trim();
if (trimmed.startsWith('(')) {
try {
return desugarHoas(parseOne(tokenizeOne(trimmed)));
} catch (_) {
return term;
}
}
return term;
}
// Weak-head normal form (D4): reduce the spine of `node` — i.e. unfold the
// head as long as there are arguments to apply to it — without descending
// into binders or argument positions. The result's top-level form is either
// a value (lambda, Pi, fresh, a stuck/neutral application, etc.) or a leaf.
//
// "Spine reduction" means: gather the applied arguments, β-reduce them
// against the head one by one, and stop as soon as the spine is exhausted.
// Substitution may leave a new redex in the body, but it is not on the
// original term's spine, so whnf returns it unevaluated. Full normalization
// (`nf`) is the place that descends into those positions.
function whnfTerm(node, env, options = {}) {
if (!Array.isArray(node)) return node;
if (node.length === 0) return [];
if (node.length === 4 && node[0] === 'subst' && typeof node[2] === 'string') {
const term = whnfTerm(node[1], env, options);
const replacement = node[3];
return whnfTerm(subst(term, node[2], replacement), env, options);
}
// Collect the leftmost-outermost `apply` spine into [head, arg1, arg2, ...]
// so the loop below can β-reduce against any number of arguments without
// re-entering whnfTerm (which would descend into the substituted body's
// spine and over-reduce — see the test "leaves arguments unevaluated").
const spineArgs = [];
let head = node;
while (Array.isArray(head) && head.length === 3 && head[0] === 'apply') {
spineArgs.unshift(head[2]);
head = head[1];
}
// Prefix-call shape: `(f arg1 arg2 ...)` where `f` is a lambda value or a
// bound name. Drain that into the spine before reducing.
if (spineArgs.length === 0 && Array.isArray(head) && head.length > 1) {
const isLambdaHead = Array.isArray(head[0]) && head[0].length === 3 && head[0][0] === 'lambda';
const isNameHead = typeof head[0] === 'string' && head[0] !== 'apply' && head[0] !== 'lambda' && head[0] !== 'Pi' && head[0] !== 'fresh';
if (isLambdaHead || isNameHead) {
const [h, ...rest] = head;
head = h;
spineArgs.push(...rest);
}
}
// Drain the spine by β-reducing against the head. Stop as soon as the
// head can no longer reduce (not a lambda, not a bound name) or there
// are no remaining args.
while (spineArgs.length > 0) {
if (Array.isArray(head) && head.length === 3 && head[0] === 'lambda') {
const parsed = parseBinding(head[1]);
if (!parsed) break;
head = subst(head[2], parsed.paramName, spineArgs.shift());
continue;
}
if (typeof head === 'string') {
const lambda = env.getLambda(head) || env.getLambda(env._resolveQualified(head));
if (!lambda) break;
head = subst(lambda.body, lambda.param, spineArgs.shift());
continue;
}
break;
}
if (spineArgs.length === 0) return head;
// Stuck spine: rebuild the unreduced applies around the residual head.
let stuck = head;
for (const arg of spineArgs) stuck = ['apply', stuck, arg];
return stuck;
}
// True for an `(apply head arg)` whose head is a free symbol the env cannot
// reduce further — i.e. an applied constructor or other neutral. The
// printed normal form drops the explicit `apply` keyword for these neutrals
// so `(apply succ zero)` shows as `(succ zero)`, matching the surface
// example in issue #50.
function isNeutralApply(node, env) {
if (!Array.isArray(node) || node.length !== 3 || node[0] !== 'apply') return false;
const fn = node[1];
if (typeof fn !== 'string') return false;
if (env.getLambda(fn) || env.getLambda(env._resolveQualified(fn))) return false;
return isVariableToken(fn);
}
// Full normal form (D4): reduce every redex, including those nested inside
// binders and argument positions, until the term is in beta-(eta-)normal
// form. The implementation is a normal-order traversal that piggy-backs on
// the kernel's existing capture-avoiding `subst` helper so substitution
// stays definitionally equal to the rest of the typed kernel.
function normalizeTerm(node, env, options = {}) {
if (!Array.isArray(node)) return node;
if (node.length === 0) return [];
if (node.length === 4 && node[0] === 'subst' && typeof node[2] === 'string') {
const term = normalizeTerm(node[1], env, options);
const replacement = normalizeTerm(node[3], env, options);
return normalizeTerm(subst(term, node[2], replacement), env, options);
}
if (node.length === 3 && node[0] === 'apply') {
const fn = normalizeTerm(node[1], env, options);
const arg = normalizeTerm(node[2], env, options);
if (Array.isArray(fn) && fn.length === 3 && fn[0] === 'lambda') {
const parsed = parseBinding(fn[1]);
if (parsed) return normalizeTerm(subst(fn[2], parsed.paramName, arg), env, options);
}
if (typeof fn === 'string') {
const lambda = env.getLambda(fn) || env.getLambda(env._resolveQualified(fn));
if (lambda) return normalizeTerm(subst(lambda.body, lambda.param, arg), env, options);
}
return ['apply', fn, arg];
}
if (node.length === 3 && node[0] === 'lambda') {
const candidate = ['lambda', normalizeTerm(node[1], env, options), normalizeTerm(node[2], env, options)];
return etaContract(candidate, env, options);
}
const [head, ...args] = node;
if (Array.isArray(head) && head.length === 3 && head[0] === 'lambda' && args.length >= 1) {
const parsed = parseBinding(head[1]);
if (parsed) {
const first = normalizeTerm(args[0], env, options);
const reduced = subst(head[2], parsed.paramName, first);
if (args.length === 1) return normalizeTerm(reduced, env, options);
return normalizeTerm([reduced, ...args.slice(1)], env, options);
}
}
if (typeof head === 'string' && args.length >= 1) {
const lambda = env.getLambda(head) || env.getLambda(env._resolveQualified(head));
if (lambda) {
const first = normalizeTerm(args[0], env, options);
const reduced = subst(lambda.body, lambda.param, first);
if (args.length === 1) return normalizeTerm(reduced, env, options);
return normalizeTerm([reduced, ...args.slice(1)], env, options);
}
}
return node.map(child => normalizeTerm(child, env, options));
}
function etaContract(term, env, options) {
if (!options.eta || !Array.isArray(term) || term.length !== 3 || term[0] !== 'lambda') {
return term;
}
const bindings = parseBindings(term[1]);
if (!bindings || bindings.length !== 1) return term;
const param = bindings[0].paramName;
const body = term[2];
let fn = null;
if (Array.isArray(body) && body.length === 3 && body[0] === 'apply' && isStructurallySame(body[2], param)) {
fn = body[1];
} else if (Array.isArray(body) && body.length === 2 && isStructurallySame(body[1], param)) {
fn = body[0];
}
if (fn !== null && !freeVariables(fn).has(param)) {
return normalizeTerm(fn, env, options);
}
return term;
}
function lookupAssignedInfix(env, op, left, right) {
for (const expr of [[op, left, right], [left, op, right]]) {
const key = keyOf(expr);
if (env.assign.has(key)) {
const value = env.assign.get(key);
env.trace('lookup', `${key} → ${formatTraceValue(value)}`);
return value;
}
}
return null;
}
function sameNormalizedInput(left, right, leftTerm, rightTerm) {
return isStructurallySame(left, leftTerm) && isStructurallySame(right, rightTerm);
}
function explicitSymbolNumber(node, env) {
if (typeof node !== 'string') return null;
if (env.symbolProb.has(node)) return env.symbolProb.get(node);
const resolved = env._resolveQualified(node);
if (resolved !== node && env.symbolProb.has(resolved)) return env.symbolProb.get(resolved);
return null;
}
function tryEvalNumeric(node, env, options = {}) {
const term = normalizeTerm(node, env, options);
if (typeof term === 'string') {
if (isNum(term)) return parseFloat(term);
return explicitSymbolNumber(term, env);
}
if (!Array.isArray(term) || term.length === 0) return null;
if (term.length === 3 && typeof term[1] === 'string' && ['+','-','*','/'].includes(term[1])) {
const left = tryEvalNumeric(term[0], env, options);
const right = tryEvalNumeric(term[2], env, options);
if (left === null || right === null) return null;
return env.getOp(term[1])(left, right);
}
if (term.length === 3 && typeof term[1] === 'string' && ['and','or','both','neither'].includes(term[1])) {
const left = tryEvalNumeric(term[0], env, options);
const right = tryEvalNumeric(term[2], env, options);
if (left === null || right === null) return null;
return env.clamp(env.getOp(term[1])(left, right));
}
const [head, ...args] = term;
if (typeof head === 'string' && env.hasOp(head) && head !== '=' && head !== '!=') {
const vals = [];
for (const arg of args) {
const value = tryEvalNumeric(arg, env, options);
if (value === null) return null;
vals.push(value);
}
return env.clamp(env.getOp(head)(...vals));
}
return null;
}
function equalityTruthValue(left, right, leftTerm, rightTerm, env, options = {}) {
const assigned = lookupAssignedInfix(env, '=', left, right);
if (assigned !== null) return env.clamp(assigned);
if (!sameNormalizedInput(left, right, leftTerm, rightTerm)) {
const normalizedAssigned = lookupAssignedInfix(env, '=', leftTerm, rightTerm);
if (normalizedAssigned !== null) return env.clamp(normalizedAssigned);
}
if (isStructurallySame(leftTerm, rightTerm)) return env.hi;
const leftNum = tryEvalNumeric(leftTerm, env, options);
const rightNum = tryEvalNumeric(rightTerm, env, options);
if (leftNum !== null && rightNum !== null) {
return decRound(leftNum) === decRound(rightNum) ? env.hi : env.lo;
}
return env.lo;
}
function evalEqualityNode(left, op, right, env, options = {}) {
const direct = lookupAssignedInfix(env, op, left, right);
if (direct !== null) return env.clamp(direct);
const leftTerm = normalizeTerm(left, env, options);
const rightTerm = normalizeTerm(right, env, options);
if (!sameNormalizedInput(left, right, leftTerm, rightTerm)) {
const normalizedDirect = lookupAssignedInfix(env, op, leftTerm, rightTerm);
if (normalizedDirect !== null) return env.clamp(normalizedDirect);
}
if (op === '=') {
return env.clamp(equalityTruthValue(left, right, leftTerm, rightTerm, env, options));
}
const eq = equalityTruthValue(left, right, leftTerm, rightTerm, env, options);
return env.clamp(env.getOp('not')(eq));
}
/**
* Check definitional equality by normalizing two terms in the supplied context.
*
* @param {*} left - Left AST term.
* @param {*} right - Right AST term.
* @param {object} [ctx] - Type-checking context.
* @param {object} [options] - Conversion options.
* @returns {boolean} True when the terms are convertible.
*/
function isConvertible(left, right, ctx, options) {
const env = ctx instanceof Env ? ctx : new Env(ctx && ctx.env ? ctx.env : ctx);
const opts = conversionOptionsFrom(ctx, options);
const leftNode = parseTermInput(left);
const rightNode = parseTermInput(right);
const assigned = lookupAssignedInfix(env, '=', leftNode, rightNode);
if (assigned !== null) return env.clamp(assigned) === env.hi;
const leftTerm = normalizeTerm(leftNode, env, opts);
const rightTerm = normalizeTerm(rightNode, env, opts);
if (!sameNormalizedInput(leftNode, rightNode, leftTerm, rightTerm)) {
const normalizedAssigned = lookupAssignedInfix(env, '=', leftTerm, rightTerm);
if (normalizedAssigned !== null) return env.clamp(normalizedAssigned) === env.hi;
}
return isStructurallySame(leftTerm, rightTerm);
}
// Drop the explicit `apply` keyword on neutral applications, recursively.
// `(apply f a)` whose head is a free constructor-like symbol becomes
// `(f a)` so the printed normal form matches the LiNo surface example
// from issue #50: `(succ (succ zero))` rather than the explicit
// `(apply succ (apply succ zero))`.
function flattenNeutralApplies(node, env) {
if (!Array.isArray(node)) return node;
if (node.length === 0) return node;
const binder = binderInfo(node);
if (binder) {
const out = node.slice();
out[binder.bodyIndex] = flattenNeutralApplies(node[binder.bodyIndex], env);
return out;
}
const flattened = node.map(child => flattenNeutralApplies(child, env));
if (isNeutralApply(flattened, env)) {
return [flattened[1], flattened[2]];
}
return flattened;
}
// Public weak-head normal form API (issue #50, D4).
// Reduces only the spine of `term` — leaves binders and arguments untouched.
/**
* Reduce a term to weak-head normal form without descending into arguments.
*/
function whnf(term, ctx, options) {
const env = ctx instanceof Env ? ctx : new Env(ctx && ctx.env ? ctx.env : ctx);
const opts = conversionOptionsFrom(ctx, options);
return whnfTerm(parseTermInput(term), env, opts);
}
// Public full normal form API (issue #50, D4).
// Reduces every redex in `term`, including ones nested under binders and in
// argument positions, until the term is in beta-(eta-)normal form.
/**
* Reduce a term to normal form.
*/
function nf(term, ctx, options) {
const env = ctx instanceof Env ? ctx : new Env(ctx && ctx.env ? ctx.env : ctx);
const opts = conversionOptionsFrom(ctx, options);
return flattenNeutralApplies(normalizeTerm(parseTermInput(term), env, opts), env);
}
function evalReducedTerm(reduced, env) {
const term = normalizeTerm(reduced, env);
if (hasUnresolvedFreeVariables(term, env)) return { term };
return evalNode(term, env);
}
// ---------- Mode declarations (issue #43, D15) ----------
// `(mode plus +input +input -output)` records the per-argument mode pattern
// for relation `plus`. Each flag is normalised to one of:
// 'in' — `+input` : caller must supply a ground argument here
// 'out' — `-output` : the relation is expected to produce a value here
// 'either' — `*either` : no directionality constraint
// Any other token is rejected with a structured `E030` diagnostic at the
// declaration site so the parser does not silently accept typos.
const MODE_FLAG_TOKENS = {
'+input': 'in',
'-output': 'out',
'*either': 'either',
};
function parseModeFlag(token) {
if (typeof token !== 'string') return null;
return Object.prototype.hasOwnProperty.call(MODE_FLAG_TOKENS, token)
? MODE_FLAG_TOKENS[token]
: null;
}
function parseModeForm(node) {
if (!Array.isArray(node) || node.length < 2) return null;
if (node[0] !== 'mode') return null;
if (typeof node[1] !== 'string') {
throw new RmlError('E030', 'Mode declaration: relation name must be a bare symbol');
}
const name = node[1];
if (node.length < 3) {
throw new RmlError('E030', `Mode declaration for "${name}" must list at least one mode flag`);
}
const flags = [];
for (let i = 2; i < node.length; i++) {
const flag = parseModeFlag(node[i]);
if (flag === null) {
throw new RmlError('E030', `Mode declaration for "${name}": unknown flag "${node[i]}" (expected +input, -output, or *either)`);
}
flags.push(flag);
}
return { name, flags };
}
// Decide whether an argument occupying a `+input` slot is "ground" enough.
// A ground argument has no free variables that the env cannot evaluate —
// numeric literals, declared terms, and known symbols are all fine; a fresh
// or otherwise unbound name is not.
function isGroundForMode(arg, env) {
if (typeof arg === 'string') {
if (isNum(arg)) return true;
return contextHasName(env, arg);
}
if (!Array.isArray(arg)) return true;
return !hasUnresolvedFreeVariables(arg, env);
}
// ---------- Relation declarations & totality (issue #44, D12) ----------
// `(relation <name> <clause>...)` records the clause list of a Twelf-style
// relation. Each clause is shaped `(<name> arg1 arg2 ... result)`, where
// `result` is the right-most argument (typically populated for relations
// whose mode declaration ends with `-output`). A single-rule shorthand
// `(relation <name> (<name> arg...) body)` is normalized to that clause shape.
// The body may contain recursive references to `<name>` whose `+input` slots
// must be strictly smaller than the head's; the totality checker enforces
// that decrease.
//
// `(total <name>)` triggers `isTotal(env, name)` and lifts the diagnostics
// it returns into the active diagnostic list. The same `isTotal` helper is
// also exported for programmatic use.
function parseRelationForm(node) {
if (!Array.isArray(node) || node[0] !== 'relation') return null;
if (node.length < 2 || typeof node[1] !== 'string') {
throw new RmlError('E032', 'Relation declaration: relation name must be a bare symbol');
}
const name = node[1];
if (node.length < 3) {
throw new RmlError('E032', `Relation declaration for "${name}" must list at least one clause`);
}
if (node.length === 4) {
const pattern = node[2];
const body = node[3];
const patternMatches = Array.isArray(pattern) && pattern.length >= 2 && pattern[0] === name;
const bodyLooksLikeClause = Array.isArray(body) && body.length >= 2 && body[0] === name;
if (patternMatches && !bodyLooksLikeClause) {
return { name, clauses: [[...pattern, body]] };
}
}
const clauses = [];
for (let i = 2; i < node.length; i++) {
const clause = node[i];
if (!Array.isArray(clause) || clause.length < 2 || clause[0] !== name) {
throw new RmlError(
'E032',
`Relation declaration for "${name}": clause ${i - 1} must be a list whose head is "${name}"`,
);
}
clauses.push(clause);
}
return { name, clauses };
}
// True when `inner` is a strict subterm of `outer` — i.e. `outer` contains a
// proper sub-expression structurally identical to `inner`. The relation is
// strict: identical terms are not subterms of themselves. Used as the
// structural-decrease witness on recursive calls.
function isStrictSubterm(inner, outer) {
if (!Array.isArray(outer)) return false;
for (const child of outer) {
if (isStructurallySame(inner, child)) return true;
if (isStrictSubterm(inner, child)) return true;
}
return false;
}
// Walk `node` and collect every recursive call to `relName` (i.e. every
// list whose head is `relName`). The clause head itself is excluded so the
// caller compares recursive calls against the head, not against itself.
function collectRecursiveCalls(node, relName, isHead) {
const out = [];
if (!Array.isArray(node)) return out;
if (!isHead && node[0] === relName) out.push(node);
for (let i = 0; i < node.length; i++) {
if (i === 0 && typeof node[i] === 'string') continue;
out.push(...collectRecursiveCalls(node[i], relName, false));
}
return out;
}
// Check a single recursive call against the clause head. Returns null when
// at least one `+input` slot is a strict subterm of the head's; otherwise
// returns a counter-witness description suitable for a diagnostic.
//
// Recursive calls inside a clause's output expression (functional style)
// commonly carry only the `+input` arguments — the output is the sub-tree
// itself. To accommodate that, the call may either:
// - supply every declared slot (`flags.length` arguments), in which case
// we compare the corresponding input positions, or
// - supply just the input slots (`numInputs` arguments), in which case
// we line them up with the head's input positions in order.
function checkRecursiveDecrease(call, headArgs, flags, relName) {
const callArgs = call.slice(1);
const inputIndices = [];
for (let i = 0; i < flags.length; i++) if (flags[i] === 'in') inputIndices.push(i);
let inputPairs = null;
if (callArgs.length === flags.length) {
inputPairs = inputIndices.map(i => [callArgs[i], headArgs[i]]);
} else if (callArgs.length === inputIndices.length) {
inputPairs = inputIndices.map((i, j) => [callArgs[j], headArgs[i]]);
} else {
return {
reason: `recursive call \`${keyOf(call)}\` has ${callArgs.length} argument${callArgs.length === 1 ? '' : 's'}, expected ${flags.length} (or ${inputIndices.length} input${inputIndices.length === 1 ? '' : 's'})`,
call,
};
}
if (inputIndices.length === 0) {
return {
reason: `relation "${relName}" has no \`+input\` slot, so structural decrease is unverifiable`,
call,
};
}
for (const [callArg, headArg] of inputPairs) {
if (isStrictSubterm(callArg, headArg)) {
return null; // decrease witnessed at this input slot
}
}
return {
reason: `recursive call \`${keyOf(call)}\` does not structurally decrease any \`+input\` slot of \`${keyOf([relName, ...headArgs])}\``,
call,
};
}
// Public-facing totality checker: returns `{ ok, diagnostics }`. When the
// relation has no declared modes the check is skipped and a single
// diagnostic is returned so callers can surface the missing prerequisite.
function isTotal(env, relName) {
const diagnostics = [];
const clauses = env.relations.get(relName);
const flags = env.modes.get(relName);
if (!flags) {
diagnostics.push({
code: 'E032',
message: `Totality check for "${relName}": no \`(mode ${relName} ...)\` declaration found`,
});
return { ok: false, diagnostics };
}
if (!clauses || clauses.length === 0) {
diagnostics.push({
code: 'E032',
message: `Totality check for "${relName}": no \`(relation ${relName} ...)\` clauses found`,
});
return { ok: false, diagnostics };
}
for (let ci = 0; ci < clauses.length; ci++) {
const clause = clauses[ci];
const headArgs = clause.slice(1);
if (headArgs.length !== flags.length) {
diagnostics.push({
code: 'E032',
message: `Totality check for "${relName}": clause ${ci + 1} \`${keyOf(clause)}\` has ${headArgs.length} argument${headArgs.length === 1 ? '' : 's'}, mode declares ${flags.length}`,
});
continue;
}
const calls = collectRecursiveCalls(clause, relName, true);
for (const call of calls) {
const witness = checkRecursiveDecrease(call, headArgs, flags, relName);
if (witness) {
diagnostics.push({
code: 'E032',
message: `Totality check for "${relName}": clause ${ci + 1} \`${keyOf(clause)}\` — ${witness.reason}`,
});
}
}
}
return { ok: diagnostics.length === 0, diagnostics };
}
// ---------- Definitions & termination checking (issue #49, D13) ----------
// `(define <name> [(measure (lex <slot>...))] (case <pat-args> <body>) ...)`
// records a recursive definition keyed by `<name>`. Each `case` clause holds
// the pattern argument list (the head's arguments at this clause) and a body
// expression that may reference `<name>` recursively.
//
// `isTerminating(env, name)` returns `{ ok, diagnostics }`. The default
// (measure-less) check requires every recursive call to structurally
// decrease the first argument relative to the matching clause's first
// pattern. The explicit `(measure (lex k1 k2 ...))` form switches to a
// lexicographic measure: a recursive call is accepted when there is some
// position k where slots before k are structurally identical to the head's
// and slot k is a strict subterm. Slots are 1-based argument indices.
function parseDefineForm(node) {
if (!Array.isArray(node) || node[0] !== 'define') return null;
if (node.length < 2 || typeof node[1] !== 'string') {
throw new RmlError('E035', 'Define declaration: name must be a bare symbol');
}
const name = node[1];
if (node.length < 3) {
throw new RmlError(
'E035',
`Define declaration for "${name}" must list at least one \`(case ...)\` clause`,
);
}
let measure = null;
const clauses = [];
for (let i = 2; i < node.length; i++) {
const child = node[i];
if (Array.isArray(child) && child[0] === 'measure') {
if (measure !== null) {
throw new RmlError(
'E035',
`Define declaration for "${name}": only one \`(measure ...)\` clause is allowed`,
);
}
if (child.length !== 2 || !Array.isArray(child[1]) || child[1][0] !== 'lex' || child[1].length < 2) {
throw new RmlError(
'E035',
`Define declaration for "${name}": \`(measure ...)\` body must be \`(lex <slot>...)\``,
);
}
const slots = [];
for (let j = 1; j < child[1].length; j++) {
const tok = child[1][j];
if (typeof tok !== 'string' || !/^[0-9]+$/.test(tok)) {
throw new RmlError(
'E035',
`Define declaration for "${name}": measure slot must be a positive integer`,
);
}
const slot = parseInt(tok, 10);
if (slot < 1) {
throw new RmlError(
'E035',
`Define declaration for "${name}": measure slot must be a positive integer (got ${slot})`,
);
}
slots.push(slot - 1); // store 0-based for direct array indexing
}
measure = { kind: 'lex', slots };
continue;
}
if (Array.isArray(child) && child[0] === 'case') {
if (child.length !== 3) {
throw new RmlError(
'E035',
`Define declaration for "${name}": \`(case <pattern-args> <body>)\` clause must have exactly two children`,
);
}
const patternArgs = child[1];
if (!Array.isArray(patternArgs)) {
throw new RmlError(
'E035',
`Define declaration for "${name}": \`(case ...)\` pattern must be a parenthesised argument list`,
);
}
clauses.push({ pattern: patternArgs, body: child[2] });
continue;
}
throw new RmlError(
'E035',
`Define declaration for "${name}": unexpected clause \`${keyOf(child)}\` (expected \`(measure ...)\` or \`(case ...)\`)`,
);
}
if (clauses.length === 0) {
throw new RmlError(
'E035',
`Define declaration for "${name}" must list at least one \`(case ...)\` clause`,
);
}
return { name, measure, clauses };
}
// Verify a single recursive call's arguments against the matching clause's
// pattern arguments. Returns null on success, or an object describing why
// the call cannot be accepted as decreasing.
function checkDefineDecrease(call, patternArgs, measure, defName) {
const callArgs = call.slice(1);
if (callArgs.length !== patternArgs.length) {
return {
reason: `recursive call \`${keyOf(call)}\` has ${callArgs.length} argument${callArgs.length === 1 ? '' : 's'}, clause pattern declares ${patternArgs.length}`,
};
}
if (measure && measure.kind === 'lex') {
for (const slot of measure.slots) {
if (slot >= patternArgs.length) {
return {
reason: `measure slot ${slot + 1} is out of range for ${patternArgs.length}-argument clause`,
};
}
}
// Lexicographic check: find the first slot where call < pattern; earlier
// slots must be structurally identical to the corresponding pattern.
for (const slot of measure.slots) {
const callArg = callArgs[slot];
const patArg = patternArgs[slot];
if (isStrictSubterm(callArg, patArg)) {
return null; // strict decrease at this slot — earlier slots already equal
}
if (!isStructurallySame(callArg, patArg)) {
// Neither equal nor strictly smaller → no further slot can rescue it.
return {
reason: `recursive call \`${keyOf(call)}\` does not lexicographically decrease the declared measure`,
};
}
}
return {
reason: `recursive call \`${keyOf(call)}\` does not lexicographically decrease the declared measure`,
};
}
// Default: structural decrease on the first argument.
if (patternArgs.length === 0) {
return {
reason: `definition "${defName}" has no arguments, so structural decrease is unverifiable`,
};
}
if (isStrictSubterm(callArgs[0], patternArgs[0])) {
return null;
}
return {
reason: `recursive call \`${keyOf(call)}\` does not structurally decrease the first argument of \`${keyOf([defName, ...patternArgs])}\``,
};
}
// Public-facing termination checker for `(define ...)` declarations.
// Mirrors the shape of `isTotal`. Returns `{ ok, diagnostics }`. Each
// diagnostic uses code `E035`.
function isTerminating(env, defName) {
const diagnostics = [];
const decl = env.definitions.get(defName);
if (!decl) {
diagnostics.push({
code: 'E035',
message: `Termination check for "${defName}": no \`(define ${defName} ...)\` declaration found`,
});
return { ok: false, diagnostics };
}
for (let ci = 0; ci < decl.clauses.length; ci++) {
const clause = decl.clauses[ci];
const calls = collectRecursiveCalls(clause.body, defName, false);
for (const call of calls) {
const witness = checkDefineDecrease(call, clause.pattern, decl.measure, defName);
if (witness) {
diagnostics.push({
code: 'E035',
message: `Termination check for "${defName}": clause ${ci + 1} \`${keyOf(['case', clause.pattern, clause.body])}\` — ${witness.reason}`,
});
}
}
}
return { ok: diagnostics.length === 0, diagnostics };
}
// ---------- Coverage checking (issue #46, D14) ----------
// `(coverage <name>)` verifies that, for every `+input` slot of relation
// `<name>`, the union of clause patterns at that slot exhausts every
// constructor of the slot's inductive type. Variables (lowercase names not
// resolvable in the env) act as wildcards covering all constructors. When
// a constructor is missing, an `E037` diagnostic is emitted with an example
// pattern such as `(succ _)`.
//
// The same `isCovered(env, name)` helper is exported for programmatic use.
// Slots whose inductive type cannot be inferred (no concrete constructor
// appears anywhere in the patterns at that slot) are skipped — coverage
// is opt-in per slot, just like world checking is opt-in per relation.
// Find the inductive type whose declared constructors include `ctorName`.
// Returns the type name string, or null when the constructor is unknown.
function inductiveTypeOfConstructor(env, ctorName) {
for (const [typeName, decl] of env.inductives) {
for (const ctor of decl.constructors) {
if (ctor.name === ctorName) return typeName;
}
}
return null;
}
// True when `pat` is a wildcard at this slot — i.e. a bare lowercase symbol
// that is not a registered constructor or other named term in the env.
function isWildcardPattern(pat, env) {
if (typeof pat !== 'string') return false;
if (isNum(pat)) return false;
if (NON_VARIABLE_TOKENS.has(pat)) return false;
if (inductiveTypeOfConstructor(env, pat) !== null) return false;
return true;
}
// Extract the constructor name a pattern matches, or null when the pattern
// is a wildcard / does not pin a constructor.
function patternConstructorHead(pat, env) {
if (typeof pat === 'string') {
if (inductiveTypeOfConstructor(env, pat) !== null) return pat;
return null;
}
if (Array.isArray(pat) && pat.length >= 1 && typeof pat[0] === 'string') {
if (inductiveTypeOfConstructor(env, pat[0]) !== null) return pat[0];
}
return null;
}
// Infer the inductive type at a single slot by examining every clause's
// pattern at that position. The first clause whose pattern names a
// constructor wins. Returns null when no clause pins a concrete type.
function inferSlotType(env, clauses, slotIndex) {
for (const clause of clauses) {
const pat = clause[slotIndex + 1];
const head = patternConstructorHead(pat, env);
if (head !== null) return inductiveTypeOfConstructor(env, head);
}
return null;
}
// Render a placeholder pattern for a constructor — `zero` for a constant
// constructor, `(succ _)` for a constructor with parameters.
function exampleConstructorPattern(ctor) {
if (ctor.params.length === 0) return ctor.name;
return `(${ctor.name}${' _'.repeat(ctor.params.length)})`;
}
function isCovered(env, relName) {
const diagnostics = [];
const clauses = env.relations.get(relName);
const flags = env.modes.get(relName);
if (!flags) {
diagnostics.push({
code: 'E037',
message: `Coverage check for "${relName}": no \`(mode ${relName} ...)\` declaration found`,
});
return { ok: false, diagnostics };
}
if (!clauses || clauses.length === 0) {
diagnostics.push({
code: 'E037',
message: `Coverage check for "${relName}": no \`(relation ${relName} ...)\` clauses found`,
});
return { ok: false, diagnostics };
}
for (let i = 0; i < flags.length; i++) {
if (flags[i] !== 'in') continue;
const slotPatterns = clauses.map(c => c[i + 1]);
if (slotPatterns.some(pat => isWildcardPattern(pat, env))) continue;
const typeName = inferSlotType(env, clauses, i);
if (typeName === null) continue;
const decl = env.inductives.get(typeName);
if (!decl) continue;
const covered = new Set();
for (const pat of slotPatterns) {
const head = patternConstructorHead(pat, env);
if (head !== null) covered.add(head);
}
const missing = decl.constructors.filter(c => !covered.has(c.name));
if (missing.length === 0) continue;
const examples = missing.map(exampleConstructorPattern).join(', ');
diagnostics.push({
code: 'E037',
message: `Coverage check for "${relName}": +input slot ${i + 1} (type "${typeName}") missing case${missing.length === 1 ? '' : 's'} for constructor${missing.length === 1 ? '' : 's'} ${examples}`,
});
}
return { ok: diagnostics.length === 0, diagnostics };
}
// ---------- World declarations (issue #54, D16) ----------
// `(world plus (Natural))` records the allow-list of constants permitted
// to appear free in arguments to relation `plus`. The world checker
// rejects relation calls whose arguments contain any other free constant
// with a structured `E034` diagnostic. Relations without a recorded
// world are unconstrained — the feature is opt-in per relation.
function parseWorldForm(node) {
if (!Array.isArray(node) || node[0] !== 'world') return null;
if (node.length < 2 || typeof node[1] !== 'string') {
throw new RmlError('E034', 'World declaration: relation name must be a bare symbol');
}
const name = node[1];
if (node.length !== 3 || !Array.isArray(node[2])) {
throw new RmlError(
'E034',
`World declaration for "${name}" must have shape \`(world ${name} (<const>...))\``,
);
}
const allowed = [];
for (const item of node[2]) {
if (typeof item !== 'string') {
throw new RmlError(
'E034',
`World declaration for "${name}": each allowed constant must be a bare symbol`,
);
}
allowed.push(item);
}
return { name, allowed };
}
// Walk an argument expression and collect every free constant — i.e.
// every leaf symbol that is not numeric, not a reserved keyword, and is
// not bound by an enclosing `lambda`/`Pi`/`fresh` binder appearing
// inside the same argument. The collected names are matched against the
// world's `allowed` list to surface E034 violations.
function collectFreeConstants(node, bound, out) {
if (typeof node === 'string') {
if (isNum(node)) return;
if (NON_VARIABLE_TOKENS.has(node)) return;
if (bound.has(node)) return;
if (!out.includes(node)) out.push(node);
return;
}
if (!Array.isArray(node)) return;
if (node.length >= 3 && (node[0] === 'lambda' || node[0] === 'Pi')
&& Array.isArray(node[1]) && node[1].length === 2 && typeof node[1][1] === 'string') {
const ty = node[1][0];
if (typeof ty === 'string') {
if (!isNum(ty) && !NON_VARIABLE_TOKENS.has(ty) && !bound.has(ty) && !out.includes(ty)) {
out.push(ty);
}
} else {
collectFreeConstants(ty, bound, out);
}
const variable = node[1][1];
const wasBound = bound.has(variable);
bound.add(variable);
for (let i = 2; i < node.length; i++) {
collectFreeConstants(node[i], bound, out);
}
if (!wasBound) bound.delete(variable);
return;
}
if (node.length === 4 && node[0] === 'fresh' && node[2] === 'in' && typeof node[1] === 'string') {
const variable = node[1];
const wasBound = bound.has(variable);
bound.add(variable);
collectFreeConstants(node[3], bound, out);
if (!wasBound) bound.delete(variable);
return;
}
for (const child of node) {
collectFreeConstants(child, bound, out);
}
}
// Validate a relation call's arguments against its world declaration.
// Returns the offending RmlError, or `null` when the call is consistent
// (or no declaration exists).
function checkWorldAtCall(name, args, env) {
const allowed = env.worlds.get(name);
if (!allowed) return null;
const violations = [];
for (const arg of args) {
const bound = new Set();
const found = [];
collectFreeConstants(arg, bound, found);
for (const sym of found) {
if (sym === name) continue;
if (allowed.includes(sym)) continue;
if (!violations.includes(sym)) violations.push(sym);
}
}
if (violations.length === 0) return null;
const listed = violations.map(s => `"${s}"`).join(', ');
return new RmlError(
'E034',
`World violation: "${name}" argument contains free constant${violations.length === 1 ? '' : 's'} ${listed} not in declared world`,
);
}
// ---------- Inductive declarations (issue #45, D10) ----------
// `(inductive Name (constructor c1) (constructor (c2 (Pi (A x) ... Name))) ...)`
// declares a first-class inductive datatype encoded as link signatures plus
// a generated eliminator `Name-rec`. The declaration:
//
// 1. registers `Name : (Type 0)` as a typed term;
// 2. installs every constructor — bare constants get type `Name`, while
// constructors written as `(c (Pi (A x) ... Name))` keep their Pi-type
// so existing `(type of c)` and `(c of (Pi ...))` queries succeed;
// 3. synthesises the eliminator `Name-rec` and its dependent Pi-type from
// the declared constructors, mirroring the standard induction principle:
// Name-rec : (Pi (motive (Pi (Name _) (Type 0)))
// (Pi (case_c1 (apply motive c1))
// ...
// (Pi (case_cN (... step type ...))
// (Pi (target Name) (apply motive target)))))
// Each step type for a constructor with one or more recursive `Name`
// arguments includes one inductive-hypothesis premise `(apply motive arg)`
// per recursive position.
// 4. records the inductive declaration on `env.inductives` for tooling.
//
// The declaration intentionally only requires a Pi-typed signature where
// every parameter type is either a previously declared type (acceptable as
// a non-recursive constructor argument) or `Name` itself (a recursive
// position used to synthesise the inductive hypothesis). Strict positivity
// beyond this syntactic check is out of scope (see Out of Scope in #45).
function _isPiSig(node) {
return Array.isArray(node) && node.length === 3 && node[0] === 'Pi';
}
// Walk a (Pi (A x) (Pi (B y) ... R)) chain into an array of binder pairs and
// the final result. Returns `null` when the shape is malformed.
function _flattenPi(typeNode) {
const params = [];
let current = typeNode;
while (_isPiSig(current)) {
const binding = parseBinding(current[1]);
if (!binding) return null;
params.push({ name: binding.paramName, type: binding.paramType });
current = current[2];
}
return { params, result: current };
}
// Build a chain of nested Pi nodes from a list of `{ name, type }` parameters
// and a final result node. With an empty parameter list returns the result
// untouched, so callers can fold a single parameter list into a Pi-type
// without special-casing.
function _buildPi(params, result) {
let out = result;
for (let i = params.length - 1; i >= 0; i--) {
const p = params[i];
out = ['Pi', [p.type, p.name], out];
}
return out;
}
// Parse a single (constructor ...) clause into `{ name, params, type }`.
// Accepts the two surface shapes:
// - `(constructor c)` — bare constant constructor of type `Name`.
// - `(constructor (c (Pi ...)))` — constructor with a Pi-typed signature.
function parseConstructorClause(clause, typeName) {
if (!Array.isArray(clause) || clause[0] !== 'constructor' || clause.length !== 2) {
throw new RmlError(
'E033',
`Inductive declaration for "${typeName}": each clause must be \`(constructor <name>)\` or \`(constructor (<name> <pi-type>))\``,
);
}
const body = clause[1];
if (typeof body === 'string') {
return { name: body, params: [], type: typeName };
}
if (Array.isArray(body) && body.length === 2 && typeof body[0] === 'string' && _isPiSig(body[1])) {
const flat = _flattenPi(body[1]);
if (!flat) {
throw new RmlError(
'E033',
`Inductive declaration for "${typeName}": constructor "${body[0]}" has malformed Pi-type \`${keyOf(body[1])}\``,
);
}
if (typeof flat.result !== 'string' || flat.result !== typeName) {
throw new RmlError(
'E033',
`Inductive declaration for "${typeName}": constructor "${body[0]}" must return "${typeName}" (got "${typeof flat.result === 'string' ? flat.result : keyOf(flat.result)}")`,
);
}
return { name: body[0], params: flat.params, type: body[1] };
}
throw new RmlError(
'E033',
`Inductive declaration for "${typeName}": malformed constructor clause \`${keyOf(clause)}\``,
);
}
function parseInductiveForm(node) {
if (!Array.isArray(node) || node[0] !== 'inductive') return null;
if (node.length < 2 || typeof node[1] !== 'string') {
throw new RmlError('E033', 'Inductive declaration: type name must be a bare symbol');
}
const name = node[1];
if (!/^[A-Z]/.test(name)) {
throw new RmlError(
'E033',
`Inductive declaration for "${name}": type name must start with an uppercase letter`,
);
}
if (node.length < 3) {
throw new RmlError(
'E033',
`Inductive declaration for "${name}" must list at least one constructor`,
);
}
const constructors = [];
const seen = new Set();
for (let i = 2; i < node.length; i++) {
const ctor = parseConstructorClause(node[i], name);
if (seen.has(ctor.name)) {
throw new RmlError(
'E033',
`Inductive declaration for "${name}": constructor "${ctor.name}" is declared more than once`,
);
}
seen.add(ctor.name);
constructors.push(ctor);
}
return { name, constructors };
}
// Build the case (step) type for one constructor under the eliminator's
// motive `m`. For a constructor `c : (Pi (A1 x1) ... (Pi (Ak xk) Name))`
// the case type is:
//
// (Pi (A1 x1) ... (Pi (Ak xk)
// (Pi (ih_j1 (apply m xj1)) ... (Pi (ih_jr (apply m xjr))
// (apply m (c x1 ... xk)))))
//
// where `xj1..xjr` are the parameters whose declared type is `Name` (i.e.
// the recursive arguments). Constant constructors degenerate to
// `(apply m c)`.
function _buildCaseType(ctor, typeName, motiveVar) {
const recBinders = [];
for (let i = 0; i < ctor.params.length; i++) {
const p = ctor.params[i];
if (typeof p.type === 'string' && p.type === typeName) {
recBinders.push({
name: `ih_${p.name}`,
type: ['apply', motiveVar, p.name],
});
}
}
let ctorApplied;
if (ctor.params.length === 0) {
ctorApplied = ctor.name;
} else {
ctorApplied = [ctor.name, ...ctor.params.map(p => p.name)];
}
const motiveOnTarget = ['apply', motiveVar, ctorApplied];
const inner = _buildPi(recBinders, motiveOnTarget);
return _buildPi(ctor.params, inner);
}
// Compose the dependent eliminator type for `Name-rec`, given the parsed
// inductive declaration. The motive parameter binds the symbol `_motive`
// throughout, and each constructor case parameter binds `case_<ctorName>`.
function buildEliminatorType(decl) {
const motiveVar = '_motive';
const motiveType = ['Pi', [decl.name, '_'], ['Type', '0']];
const caseParams = decl.constructors.map(c => ({
name: `case_${c.name}`,
type: _buildCaseType(c, decl.name, motiveVar),
}));
const targetVar = '_target';
const final = ['apply', motiveVar, targetVar];
const inner = _buildPi([{ name: targetVar, type: decl.name }], final);
const withCases = _buildPi(caseParams, inner);
return _buildPi([{ name: motiveVar, type: motiveType }], withCases);
}
// Record an inductive declaration on the environment: install the type, all
// constructors, the eliminator name, and the eliminator's Pi-type.
function registerInductive(env, decl) {
const storeType = env.qualifyName(decl.name);
env.terms.add(storeType);
env.setType(storeType, ['Type', '0']);
evalNode(['Type', '0'], env);
for (const ctor of decl.constructors) {
const storeName = env.qualifyName(ctor.name);
env.terms.add(storeName);
env.setType(storeName, ctor.type);
if (Array.isArray(ctor.type)) evalNode(ctor.type, env);
}
const elimName = `${decl.name}-rec`;
const elimType = buildEliminatorType(decl);
const storeElim = env.qualifyName(elimName);
env.terms.add(storeElim);
env.setType(storeElim, elimType);
evalNode(elimType, env);
env.inductives.set(decl.name, {
name: decl.name,
constructors: decl.constructors,
elimName,
elimType,
});
return 1;
}
// ---------- Coinductive declarations (issue #53, D11) ----------
// `(coinductive Name (constructor c1) (constructor (c2 (Pi (A x) ... Name))) ...)`
// declares a first-class coinductive datatype encoded as link signatures plus
// a generated corecursor `Name-corec`. The declaration mirrors `inductive`
// but additionally enforces a syntactic *productivity* check:
//
// - At least one constructor must take a recursive `Name` argument.
//
// The check captures the essential dual of the inductive case: an inductive
// type with no recursive constructors is just a finite enumeration and works
// fine; a coinductive type with no recursive constructors cannot generate
// any infinite value, so corecursive definitions over it can never make
// progress (i.e. they are non-productive). Declarations failing this check
// raise `E036`.
//
// The generated corecursor `Name-corec` follows the standard coiteration
// principle. For a state type `X`, each constructor case takes the seed
// state and produces the constructor's argument list with recursive `Name`
// positions replaced by `X` (the next-state slot):
//
// Name-corec : (Pi (X (Type 0))
// (Pi (case_c1 (Pi (X _state) <c1 sig with Name → X in args, Name in result>))
// ...
// (Pi (case_cN ...)
// (Pi (_seed X) Name))))
//
// For a constant constructor (no parameters) the case degenerates to
// `(Pi (X _state) Name)`. The corecursor type participates in the
// bidirectional checker just like any other typed term.
// Walk a constructor's parameter list and return the indices whose declared
// type is exactly the inductive type name (the recursive positions). Used
// both by the productivity check and by corecursor-type generation.
function _recursiveParamIndices(ctor, typeName) {
const indices = [];
for (let i = 0; i < ctor.params.length; i++) {
const p = ctor.params[i];
if (typeof p.type === 'string' && p.type === typeName) indices.push(i);
}
return indices;
}
// Build the case (step) type for one constructor under the corecursor's
// state variable `X`. For `c : (Pi (A1 x1) ... (Pi (Ak xk) Name))` the case
// type is:
//
// (Pi (X _state) (Pi (A1' x1) ... (Pi (Ak' xk) Name)))
//
// where each `Ai' = X` if the original `Ai = Name` (recursive position) and
// otherwise `Ai' = Ai`. A constant constructor degenerates to
// `(Pi (X _state) Name)`.
function _buildCorecCaseType(ctor, typeName, stateVar) {
const dualParams = ctor.params.map(p => ({
name: p.name,
type: (typeof p.type === 'string' && p.type === typeName) ? stateVar : p.type,
}));
const inner = _buildPi(dualParams, typeName);
return _buildPi([{ name: '_state', type: stateVar }], inner);
}
// Compose the dependent corecursor type for `Name-corec`, given the parsed
// coinductive declaration. The state parameter binds the symbol `_state`
// throughout, and each constructor case parameter binds `case_<ctorName>`.
function buildCorecursorType(decl) {
const stateVar = '_state_type';
const stateType = ['Type', '0'];
const caseParams = decl.constructors.map(c => ({
name: `case_${c.name}`,
type: _buildCorecCaseType(c, decl.name, stateVar),
}));
const seedVar = '_seed';
const final = decl.name;
const inner = _buildPi([{ name: seedVar, type: stateVar }], final);
const withCases = _buildPi(caseParams, inner);
return _buildPi([{ name: stateVar, type: stateType }], withCases);
}
function parseCoinductiveForm(node) {
if (!Array.isArray(node) || node[0] !== 'coinductive') return null;
if (node.length < 2 || typeof node[1] !== 'string') {
throw new RmlError('E036', 'Coinductive declaration: type name must be a bare symbol');
}
const name = node[1];
if (!/^[A-Z]/.test(name)) {
throw new RmlError(
'E036',
`Coinductive declaration for "${name}": type name must start with an uppercase letter`,
);
}
if (node.length < 3) {
throw new RmlError(
'E036',
`Coinductive declaration for "${name}" must list at least one constructor`,
);
}
const constructors = [];
const seen = new Set();
for (let i = 2; i < node.length; i++) {
const ctor = parseConstructorClauseCo(node[i], name);
if (seen.has(ctor.name)) {
throw new RmlError(
'E036',
`Coinductive declaration for "${name}": constructor "${ctor.name}" is declared more than once`,
);
}
seen.add(ctor.name);
constructors.push(ctor);
}
// Productivity check (guarded corecursion): at least one constructor must
// take a recursive `Name` argument so the type can generate progress.
const anyRecursive = constructors.some(c => _recursiveParamIndices(c, name).length > 0);
if (!anyRecursive) {
throw new RmlError(
'E036',
`Coinductive declaration for "${name}" is non-productive: at least one constructor must take a recursive "${name}" argument`,
);
}
return { name, constructors };
}
// Parse a single (constructor ...) clause for a coinductive declaration.
// Identical shape rules as the inductive form, but errors are reported with
// E036 so coinductive-specific failures stay distinguishable from E033.
function parseConstructorClauseCo(clause, typeName) {
if (!Array.isArray(clause) || clause[0] !== 'constructor' || clause.length !== 2) {
throw new RmlError(
'E036',
`Coinductive declaration for "${typeName}": each clause must be \`(constructor <name>)\` or \`(constructor (<name> <pi-type>))\``,
);
}
const body = clause[1];
if (typeof body === 'string') {
return { name: body, params: [], type: typeName };
}
if (Array.isArray(body) && body.length === 2 && typeof body[0] === 'string' && _isPiSig(body[1])) {
const flat = _flattenPi(body[1]);
if (!flat) {
throw new RmlError(
'E036',
`Coinductive declaration for "${typeName}": constructor "${body[0]}" has malformed Pi-type \`${keyOf(body[1])}\``,
);
}
if (typeof flat.result !== 'string' || flat.result !== typeName) {
throw new RmlError(
'E036',
`Coinductive declaration for "${typeName}": constructor "${body[0]}" must return "${typeName}" (got "${typeof flat.result === 'string' ? flat.result : keyOf(flat.result)}")`,
);
}
return { name: body[0], params: flat.params, type: body[1] };
}
throw new RmlError(
'E036',
`Coinductive declaration for "${typeName}": malformed constructor clause \`${keyOf(clause)}\``,
);
}
// Record a coinductive declaration on the environment: install the type,
// all constructors, the corecursor name, and the corecursor's Pi-type.
function registerCoinductive(env, decl) {
const storeType = env.qualifyName(decl.name);
env.terms.add(storeType);
env.setType(storeType, ['Type', '0']);
evalNode(['Type', '0'], env);
for (const ctor of decl.constructors) {
const storeName = env.qualifyName(ctor.name);
env.terms.add(storeName);
env.setType(storeName, ctor.type);
if (Array.isArray(ctor.type)) evalNode(ctor.type, env);
}
const corecName = `${decl.name}-corec`;
const corecType = buildCorecursorType(decl);
const storeCorec = env.qualifyName(corecName);
env.terms.add(storeCorec);
env.setType(storeCorec, corecType);
evalNode(corecType, env);
env.coinductives.set(decl.name, {
name: decl.name,
constructors: decl.constructors,
corecName,
corecType,
});
return 1;
}
// Check a call site `(name args...)` against any registered mode declaration
// for `name`. Returns the offending RmlError on mismatch, or `null` when the
// call is consistent (or no declaration exists).
function checkModeAtCall(name, args, env) {
const flags = env.modes.get(name);
if (!flags) return null;
if (args.length !== flags.length) {
return new RmlError(
'E031',
`Mode mismatch for "${name}": expected ${flags.length} argument${flags.length === 1 ? '' : 's'}, got ${args.length}`,
);
}
for (let i = 0; i < flags.length; i++) {
if (flags[i] === 'in' && !isGroundForMode(args[i], env)) {
return new RmlError(
'E031',
`Mode mismatch for "${name}": argument ${i + 1} (+input) is not ground`,
);
}
}
return null;
}
function contextHasName(env, name) {
if (env.terms.has(name) || env.types.has(name) || env.lambdas.has(name) || env.symbolProb.has(name) || env.ops.has(name)) {
return true;
}
const resolved = env._resolveQualified(name);
return resolved !== name && (
env.terms.has(resolved) ||
env.types.has(resolved) ||
env.lambdas.has(resolved) ||
env.symbolProb.has(resolved) ||
env.ops.has(resolved)
);
}
function evalFresh(varName, body, env) {
if (contextHasName(env, varName)) {
throw new RmlError('E010', `fresh variable "${varName}" already appears in context`);
}
const hadTerm = env.terms.has(varName);
const hadType = env.types.has(varName);
const previousType = env.types.get(varName);
const hadLambda = env.lambdas.has(varName);
const previousLambda = env.lambdas.get(varName);
const hadSymbol = env.symbolProb.has(varName);
const previousSymbol = env.symbolProb.get(varName);
env.terms.add(varName);
try {
return evalNode(body, env);
} finally {
if (!hadTerm) env.terms.delete(varName);
if (hadType) env.types.set(varName, previousType);
else env.types.delete(varName);
if (hadLambda) env.lambdas.set(varName, previousLambda);
else env.lambdas.delete(varName);
if (hadSymbol) env.symbolProb.set(varName, previousSymbol);
else env.symbolProb.delete(varName);
}
}
function decideAutomaticSequenceTheorem(name) {
if (name === 'thue-morse-cube-free') {
return {
theorem: name,
value: true,
method: 'built-in Buchi emptiness certificate',
certificate: ['buchi-emptiness', 'thue-morse', 'cube-free'],
};
}
return null;
}
function automaticSequencesDomainPlugin(forms, env) {
if (!Array.isArray(forms) || forms.length === 0) {
throw new RmlError('E041', 'automatic-sequences domain requires at least one request');
}
for (const form of forms) {
if (!Array.isArray(form) || form.length !== 2 || form[0] !== 'theorem' || typeof form[1] !== 'string') {
throw new RmlError('E041', 'automatic-sequences entries must be `(theorem <name>)`');
}
const decision = decideAutomaticSequenceTheorem(form[1]);
if (!decision) {
throw new RmlError('E041', `unknown automatic-sequences theorem "${form[1]}"`);
}
const storeName = env.qualifyName(decision.theorem);
const truthValue = decision.value ? env.hi : env.lo;
env.terms.add(storeName);
env.setType(storeName, 'Theorem');
env.setSymbolProb(storeName, truthValue);
env.automaticSequenceDecisions.set(storeName, {
...decision,
theorem: storeName,
truthValue,
});
env.trace('domain', `${storeName} decided by automatic-sequences`);
}
return 1;
}
function evalDomainForm(node, env) {
if (node.length < 3 || typeof node[1] !== 'string') {
throw new RmlError('E041', 'Domain form must be `(domain <name> <request>...)`');
}
const pluginName = node[1];
const plugin = env.getDomainPlugin(pluginName);
if (!plugin) {
throw new RmlError('E041', `Unknown domain plugin "${pluginName}"`);
}
return plugin(node.slice(2), env);
}
function evalNode(node, env){
if (typeof node === 'string') {
if (isNum(node)) return env.toNum(node);
// bare symbol → optional prior probability if set; otherwise irrelevant in calc
return env.getSymbolProb(node);
}
// HOAS desugaring (issue #51, D7): rewrite `(forall (A x) body)` to
// `(Pi (A x) body)` so callers passing AST nodes directly to `evalNode`
// benefit from the same surface as `evaluate()` / `parseLinoForms`. The
// recursive walk also handles `forall` nested inside definition RHSs such
// as `(succ: (forall (Natural n) Natural))`.
if (Array.isArray(node)) {
node = desugarHoas(node);
}
// Definitions & operator redefs: (head: ...)
if (typeof node[0] === 'string' && node[0].endsWith(':')) {
const head = node[0].slice(0,-1);
return defineForm(head, node.slice(1), env);
}
// Note: (x : A) with spaces as a standalone colon separator is NOT supported.
// Use (x: A) instead — the colon must be part of the link name.
// Mode declaration (issue #43, D15): (mode <name> +input -output ...)
// Records the per-argument mode pattern for a relation. Validation lives
// in `parseModeForm`, which throws `E030` on a malformed declaration.
if (node[0] === 'mode') {
const decl = parseModeForm(node);
if (decl) {
env.modes.set(decl.name, decl.flags);
return 1;
}
}
// Relation declaration (issue #44, D12): (relation <name> <clause>...)
// Stores the clause list keyed by relation name. `parseRelationForm`
// throws E032 on a malformed declaration so the call sites do not have
// to handle absent or shape-broken clauses defensively.
if (node[0] === 'relation') {
const decl = parseRelationForm(node);
if (decl) {
env.relations.set(decl.name, decl.clauses);
return 1;
}
}
// World declaration (issue #54, D16): (world <name> (<const>...))
// Records the allow-list of constants permitted to appear free in
// arguments of a relation. `parseWorldForm` throws E034 on a
// malformed declaration so the call sites do not have to handle
// shape-broken declarations defensively.
if (node[0] === 'world') {
const decl = parseWorldForm(node);
if (decl) {
env.worlds.set(decl.name, decl.allowed);
return 1;
}
}
// Inductive declaration (issue #45, D10): (inductive Name (constructor ...) ...)
// Records the inductive datatype, installs every constructor, and
// generates the eliminator `Name-rec` with a dependent Pi-type.
// `parseInductiveForm` throws E033 on a malformed declaration.
if (node[0] === 'inductive') {
const decl = parseInductiveForm(node);
if (decl) {
return registerInductive(env, decl);
}
}
// Coinductive declaration (issue #53, D11): (coinductive Name (constructor ...) ...)
// Records the coinductive datatype, installs every constructor, and
// generates the corecursor `Name-corec` with a dependent Pi-type. The
// declaration also enforces a syntactic productivity check (at least one
// constructor must take a recursive argument). `parseCoinductiveForm`
// throws E036 on a malformed or non-productive declaration.
if (node[0] === 'coinductive') {
const decl = parseCoinductiveForm(node);
if (decl) {
return registerCoinductive(env, decl);
}
}
// Domain plugin driver (issue #63): (domain <name> <request>...)
// Dispatches the block body to a registered domain-specific decision
// procedure. The default Env registers `automatic-sequences`.
if (node[0] === 'domain') {
return evalDomainForm(node, env);
}
// Totality check (issue #44, D12): (total <name>) runs `isTotal` over
// the recorded relation and turns each returned diagnostic into an
// E032 RmlError. The first error short-circuits, mirroring how the
// mode checker surfaces a single failure per evaluator step.
if (node[0] === 'total' && node.length === 2 && typeof node[1] === 'string') {
const result = isTotal(env, node[1]);
if (!result.ok && result.diagnostics.length > 0) {
const first = result.diagnostics[0];
throw new RmlError(first.code || 'E032', first.message);
}
return 1;
}
if (node[0] === 'total') {
throw new RmlError('E032', 'Totality declaration must be `(total <relation-name>)`');
}
// Definition declaration (issue #49, D13): (define <name> [(measure ...)] (case ...) ...)
// Records the definition on `env.definitions` so termination can be
// queried later via `isTerminating` or via the `(terminating <name>)`
// driver form. Malformed declarations raise E035 from the parser.
if (node[0] === 'define') {
const decl = parseDefineForm(node);
if (decl) {
env.definitions.set(decl.name, decl);
return 1;
}
}
// Termination check (issue #49, D13): (terminating <name>) runs
// `isTerminating` and surfaces the first diagnostic via the existing
// diagnostic pipeline.
if (node[0] === 'terminating' && node.length === 2 && typeof node[1] === 'string') {
const result = isTerminating(env, node[1]);
if (!result.ok && result.diagnostics.length > 0) {
const first = result.diagnostics[0];
throw new RmlError(first.code || 'E035', first.message);
}
return 1;
}
if (node[0] === 'terminating') {
throw new RmlError('E035', 'Termination declaration must be `(terminating <definition-name>)`');
}
// Coverage check (issue #46, D14): (coverage <name>) runs `isCovered`
// and surfaces every returned diagnostic. The first becomes the thrown
// RmlError so the surrounding form gets a diagnostic span; any extras
// are appended to `env._shadowDiagnostics` so each missing case (e.g.
// for a relation with multiple `+input` slots) reaches the user.
if (node[0] === 'coverage' && node.length === 2 && typeof node[1] === 'string') {
const result = isCovered(env, node[1]);
if (!result.ok && result.diagnostics.length > 0) {
const [first, ...rest] = result.diagnostics;
if (rest.length > 0 && Array.isArray(env._shadowDiagnostics)) {
for (const d of rest) {
env._shadowDiagnostics.push(new Diagnostic({
code: d.code || 'E037',
message: d.message,
span: env._currentSpan || null,
}));
}
}
throw new RmlError(first.code || 'E037', first.message);
}
return 1;
}
if (node[0] === 'coverage') {
throw new RmlError('E037', 'Coverage declaration must be `(coverage <relation-name>)`');
}
// Mode-mismatch check (issue #43, D15): a call `(name args...)` whose
// head has a registered mode declaration must agree with the declared
// flags. The check runs before the head's evaluation so the diagnostic
// points at the call rather than at a downstream beta-reduction.
if (typeof node[0] === 'string' && env.modes.has(node[0])) {
const err = checkModeAtCall(node[0], node.slice(1), env);
if (err) throw err;
}
// World-violation check (issue #54, D16): a call `(name args...)`
// whose head has a registered world declaration must only contain
// declared constants free in its arguments.
if (typeof node[0] === 'string' && env.worlds.has(node[0])) {
const err = checkWorldAtCall(node[0], node.slice(1), env);
if (err) throw err;
}
// Assignment: ((expr) has probability p)
if (node.length === 4 && node[1] === 'has' && node[2] === 'probability' && isNum(node[3])) {
const p = parseFloat(node[3]);
// Carrier enforcement (issue #97, Section 2): if an enclosing
// `(with-foundation ...)` declared a strict carrier, the assigned value
// must belong to that carrier. Violations surface as E063 instead of
// being silently clamped.
const carrierErr = env.checkCarrierValue(env.clamp(p));
if (carrierErr) {
throw new RmlError(
'E063',
`Probability assignment ${keyOf(node[0])} = ${formatTraceValue(env.clamp(p))} violates active foundation carrier: ${carrierErr}`,
);
}
env.setExprProb(node[0], p);
env.trace('assign', `${keyOf(node[0])} ← ${formatTraceValue(env.clamp(p))}`);
return env.toNum(node[3]);
}
// Range configuration: (range: lo hi) — sets the truth value range
// (range: 0 1) for standard [0,1] or (range: -1 1) for balanced [-1,1]
// See: https://en.wikipedia.org/wiki/Balanced_ternary
// Must be checked in evalNode for (range lo hi) prefix form
if (node.length === 3 && node[0] === 'range' && isNum(node[1]) && isNum(node[2])) {
env.lo = parseFloat(node[1]);
env.hi = parseFloat(node[2]);
// Re-initialize ops for new range
_reinitOps(env);
return 1;
}
// Valence configuration: (valence N) prefix form
if (node.length === 2 && node[0] === 'valence' && isNum(node[1])) {
env.valence = parseInt(node[1], 10);
return 1;
}
// Query: (? expr) with optional `with proof` suffix (issue #35).
// The suffix is a per-query opt-in for derivation output; the actual proof
// is built by `buildProof` in `evaluate()`. Stripping it here keeps the
// legacy evaluation path unchanged regardless of whether proofs are
// requested.
if (node[0] === '?') {
const parts = _stripWithProof(node.slice(1));
const target = parts.length === 1 ? parts[0] : parts;
const v = evalNode(target, env);
// If inner result is already a query (e.g. from (type of x)), pass it through
if (v && typeof v === 'object' && v.query) return v;
if (isTermResult(v)) return { query:true, value: keyOf(v.term), typeQuery: true };
return { query:true, value: env.clamp(v) };
}
// Kernel substitution primitive: (subst term x replacement)
if (node.length === 4 && node[0] === 'subst' && typeof node[2] === 'string') {
return { term: evalTermNode(node, env) };
}
// Weak-head normal form (issue #50, D4): (whnf expr) reduces only the
// spine of `expr` — leaves binders and arguments untouched. Returned as a
// term result so callers can keep reducing or print the form directly.
if (node.length === 2 && node[0] === 'whnf') {
return { term: whnfTerm(node[1], env) };
}
if (node[0] === 'whnf') {
throw new RmlError('E038', 'Normalization form must be `(whnf <expr>)`');
}
// Full normal form (issue #50, D4): (nf expr) and the long alias
// (normal-form expr). Returns the beta-normal form as a term result.
if (node.length === 2 && node[0] === 'nf') {
return { term: flattenNeutralApplies(normalizeTerm(node[1], env), env) };
}
if (node[0] === 'nf') {
throw new RmlError('E038', 'Normalization form must be `(nf <expr>)`');
}
if (node.length === 2 && node[0] === 'normal-form') {
return { term: flattenNeutralApplies(normalizeTerm(node[1], env), env) };
}
if (node[0] === 'normal-form') {
throw new RmlError('E038', 'Normalization form must be `(normal-form <expr>)`');
}
// Freshness binder: (fresh x in body)
if (node.length === 4 && node[0] === 'fresh' && node[2] === 'in' && typeof node[1] === 'string') {
return evalFresh(node[1], node[3], env);
}
// Infix arithmetic: (A + B), (A - B), (A * B), (A / B)
// Arithmetic uses raw numeric values (not clamped to the logic range)
if (node.length === 3 && typeof node[1] === 'string' && ['+','-','*','/'].includes(node[1])) {
const op = env.getOp(node[1]);
const L = evalArith(node[0], env);
const R = evalArith(node[2], env);
return op(L,R);
}
// Numeric comparisons: (A < B), (A <= B)
if (node.length === 3 && typeof node[1] === 'string' && ['<','<='].includes(node[1])) {
const op = env.getOp(node[1]);
const L = evalArith(node[0], env);
const R = evalArith(node[2], env);
return env.clamp(op(L,R));
}
// Infix AND/OR/BOTH/NEITHER: ((A) and (B)) / ((A) or (B)) / ((A) both (B)) / ((A) neither (B))
if (node.length === 3 && typeof node[1] === 'string' && (node[1]==='and' || node[1]==='or' || node[1]==='both' || node[1]==='neither')) {
const op = env.getOp(node[1]);
const L = evalNode(node[0], env);
const R = evalNode(node[2], env);
return env.clamp(op(L,R));
}
// Composite natural language operators: (both A and B [and C ...]), (neither A nor B [nor C ...])
if (node.length >= 4 && typeof node[0] === 'string' && (node[0]==='both' || node[0]==='neither')) {
const sep = node[0]==='both' ? 'and' : 'nor';
// Validate pattern: operator, value, sep, value [, sep, value ...]
let valid = node.length % 2 === 0; // both + (n values) + (n-1 seps) = 1 + n + (n-1) = 2n, always even
if (valid) {
for (let i = 2; i < node.length; i += 2) {
if (node[i] !== sep) { valid = false; break; }
}
}
if (valid) {
const op = env.getOp(node[0]);
const vals = [];
for (let i = 1; i < node.length; i += 2) {
vals.push(evalNode(node[i], env));
}
return env.clamp(op(...vals));
}
}
// Infix equality/inequality: (L = R), (L != R)
if (node.length === 3 && typeof node[1] === 'string' && (node[1]==='=' || node[1]==='!=')) {
return evalEqualityNode(node[0], node[1], node[2], env);
}
// ---------- Type System: "everything is a link" ----------
// Type universe: (Type N) — the sort at universe level N
if (node.length === 2 && node[0] === 'Type') {
const level = parseUniverseLevelToken(node[1]);
if (level === null) return 0;
// (Type N) has type (Type N+1)
env.setType(node, ['Type', String(level + 1)]);
return 1; // valid expression
}
// Prop: (Prop) is sugar for (Type 0) in the propositions-as-types interpretation
if (node.length === 1 && node[0] === 'Prop') {
env.setType(['Prop'], ['Type', '1']);
return 1;
}
// Dependent product (Pi-type): (Pi (A x) B) or (Pi (x: A) B)
if (node.length === 3 && node[0] === 'Pi') {
const binding = node[1];
const parsed = parseBinding(binding);
if (parsed) {
const { paramName, paramType } = parsed;
env.terms.add(paramName);
env.setType(paramName, paramType);
env.setType(node, ['Type', '0']);
}
return 1;
}
// Lambda abstraction: (lambda (A x) body) or (lambda (x: A) body)
// Also supports multi-param: (lambda (A x, B y) body)
if (node.length === 3 && node[0] === 'lambda') {
const binding = node[1];
const bindings = parseBindings(binding);
if (bindings && bindings.length > 0) {
// For single binding — standard case
const { paramName, paramType } = bindings[0];
const body = node[2];
env.terms.add(paramName);
env.setType(paramName, paramType);
// Register additional bindings
for (let i = 1; i < bindings.length; i++) {
env.terms.add(bindings[i].paramName);
env.setType(bindings[i].paramName, bindings[i].paramType);
}
const bodyType = env.getType(body);
const paramTypeKey = typeof paramType === 'string' ? paramType : keyOf(paramType);
const bodyTypeKey = bodyType || 'unknown';
env.setType(node, '(Pi (' + paramTypeKey + ' ' + paramName + ') ' + bodyTypeKey + ')');
}
return 1;
}
// Application: (apply f x) — explicit application with beta-reduction
if (node.length === 3 && node[0] === 'apply') {
const fn = node[1];
const arg = node[2];
// Check if fn is a lambda: (lambda (A x) body)
if (Array.isArray(fn) && fn.length === 3 && fn[0] === 'lambda') {
const parsed = parseBinding(fn[1]);
if (parsed) {
const body = fn[2];
const result = subst(body, parsed.paramName, arg);
return evalReducedTerm(result, env);
}
}
// Check if fn is a named lambda
if (typeof fn === 'string') {
const lambda = env.getLambda(fn);
if (lambda) {
const result = subst(lambda.body, lambda.param, arg);
return evalReducedTerm(result, env);
}
}
// Otherwise evaluate fn and arg normally
const fVal = evalNode(fn, env);
const aVal = evalNode(arg, env);
return typeof fVal === 'number' ? fVal : (fVal && fVal.value !== undefined ? fVal.value : 0);
}
// Type query: (type of expr) — returns the type of an expression
// e.g. (? (type of x)) → returns the type string
if (node.length === 3 && node[0] === 'type' && node[1] === 'of') {
const expr = node[2];
const typeStr = inferTypeKey(expr, env);
if (typeStr) {
return { query: true, value: typeStr, typeQuery: true };
}
return { query: true, value: 'unknown', typeQuery: true };
}
// Type check query: (expr of Type) — checks if expr has the given type
// e.g. (? (x of Natural)) → returns 1 or 0
if (node.length === 3 && node[1] === 'of') {
const expr = node[0];
const expectedType = node[2];
const actualType = inferTypeKey(expr, env);
if (actualType) {
const expectedKey = typeof expectedType === 'string' ? expectedType : keyOf(expectedType);
return actualType === expectedKey ? env.hi : env.lo;
}
return env.lo;
}
// Prefix: (not X), (and X Y ...), (or X Y ...)
const [head, ...args] = node;
if (typeof head === 'string' && (head === '=' || head === '!=') && args.length === 2) {
return evalEqualityNode(args[0], head, args[1], env);
}
if (typeof head === 'string' && env.hasOp(head)) {
const op = env.getOp(head);
const vals = args.map(a => evalNode(a, env));
return env.clamp(op(...vals));
}
// Fall through: prefix application (f x y ...) for named lambdas
if (typeof head === 'string' && args.length >= 1) {
const lambda = env.getLambda(head) || env.getLambda(env._resolveQualified(head));
if (lambda) {
// Apply first argument, then recursively apply rest
let result = subst(lambda.body, lambda.param, args[0]);
if (args.length === 1) {
return evalReducedTerm(result, env);
}
return evalReducedTerm([result, ...args.slice(1)], env);
}
}
// Prefix application with an inline lambda head: ((lambda (A x) body) arg)
if (Array.isArray(head) && head.length === 3 && head[0] === 'lambda' && args.length >= 1) {
const parsed = parseBinding(head[1]);
if (parsed) {
const result = subst(head[2], parsed.paramName, args[0]);
if (args.length === 1) return evalReducedTerm(result, env);
return evalReducedTerm([result, ...args.slice(1)], env);
}
}
return 0;
}
// Re-initialize default ops when range changes
function _reinitOps(env) {
env.ops.set('not', (x) => env.hi - (x - env.lo));
env.ops.set('and', (...xs) => xs.length ? xs.reduce((a,b)=>a+b,0)/xs.length : env.lo);
env.ops.set('or', (...xs) => xs.length ? Math.max(...xs) : env.lo);
env.ops.set('both', (...xs) => xs.length ? decRound(xs.reduce((a,b)=>a+b,0)/xs.length) : env.lo);
env.ops.set('neither', (...xs) => xs.length ? decRound(xs.reduce((a,b)=>a*b,1)) : env.lo);
env.ops.set('=', (L,R,ctx) => {
const kPrefix = keyOf(['=',L,R]);
if (env.assign.has(kPrefix)) {
const v = env.assign.get(kPrefix);
env.trace('lookup', `${kPrefix} → ${formatTraceValue(v)}`);
return v;
}
const kInfix = keyOf([L,'=',R]);
if (env.assign.has(kInfix)) {
const v = env.assign.get(kInfix);
env.trace('lookup', `${kInfix} → ${formatTraceValue(v)}`);
return v;
}
return isStructurallySame(L,R) ? env.hi : env.lo;
});
env.ops.set('!=', (...args) => env.getOp('not')( env.getOp('=')(...args) ));
env.ops.set('+', (a,b) => decRound(a + b));
env.ops.set('-', (a,b) => decRound(a - b));
env.ops.set('*', (a,b) => decRound(a * b));
env.ops.set('/', (a,b) => b === 0 ? 0 : decRound(a / b));
env.ops.set('<', (a,b) => a < b ? env.hi : env.lo);
env.ops.set('<=', (a,b) => a <= b ? env.hi : env.lo);
// Re-initialize truth constants for new range
env._initTruthConstants();
}
function defineForm(head, rhs, env){
// Configuration directives are file-level and never namespaced.
// Range configuration: (range: lo hi) — sets the truth value range
if (head === 'range' && rhs.length === 2 && isNum(rhs[0]) && isNum(rhs[1])) {
env.lo = parseFloat(rhs[0]);
env.hi = parseFloat(rhs[1]);
_reinitOps(env);
return 1;
}
// Valence configuration: (valence: N) — sets the number of truth values
// N=1: unary (trivial), N=2: binary (Boolean), N=3: ternary, N=0: continuous
if (head === 'valence' && rhs.length === 1 && isNum(rhs[0])) {
env.valence = parseInt(rhs[0], 10);
return 1;
}
// Bindings introduced inside `(namespace foo)` are stored under `foo.head`.
// The syntactic head (e.g. `a` in `(a: a is a)`) is still used to match
// patterns; only the storage key is qualified.
const storeName = env.qualifyName(head);
// Shadowing diagnostic (E008): if this name was already imported, warn.
if (storeName !== head || env.namespace === null) {
_maybeWarnShadow(env, storeName);
} else {
_maybeWarnShadow(env, head);
}
// Term definition: (a: a is a) → declare 'a' as a term (no probability assignment)
if (rhs.length === 3 && typeof rhs[0]==='string' && rhs[1]==='is' && typeof rhs[2]==='string' && rhs[0]===head && rhs[2]===head) {
env.terms.add(storeName);
return 1;
}
// Prefix type notation: (name: TypeName name) → typed self-referential declaration
// e.g. (zero: Natural zero), (boolean: Type boolean), (true: Boolean true)
if (rhs.length === 2 && typeof rhs[0] === 'string' && typeof rhs[1] === 'string' && rhs[1] === head) {
const typeName = rhs[0];
// Only if typeName starts with uppercase (type convention) and is not an operator
if (/^[A-Z]/.test(typeName)) {
env.terms.add(storeName);
env.setType(storeName, typeName);
return 1;
}
}
// Prefix type notation with complex type: (name: (Type 0) name) → typed self-referential declaration
if (rhs.length === 2 && Array.isArray(rhs[0]) && typeof rhs[1] === 'string' && rhs[1] === head) {
const typeExpr = rhs[0];
env.terms.add(storeName);
env.setType(storeName, typeExpr);
evalNode(typeExpr, env);
return 1;
}
// Typed declaration with complex type expression: (succ: (Pi (Natural n) Natural))
// Only complex expressions (arrays) are accepted as type annotations in single-element form.
// Simple name type annotations like (x: Natural) are NOT supported — use (x: Natural x) prefix form instead.
if (rhs.length === 1 && Array.isArray(rhs[0])) {
const isOp = ['=','!=','and','or','not','is','?:','both','neither'].includes(head) || /[=!]/.test(head);
if (!isOp) {
const typeExpr = rhs[0];
env.terms.add(storeName);
env.setType(storeName, typeExpr);
evalNode(typeExpr, env);
return 1;
}
}
// Optional symbol prior: (a: 0.7) — not required for your use-case, but allowed
if (rhs.length === 1 && isNum(rhs[0])) {
env.setSymbolProb(storeName, parseFloat(rhs[0]));
return env.toNum(rhs[0]);
}
// Operator redefinitions
if (['=','!=','and','or','not','is','?:','both','neither'].includes(head) || /[=!]/.test(head)) {
// Operator alias: `(not: not)` inside a namespace exports the existing
// operator under the qualified name, e.g. `classical.not`.
if (rhs.length === 1 && typeof rhs[0] === 'string' && env.hasOp(rhs[0])) {
const target = rhs[0];
const op = env.getOp(target);
env.defineOp(storeName, (...xs) => op(...xs));
env.trace('resolve', `(${storeName}: ${target})`);
return 1;
}
// Composition like: (!=: not =) or (=: =) (no-op)
if (rhs.length === 2 && typeof rhs[0]==='string' && typeof rhs[1]==='string') {
const outer = env.getOp(rhs[0]);
const inner = env.getOp(rhs[1]);
env.defineOp(storeName, (...xs) => env.clamp( outer( inner(...xs) ) ));
env.trace('resolve', `(${storeName}: ${rhs[0]} ${rhs[1]})`);
return 1;
}
// Aggregator selection: (and: avg|min|max|product|probabilistic_sum)
if ((head==='and' || head==='or' || head==='both' || head==='neither') && rhs.length===1 && typeof rhs[0]==='string') {
const sel = rhs[0];
const lo = env.lo;
const agg =
sel==='avg' ? xs=>xs.reduce((a,b)=>a+b,0)/xs.length :
sel==='min' ? xs=>xs.length? Math.min(...xs) : lo :
sel==='max' ? xs=>xs.length? Math.max(...xs) : lo :
sel==='product' || sel==='prod' ? xs=>xs.reduce((a,b)=>a*b,1) :
sel==='probabilistic_sum' || sel==='ps' ? xs=> 1 - xs.reduce((a,b)=>a*(1-b),1) : null;
if (!agg) throw new RmlError('E004', `Unknown aggregator "${sel}"`);
env.defineOp(storeName, (...xs)=> xs.length? agg(xs) : lo);
env.trace('resolve', `(${storeName}: ${sel})`);
return 1;
}
throw new RmlError('E003', `Unsupported operator definition for "${head}"`);
}
// Lambda definition: (name: lambda (A x) body)
if (rhs.length >= 2 && rhs[0] === 'lambda') {
if (rhs.length === 3 && Array.isArray(rhs[1])) {
const parsed = parseBinding(rhs[1]);
if (parsed) {
const { paramName, paramType } = parsed;
const body = rhs[2];
env.terms.add(storeName);
env.setLambda(storeName, paramName, paramType, body);
const hadParamTerm = env.terms.has(paramName);
const previousParamType = env.getType(paramName);
env.terms.add(paramName);
env.setType(paramName, paramType);
const paramTypeKey = typeof paramType === 'string' ? paramType : keyOf(paramType);
const bodyTypeKey = env.getType(body) || (typeof body === 'string' ? body : keyOf(body));
if (!hadParamTerm) env.terms.delete(paramName);
if (previousParamType === null) env.types.delete(paramName);
else env.setType(paramName, previousParamType);
env.setType(storeName, '(Pi (' + paramTypeKey + ' ' + paramName + ') ' + bodyTypeKey + ')');
return 1;
}
}
}
// Typed definition: (name : Type) — just a type annotation (no body)
// Already handled by the (x: A) form in evalNode
// Generic symbol alias like (x: y) just copies y's prior probability if any
if (rhs.length===1 && typeof rhs[0]==='string') {
env.setSymbolProb(storeName, env.getSymbolProb(rhs[0]));
return env.getSymbolProb(storeName);
}
// Else: ignore (keeps PoC minimal)
return 0;
}
// Emit a shadowing warning (E008) if the name being defined was previously
// brought in via `(import ...)`. The import handler tracks names it added to
// the environment in `env.imported`; the importing file's own definitions are
// not in that set, so re-binding them locally never triggers the warning.
// Diagnostics are appended to `env._shadowDiagnostics` and surfaced by the
// outer `evaluate()` loop alongside other diagnostics.
function _maybeWarnShadow(env, name) {
if (!env.imported) return;
// Resolve the name through alias mappings so a re-binding like `(cl.and: ...)`
// matches the canonical imported key `classical.and`.
let key = name;
if (!env.imported.has(key)) {
const resolved = env._resolveQualified(name);
if (resolved !== name && env.imported.has(resolved)) {
key = resolved;
} else {
return;
}
}
// Only warn once per name to keep noise down; remove from imported so the
// shadow only fires the first time it's rebinding.
env.imported.delete(key);
const span = env._currentSpan || { file: null, line: 1, col: 1, length: 0 };
const diag = new Diagnostic({
code: 'E008',
message: `Definition of "${name}" shadows an imported binding`,
span,
});
if (Array.isArray(env._shadowDiagnostics)) {
env._shadowDiagnostics.push(diag);
}
}
// ---------- Bidirectional Type Checker (issue #42) ----------
// Public API:
// synth(term, ctx) -> { type: Node|null, diagnostics: Diagnostic[] }
// check(term, expectedType, ctx) -> { ok: boolean, diagnostics: Diagnostic[] }
//
// `ctx` is either an `Env` instance or a plain options object passed to
// `new Env(...)`. Term/type inputs may be parsed AST nodes, link strings,
// or plain symbol strings — the checker normalises each via `parseTermInput`.
//
// Design notes:
// - Synthesise mode walks the term and looks up types in `env.types`,
// applies kernel rules for `(Type N)`, `(Pi ...)`, `(lambda ...)`,
// `(apply ...)`, `(subst ...)`, `(type of ...)`, and `(expr of T)`.
// - Check mode prefers a direct lambda-vs-Pi rule that opens the binder
// and recurses on the body; otherwise it falls back to synthesise +
// definitional convertibility (`isConvertible`).
// - Diagnostics use stable codes E020..E024 (see `docs/DIAGNOSTICS.md`).
// - The checker never throws on user errors; runtime invariants still
// bubble up so genuine bugs surface in tests.
function _typeKeyOf(typeNode) {
if (typeNode === null || typeNode === undefined) return null;
return typeof typeNode === 'string' ? typeNode : keyOf(typeNode);
}
function _parseTypeKeyToNode(typeKey) {
if (typeof typeKey !== 'string') return typeKey;
const trimmed = typeKey.trim();
if (trimmed.startsWith('(')) {
try {
return parseOne(tokenizeOne(trimmed));
} catch (_) {
return typeKey;
}
}
return typeKey;
}
function _diag(code, message, span) {
return new Diagnostic({
code,
message,
span: span || { file: null, line: 1, col: 1, length: 0 },
});
}
function _envFromCtx(ctx) {
return ctx instanceof Env ? ctx : new Env(ctx && ctx.env ? ctx.env : ctx);
}
function _spanFromCtx(ctx, options) {
const opts = options || {};
if (opts.span) return opts.span;
if (ctx instanceof Env && ctx._currentSpan) return ctx._currentSpan;
if (ctx && ctx.span) return ctx.span;
return null;
}
// Snapshot just enough of the env's type-related state to restore after a
// scoped extension (used by lambda binder introduction during synth/check).
function _snapshotTypeBinding(env, name) {
return {
name,
hadTerm: env.terms.has(name),
hadType: env.types.has(name),
previousType: env.types.get(name),
};
}
function _extendTypeBinding(env, name, typeKey) {
env.terms.add(name);
env.types.set(name, typeKey);
}
function _restoreTypeBinding(env, snap) {
if (!snap.hadTerm) env.terms.delete(snap.name);
if (snap.hadType) env.types.set(snap.name, snap.previousType);
else env.types.delete(snap.name);
}
// Best-effort node equality after beta-normalisation. Falls back to plain
// structural equality when convertibility throws.
function _typesAgree(a, b, env) {
if (a === null || b === null) return false;
const aN = _expandForall(a);
const bN = _expandForall(b);
if (isStructurallySame(aN, bN)) return true;
try {
return isConvertible(aN, bN, env);
} catch (_) {
return false;
}
}
// Prenex polymorphism (D9): `(forall A T)` is sugar for `(Pi (Type A) T)`.
// `A` is a bound type variable ranging over the universe `Type`. Expansion
// happens at the outermost layer only — nested quantifiers desugar lazily as
// the type checker recurses into the body.
function _isForallNode(node) {
return (
Array.isArray(node) &&
node.length === 3 &&
node[0] === 'forall' &&
typeof node[1] === 'string'
);
}
function _expandForall(node) {
if (!_isForallNode(node)) return node;
return ['Pi', ['Type', node[1]], node[2]];
}
function _synthLeaf(term, env) {
if (isNum(term)) {
// Numeric literals do not carry an inferable kernel type without an
// ambient annotation; treat them as members of an unspecified Number
// sort by leaving the type unresolved. Callers asking to check a
// literal against a specific type fall back to convertibility.
return null;
}
const recorded = inferTypeKey(term, env);
if (recorded) return _parseTypeKeyToNode(recorded);
// Resolve through namespaces / aliases the same way getType does.
const resolved = env._resolveQualified(term);
if (resolved !== term) {
const fromAlias = env.types.get(resolved);
if (fromAlias) return _parseTypeKeyToNode(fromAlias);
}
// Named lambda introduced via `(name: lambda (A x) body)` records the Pi
// type under `name`, so the inferTypeKey path above already covers it.
return null;
}
function _synthApply(node, env, span, diagnostics) {
// (apply f a) — synth f, expect Pi; check a against domain; result is
// codomain with x := a substituted.
const fnSynth = synth(node[1], env, { span, parentDiagnostics: diagnostics });
for (const d of fnSynth.diagnostics) diagnostics.push(d);
if (!fnSynth.type) {
diagnostics.push(_diag(
'E020',
`Cannot synthesize type of \`${keyOf(node[1])}\` in \`${keyOf(node)}\``,
span,
));
return null;
}
// Prenex polymorphism (D9): `(forall A T)` desugars to `(Pi (Type A) T)`,
// so type-application `(apply f Natural)` reduces by substituting `A := Natural`
// in the body just like a regular Pi-type does.
const fnType = _expandForall(fnSynth.type);
if (!Array.isArray(fnType) || fnType.length !== 3 || fnType[0] !== 'Pi') {
diagnostics.push(_diag(
'E022',
`Application head \`${keyOf(node[1])}\` has type \`${keyOf(fnType)}\`, expected a Pi-type`,
span,
));
return null;
}
const parsed = parseBinding(fnType[1]);
if (!parsed) {
diagnostics.push(_diag(
'E022',
`Application head has malformed Pi binder \`${keyOf(fnType[1])}\``,
span,
));
return null;
}
const domainNode = typeof parsed.paramType === 'string'
? parsed.paramType
: parsed.paramType;
const argCheck = check(node[2], domainNode, env, { span, parentDiagnostics: diagnostics });
for (const d of argCheck.diagnostics) diagnostics.push(d);
if (!argCheck.ok) return null;
// Substitute x := a in the codomain to get the result type.
return subst(fnType[2], parsed.paramName, node[2]);
}
function _synthLambda(node, env, span, diagnostics) {
// (lambda (A x) body) synthesises a Pi-type by extending the context
// with x : A and recursively synthesising the body.
const parsed = parseBinding(node[1]);
if (!parsed) {
diagnostics.push(_diag(
'E024',
`Lambda has malformed binder \`${keyOf(node[1])}\``,
span,
));
return null;
}
const paramTypeKey = _typeKeyOf(parsed.paramType);
const snap = _snapshotTypeBinding(env, parsed.paramName);
_extendTypeBinding(env, parsed.paramName, paramTypeKey);
let bodyType = null;
try {
const bodySynth = synth(node[2], env, { span, parentDiagnostics: diagnostics });
for (const d of bodySynth.diagnostics) diagnostics.push(d);
bodyType = bodySynth.type;
} finally {
_restoreTypeBinding(env, snap);
}
if (!bodyType) return null;
return ['Pi', [parsed.paramType, parsed.paramName], bodyType];
}
function _synthTypeOfQuery(node, env) {
// (type of expr) reports the synthesized type literally.
const inner = node[2];
const result = synth(inner, env);
if (result.type) return ['Type', '0'];
return null;
}
function _synthOfMembership(node, env, span, diagnostics) {
// (expr of Type) — checks membership and produces a (Type 0) result if
// the check holds. We delegate to `check` against the declared type.
const expected = node[2];
const result = check(node[0], expected, env, { span, parentDiagnostics: diagnostics });
for (const d of result.diagnostics) diagnostics.push(d);
if (!result.ok) return null;
return ['Type', '0'];
}
/**
* Synthesize the type of a kernel term.
*/
function synth(term, ctx, options) {
const env = _envFromCtx(ctx);
const span = _spanFromCtx(ctx, options);
const diagnostics = [];
const node = parseTermInput(term);
// Leaves: numeric literals and bare symbols.
if (typeof node === 'string') {
const t = _synthLeaf(node, env);
if (!t && !isNum(node)) {
diagnostics.push(_diag(
'E020',
`Cannot synthesize type of symbol \`${node}\``,
span,
));
}
return { type: t, diagnostics };
}
if (!Array.isArray(node)) {
diagnostics.push(_diag(
'E020',
`Cannot synthesize type of \`${keyOf(node)}\``,
span,
));
return { type: null, diagnostics };
}
// (Type N) : (Type N+1)
if (node.length === 2 && node[0] === 'Type') {
const universeType = universeTypeKey(node);
if (universeType) return { type: _parseTypeKeyToNode(universeType), diagnostics };
diagnostics.push(_diag(
'E020',
`Universe \`${keyOf(node)}\` has invalid level token \`${keyOf(node[1])}\``,
span,
));
return { type: null, diagnostics };
}
// (Prop) : (Type 1)
if (node.length === 1 && node[0] === 'Prop') {
return { type: ['Type', '1'], diagnostics };
}
// (Pi (A x) B) : (Type 0) — domain checks against (Type 0); body checks
// under the extended context. We do not enforce a universe stratification
// here beyond what evalNode records, matching the documented kernel.
if (node.length === 3 && node[0] === 'Pi') {
const parsed = parseBinding(node[1]);
if (!parsed) {
diagnostics.push(_diag(
'E024',
`Pi has malformed binder \`${keyOf(node[1])}\``,
span,
));
return { type: null, diagnostics };
}
return { type: ['Type', '0'], diagnostics };
}
// (forall A T) : (Type 0) — prenex polymorphism (D9). `A` is bound as a
// type variable ranging over `Type`; the body `T` is the polymorphic type.
// Synthesised as a Type because the surface form is itself a type.
if (_isForallNode(node)) {
return synth(_expandForall(node), env, { span, parentDiagnostics: diagnostics });
}
// (lambda (A x) body)
if (node.length === 3 && node[0] === 'lambda') {
const lambdaType = _synthLambda(node, env, span, diagnostics);
return { type: lambdaType, diagnostics };
}
// (apply f a)
if (node.length === 3 && node[0] === 'apply') {
const appType = _synthApply(node, env, span, diagnostics);
return { type: appType, diagnostics };
}
// (subst term x replacement) — synth the substituted term.
if (node.length === 4 && node[0] === 'subst' && typeof node[2] === 'string') {
const reduced = subst(parseTermInput(node[1]), node[2], parseTermInput(node[3]));
return synth(reduced, env, { span, parentDiagnostics: diagnostics });
}
// (type of expr) — kernel returns the type of expr.
if (node.length === 3 && node[0] === 'type' && node[1] === 'of') {
const innerSynth = synth(node[2], env, { span });
for (const d of innerSynth.diagnostics) diagnostics.push(d);
if (innerSynth.type) {
// (type of expr) itself is a (Type 0)-level term — a representation
// of a type. Its synthesised type is therefore (Type 0).
return { type: ['Type', '0'], diagnostics };
}
diagnostics.push(_diag(
'E020',
`Cannot synthesize type referenced by \`${keyOf(node)}\``,
span,
));
return { type: null, diagnostics };
}
// (expr of T) — succeeds with (Type 0) when the membership check holds.
if (node.length === 3 && node[1] === 'of') {
const t = _synthOfMembership(node, env, span, diagnostics);
return { type: t, diagnostics };
}
// Fallback: try the recorded type from evalNode-installed facts.
const recorded = inferTypeKey(node, env);
if (recorded) return { type: _parseTypeKeyToNode(recorded), diagnostics };
diagnostics.push(_diag(
'E020',
`Cannot synthesize type of \`${keyOf(node)}\``,
span,
));
return { type: null, diagnostics };
}
/**
* Check that a kernel term has the expected type.
*/
function check(term, expectedType, ctx, options) {
const env = _envFromCtx(ctx);
const span = _spanFromCtx(ctx, options);
const diagnostics = [];
const node = parseTermInput(term);
let expectedNode = parseTermInput(expectedType);
// Prenex polymorphism (D9): `(forall A T)` is sugar for `(Pi (Type A) T)`.
// Expand once here so the lambda-vs-Pi rule below applies uniformly. The
// term-side stays unchanged because `(lambda (Type A) ...)` already uses
// the Pi-friendly binder form.
if (_isForallNode(expectedNode)) {
expectedNode = _expandForall(expectedNode);
}
// Direct rule: (lambda (A x) body) checks against (Pi (A' y) B) when
// A converts with A' — open the binder, alpha-rename if needed, recurse
// on body against B.
if (
Array.isArray(node) && node.length === 3 && node[0] === 'lambda' &&
Array.isArray(expectedNode) && expectedNode.length === 3 && expectedNode[0] === 'Pi'
) {
const lambdaParsed = parseBinding(node[1]);
const piParsed = parseBinding(expectedNode[1]);
if (lambdaParsed && piParsed) {
const domainOk = _typesAgree(
parseTermInput(lambdaParsed.paramType),
parseTermInput(piParsed.paramType),
env,
);
if (!domainOk) {
diagnostics.push(_diag(
'E021',
`Lambda parameter type \`${keyOf(lambdaParsed.paramType)}\` does not match Pi domain \`${keyOf(piParsed.paramType)}\``,
span,
));
return { ok: false, diagnostics };
}
// Align body context: introduce the lambda's parameter, then check
// the body against the Pi codomain with the Pi parameter renamed to
// the lambda parameter.
const codomain = subst(expectedNode[2], piParsed.paramName, lambdaParsed.paramName);
const paramTypeKey = _typeKeyOf(lambdaParsed.paramType);
const snap = _snapshotTypeBinding(env, lambdaParsed.paramName);
_extendTypeBinding(env, lambdaParsed.paramName, paramTypeKey);
try {
const bodyResult = check(node[2], codomain, env, { span, parentDiagnostics: diagnostics });
for (const d of bodyResult.diagnostics) diagnostics.push(d);
return { ok: bodyResult.ok, diagnostics };
} finally {
_restoreTypeBinding(env, snap);
}
}
}
// Lambda checked against non-Pi expected type.
if (
Array.isArray(node) && node.length === 3 && node[0] === 'lambda' &&
!(Array.isArray(expectedNode) && expectedNode[0] === 'Pi')
) {
diagnostics.push(_diag(
'E023',
`Lambda \`${keyOf(node)}\` cannot check against non-Pi type \`${keyOf(expectedNode)}\``,
span,
));
return { ok: false, diagnostics };
}
// Numeric literal: accept any non-empty annotation; the kernel does not
// record number sorts directly. Equality with the expected type collapses
// through definitional convertibility downstream.
if (typeof node === 'string' && isNum(node)) {
return { ok: true, diagnostics };
}
// Default mode-switch: synthesise and compare with definitional equality.
const synthResult = synth(node, env, { span });
for (const d of synthResult.diagnostics) diagnostics.push(d);
if (!synthResult.type) {
return { ok: false, diagnostics };
}
const ok = _typesAgree(synthResult.type, expectedNode, env);
if (!ok) {
diagnostics.push(_diag(
'E021',
`Type mismatch: \`${keyOf(node)}\` has type \`${keyOf(synthResult.type)}\`, expected \`${keyOf(expectedNode)}\``,
span,
));
}
return { ok, diagnostics };
}
// ---------- Public LiNo helpers ----------
function stripLinoComments(text) {
return text
.replace(/^[ \t]*#.*$/gm, '') // full-line comments
.replace(/(\)[ \t]+)#.*$/gm, '$1') // inline comments after closing paren
.replace(/\n{3,}/g, '\n\n');
}
function isLiterateLinoPath(file) {
return typeof file === 'string' && /\.lino\.md$/i.test(file);
}
function parseMarkdownFence(line) {
const trimmed = String(line).replace(/^[ \t]*/, '');
const match = trimmed.match(/^(`{3,}|~{3,})(.*)$/);
if (!match) return null;
return {
marker: match[1][0],
length: match[1].length,
info: match[2] || '',
};
}
function isClosingMarkdownFence(line, fence) {
const parsed = parseMarkdownFence(line);
return parsed &&
parsed.marker === fence.marker &&
parsed.length >= fence.length &&
/^[ \t]*$/.test(parsed.info);
}
function isLinoFenceInfo(info) {
const tag = String(info).trim().split(/\s+/, 1)[0] || '';
return tag.toLowerCase() === 'lino';
}
/**
* Extract LiNo code from fenced `lino` blocks in a literate `.lino.md` file.
*
* Non-LiNo prose and other code fences become blank lines so diagnostics keep
* the original Markdown line numbers.
*/
function extractLiterateLino(text) {
const lines = String(text).split('\n');
const out = [];
let activeFence = null;
for (const line of lines) {
if (activeFence) {
if (isClosingMarkdownFence(line, activeFence)) {
activeFence = null;
out.push('');
} else {
out.push(activeFence.include ? line : '');
}
continue;
}
const fence = parseMarkdownFence(line);
if (fence) {
activeFence = { ...fence, include: isLinoFenceInfo(fence.info) };
out.push('');
continue;
}
out.push('');
}
return out.join('\n');
}
function sourceForEvaluation(code, file) {
const source = String(code);
return isLiterateLinoPath(file) ? extractLiterateLino(source) : source;
}
/**
* Parse LiNo source text with the official links-notation parser.
*/
function parseLino(text) {
const parser = new Parser();
return parser.parse(stripLinoComments(text)).map(link => String(link));
}
function parseLinoForms(text) {
return parseLino(text)
.filter(linkStr => {
const s = String(linkStr).trim();
// Skip if it's just a comment link like "(# ...)"
return !s.match(/^\(#\s/);
})
.map(linkStr => {
const toks = tokenizeOne(String(linkStr));
return desugarHoas(parseOne(toks));
});
}
// Compute (line, col) source positions for every top-level link in `text`.
// A "top-level link" is a parenthesized form that is not nested inside another;
// position is reported as 1-based line and column of its opening `(`.
// Lines starting with `#` are treated as full-line comments and ignored, just
// like `stripLinoComments` does. Inline `# ...` comments that follow a closing
// paren (matching `(\)[ \t]+)#.*$` in `stripLinoComments`) are also skipped so
// parens inside the comment don't disturb the depth counter.
/**
* Compute 1-based source spans for every top-level LiNo form in `text`.
*/
function computeFormSpans(text, file) {
const spans = [];
const lines = text.split('\n');
// Track parenthesis depth across the whole text (top-level links never nest).
let depth = 0;
let lineNum = 1;
let colNum = 1;
let pendingStart = null; // {line, col, offset} for the next top-level link
let inLineComment = false;
let lastClosingDepthZeroCol = -1;
let sawWsAfterClose = false;
for (let off = 0; off < text.length; off++) {
const ch = text[off];
if (ch === '\n') {
inLineComment = false;
lineNum++;
colNum = 1;
lastClosingDepthZeroCol = -1;
sawWsAfterClose = false;
continue;
}
if (inLineComment) { colNum++; continue; }
// Detect a full-line comment (line begins with optional whitespace + #).
if (ch === '#' && depth === 0) {
// Full-line comment: line so far is all whitespace.
const lineSoFar = lines[lineNum - 1].slice(0, colNum - 1);
if (/^[ \t]*$/.test(lineSoFar)) {
inLineComment = true;
colNum++;
continue;
}
// Inline comment after `)` + whitespace: discard rest of line.
if (lastClosingDepthZeroCol >= 0 && sawWsAfterClose) {
inLineComment = true;
colNum++;
continue;
}
}
if (ch === '(') {
if (depth === 0) {
pendingStart = { line: lineNum, col: colNum };
}
depth++;
sawWsAfterClose = false;
} else if (ch === ')') {
depth--;
if (depth === 0 && pendingStart) {
spans.push({ file: file || null, line: pendingStart.line, col: pendingStart.col, length: 1 });
pendingStart = null;
lastClosingDepthZeroCol = colNum;
sawWsAfterClose = false;
}
} else if (ch === ' ' || ch === '\t') {
if (lastClosingDepthZeroCol >= 0) sawWsAfterClose = true;
} else {
// Any other character resets the inline-comment-eligible state.
lastClosingDepthZeroCol = -1;
sawWsAfterClose = false;
}
colNum++;
}
return spans;
}
// New structured evaluator: returns { results, diagnostics }. Existing
// callers can keep using `run`, which now delegates to this and surfaces only
// the result list (preserving its previous signature for tests/CLI consumers).
//
// `options.env` may be an existing `Env` instance (used by the REPL to
// preserve state across inputs) or a plain options object passed to `new Env`.
/**
* Evaluate LiNo source and return query results plus structured diagnostics.
*
* @param {string} code - LiNo source text.
* @param {object} [options] - Evaluation options.
* @param {string} [options.file] - Source file path used in diagnostics.
* @param {Env|object} [options.env] - Existing environment or environment options.
* @param {boolean} [options.trace=false] - Include deterministic trace events.
* @param {boolean} [options.withProofs=false] - Include proof witnesses for queries.
* @returns {object} Results, diagnostics, and optional trace/proof arrays.
*/
function evaluate(code, options) {
const opts = options || {};
const file = opts.file || null;
const sourceText = sourceForEvaluation(code, file);
const env = opts.env instanceof Env ? opts.env : new Env(opts.env || opts);
const results = [];
const diagnostics = [];
const traceEnabled = !!opts.trace;
const trace = traceEnabled ? [] : null;
if (traceEnabled) {
env._tracer = (kind, detail, span) => {
trace.push(new TraceEvent({ kind, detail, span: span || { file, line: 1, col: 1, length: 0 } }));
};
}
// Proof mode (issue #35): when `withProofs` is true every query result is
// accompanied by a derivation tree at the same index in `proofs`. The
// inline `(? expr with proof)` form opts in per-query without flipping the
// global flag — in that case `proofs` is still populated, but bare queries
// that did not ask for a witness get `null` so the array stays
// index-aligned with `results`.
const proofsEnabled = !!opts.withProofs;
let proofs = proofsEnabled ? [] : null;
// Equality-layer provenance (issue #97). Lazy-allocated: as soon as one
// query reports a non-null equality layer we backfill nulls for prior
// queries so the array stays index-aligned with `results`. The property
// is only attached to the output when at least one entry is non-null,
// which keeps the baseline `{results, diagnostics}` shape unchanged for
// programs that never test equality.
let provenance = null;
const recordProvenance = (expandedForm, expandedSpan) => {
const rule = equalityProvenanceForQuery(expandedForm, env);
if (rule === null) {
if (provenance !== null) provenance.push(null);
return;
}
if (provenance === null) {
provenance = results.slice(0, -1).map(() => null);
}
provenance.push(rule);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'equality-layer', detail: rule, span: expandedSpan }));
}
};
// Import context: a stack of canonical paths currently being loaded (cycle
// detection) and a set of canonical paths already loaded into this env
// (caching for diamond patterns). Both are reused across recursive calls.
const importStack = opts._importStack || [];
const importedFiles = opts._importedFiles || new Set();
// Shadowing diagnostics (E008) are appended to this array by `defineForm`
// when a top-level definition rebinds an imported name. Surfaced after the
// form-evaluation loop so they appear alongside other diagnostics.
if (!Array.isArray(env._shadowDiagnostics)) env._shadowDiagnostics = [];
// Pre-compute spans for each top-level form so error reporting can attach
// a real source location even when the parser/evaluator throw deep inside.
const formSpans = computeFormSpans(sourceText, file);
let forms;
try {
forms = parseLinoForms(sourceText);
} catch (err) {
const diag = err && err.code
? new Diagnostic({ code: err.code, message: err.message, span: { file, line: 1, col: 1, length: 0 } })
: new Diagnostic({ code: 'E006', message: `LiNo parse failure: ${err && err.message ? err.message : String(err)}`, span: { file, line: 1, col: 1, length: 0 } });
diagnostics.push(diag);
const out = { results, diagnostics };
if (traceEnabled) out.trace = trace;
if (proofs !== null) out.proofs = proofs;
if (provenance !== null) out.provenance = provenance;
return out;
}
// Evaluate a single `(with-foundation <name> <body>...)` form. Body forms
// are dispatched recursively so nested `(with-foundation ...)`,
// `(foundation-report)`, and `(foundation ...)` declarations work the
// same way they do at the top level. Anything else falls through to the
// regular `evalNode` query path.
const runWithFoundation = (form, span) => {
if (form.length < 2 || typeof form[1] !== 'string') {
diagnostics.push(new Diagnostic({
code: 'E062',
message: 'with-foundation form must be `(with-foundation <name> <body>...)`',
span,
}));
return;
}
const fname = form[1];
let entered = false;
try {
env.enterFoundation(fname);
entered = true;
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'with-foundation/enter', detail: fname, span }));
}
for (let bi = 2; bi < form.length; bi++) {
let body = form[bi];
while (Array.isArray(body) && body.length === 1 && Array.isArray(body[0])) {
body = body[0];
}
try {
if (Array.isArray(body) && body[0] === 'with-foundation') {
runWithFoundation(body, span);
} else if (Array.isArray(body)
&& (body[0] === 'foundation-report' || body[0] === 'foundation-report?')) {
const report = env.foundationReport();
results.push(report);
if (proofs !== null) proofs.push(null);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'foundation-report', detail: report.activeFoundation, span }));
}
} else if (Array.isArray(body) && body[0] === 'foundation') {
const foundation = parseFoundationForm(body);
env.registerFoundation(foundation);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'foundation', detail: foundation.name, span }));
}
} else if (Array.isArray(body) && body[0] === 'root-construct') {
const descriptor = parseRootConstructForm(body);
env.registerRootConstruct(descriptor);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'root-construct', detail: descriptor.name, span }));
}
} else if (Array.isArray(body) && body[0] === 'rule' && isProofRuleShape(body)) {
const rule = parseRuleForm(body);
env.registerProofRule(rule);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'rule', detail: rule.name, span }));
}
} else if (Array.isArray(body) && (body[0] === 'assumption' || body[0] === 'axiom')) {
const assumption = parseProofAssumptionForm(body);
env.registerProofAssumption(assumption);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: assumption.kind, detail: assumption.name, span }));
}
} else if (Array.isArray(body) && body[0] === 'proof-object') {
const po = parseProofObjectForm(body);
env.registerProofObject(po);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'proof-object', detail: po.name, span }));
}
} else if (Array.isArray(body) && body[0] === 'check-proof') {
if (body.length !== 2 || typeof body[1] !== 'string' || !body[1]) {
throw new RmlError('E064', '(check-proof <name>) requires a proof-object name');
}
const target = body[1];
const verdict = checkProofObject(env, target);
const value = verdict.ok ? 1 : 0;
results.push(value);
if (proofs !== null) proofs.push(null);
if (provenance !== null) provenance.push(null);
if (!verdict.ok) {
diagnostics.push(new Diagnostic({ code: 'E064', message: verdict.error, span }));
}
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'check-proof', detail: `${target} -> ${verdict.ok ? 'ok' : 'fail'}`, span }));
}
} else if (Array.isArray(body) && body[0] === 'proof-report') {
if (body.length !== 2 || typeof body[1] !== 'string' || !body[1]) {
throw new RmlError('E064', '(proof-report <name>) requires a proof-object name');
}
const target = body[1];
const report = env.proofReport(target);
results.push(report);
if (proofs !== null) proofs.push(null);
if (provenance !== null) provenance.push(null);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'proof-report', detail: target, span }));
}
} else if (Array.isArray(body) && body[0] === 'eval-nat') {
if (body.length !== 2) {
throw new RmlError('E067', '(eval-nat <term>) requires exactly one term argument');
}
const { value, normalForm, steps } = evalNatTerm(env, body[1]);
results.push(value);
if (proofs !== null) proofs.push(null);
if (provenance !== null) provenance.push(null);
if (traceEnabled && trace) {
trace.push(new TraceEvent({
kind: 'eval-nat',
detail: `${formatTraceValue(body[1])} -> normal-form ${keyOf(normalForm)} -> ${value}; rules-used: ${steps.join(', ') || '<none>'}; host-primitives-used: structural-matcher; renderer: nat-normal-form-to-host-number`,
span,
}));
}
} else if (Array.isArray(body) && body[0] === 'strict-foundation') {
const decl = parseStrictFoundationForm(body);
env.strictPureLinks = true;
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'strict-foundation', detail: decl.profile, span }));
}
} else if (Array.isArray(body) && body[0] === 'allow-host-primitive') {
const decl = parseAllowHostPrimitiveForm(body);
for (const name of decl.names) env.allowedHostPrimitives.add(name);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'allow-host-primitive', detail: decl.names.join(' '), span }));
}
} else {
const expanded = expandTemplates(body, env);
const res = evalNode(expanded, env);
if (res && res.query) {
results.push(res.value);
if (proofs !== null) proofs.push(null);
recordProvenance(expanded, span);
const carrierErr = env.checkCarrierValue(res.value);
if (carrierErr) {
diagnostics.push(new Diagnostic({
code: 'E063',
message: `Query result ${formatTraceValue(res.value)} violates active foundation carrier: ${carrierErr}`,
span,
}));
}
if (env.strictPureLinks === true) {
const innerExp = _stripWithProof(expanded.slice(1));
const targetExp = innerExp.length === 1 ? innerExp[0] : innerExp;
const offenders = scanPureLinksOffenders(targetExp, env);
if (offenders.length > 0) {
diagnostics.push(new Diagnostic({
code: 'E065',
message: `Query depends on host-primitive construct(s) under pure-links strict mode: ${offenders.join(', ')}`,
span,
}));
}
}
}
}
} catch (innerErr) {
const diagSpan = (innerErr && innerErr.span) || span;
const code = (innerErr && innerErr.code) || 'E000';
const message = innerErr && innerErr.message ? innerErr.message : String(innerErr);
diagnostics.push(new Diagnostic({ code, message, span: diagSpan }));
}
}
} catch (err) {
diagnostics.push(new Diagnostic({
code: (err && err.code) || 'E062',
message: err && err.message ? err.message : String(err),
span: (err && err.span) || span,
}));
} finally {
if (entered) {
env.exitFoundation();
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'with-foundation/exit', detail: fname, span }));
}
}
}
};
for (let idx = 0; idx < forms.length; idx++) {
let form = forms[idx];
while (Array.isArray(form) && form.length === 1 && Array.isArray(form[0])) {
form = form[0];
}
const span = formSpans[idx] || { file, line: 1, col: 1, length: 0 };
env._currentSpan = span;
// Handle (namespace <name>) at the top level — sets the active namespace
// for subsequent definitions in this file (issue #34).
if (Array.isArray(form) && form.length === 2 && form[0] === 'namespace' && typeof form[1] === 'string') {
const ns = form[1];
if (ns.includes('.')) {
diagnostics.push(new Diagnostic({
code: 'E009',
message: `Namespace name must not contain '.': "${ns}"`,
span,
}));
} else {
env.namespace = ns;
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'namespace', detail: ns, span }));
}
}
continue;
}
// Handle (import <path>) and (import <path> as <alias>) at the top level —
// file-level directives, not regular RML expressions.
if (Array.isArray(form) && form[0] === 'import') {
let importDiag = null;
if (form.length === 2) {
importDiag = handleImport(form[1], null, span, file, env, importStack, importedFiles, diagnostics, traceEnabled, trace);
} else if (form.length === 4 && form[2] === 'as' && typeof form[3] === 'string') {
importDiag = handleImport(form[1], form[3], span, file, env, importStack, importedFiles, diagnostics, traceEnabled, trace);
} else if (form.length === 2 || form.length >= 3) {
importDiag = new Diagnostic({
code: 'E007',
message: 'Import directive must be (import "<path>") or (import "<path>" as <alias>)',
span,
});
}
if (importDiag) diagnostics.push(importDiag);
continue;
}
// Handle `(template (<name> <param>...) <body>)` at the top level. The
// declaration itself produces no result; later forms are expanded before
// regular evaluation.
if (Array.isArray(form) && form[0] === 'template') {
try {
const registered = registerTemplateForm(form, env);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'template', detail: registered, span }));
}
} catch (err) {
diagnostics.push(new Diagnostic({
code: (err && err.code) || 'E040',
message: err && err.message ? err.message : String(err),
span: (err && err.span) || span,
}));
}
continue;
}
// Foundation / root-construct registry (issue #97). Declarations are
// data-only: they record what the prover trusts but never alter the host
// implementation's behaviour. Backward compatibility is preserved by
// defaulting to the `default-rml` foundation, which is preregistered with
// the current host semantics.
if (Array.isArray(form) && form[0] === 'root-construct') {
try {
const descriptor = parseRootConstructForm(form);
env.registerRootConstruct(descriptor);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'root-construct', detail: descriptor.name, span }));
}
} catch (err) {
diagnostics.push(new Diagnostic({
code: (err && err.code) || 'E060',
message: err && err.message ? err.message : String(err),
span: (err && err.span) || span,
}));
}
continue;
}
if (Array.isArray(form) && form[0] === 'foundation') {
try {
const foundation = parseFoundationForm(form);
env.registerFoundation(foundation);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'foundation', detail: foundation.name, span }));
}
} catch (err) {
diagnostics.push(new Diagnostic({
code: (err && err.code) || 'E061',
message: err && err.message ? err.message : String(err),
span: (err && err.span) || span,
}));
}
continue;
}
if (Array.isArray(form) && form[0] === 'with-foundation') {
runWithFoundation(form, span);
continue;
}
if (Array.isArray(form) && (form[0] === 'foundation-report' || form[0] === 'foundation-report?')) {
const report = env.foundationReport();
results.push(report);
if (proofs !== null) proofs.push(null);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'foundation-report', detail: report.activeFoundation, span }));
}
continue;
}
// Proof-object substrate (issue #97, Phase 3). Rules and proof objects
// are registered as data; `(check-proof <name>)` looks the proof up,
// matches it structurally against the rule's pattern with `?metavar`
// unification, and returns 1.0 on success or 0.0 on failure (emitting
// an E064 diagnostic so callers can surface the mismatch reason).
// The proof-substrate form is `(rule <leaf-name> (premise ...)... (conclusion ...))`.
// Existing self-bootstrap grammar files declare `(rule <leaf-name> (sequence
// ...) ...)` data-only forms that we must not hijack, so we route to the
// proof substrate only when every clause uses the `premise`/`conclusion`
// keywords and at least one `conclusion` clause is present. Other shapes
// fall through to the legacy data path unchanged.
if (isProofRuleShape(form)) {
try {
const rule = parseRuleForm(form);
env.registerProofRule(rule);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'rule', detail: rule.name, span }));
}
} catch (err) {
diagnostics.push(new Diagnostic({
code: (err && err.code) || 'E064',
message: err && err.message ? err.message : String(err),
span: (err && err.span) || span,
}));
}
continue;
}
if (Array.isArray(form) && (form[0] === 'assumption' || form[0] === 'axiom')) {
try {
const assumption = parseProofAssumptionForm(form);
env.registerProofAssumption(assumption);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: assumption.kind, detail: assumption.name, span }));
}
} catch (err) {
diagnostics.push(new Diagnostic({
code: (err && err.code) || 'E064',
message: err && err.message ? err.message : String(err),
span: (err && err.span) || span,
}));
}
continue;
}
if (Array.isArray(form) && form[0] === 'proof-object') {
try {
const po = parseProofObjectForm(form);
env.registerProofObject(po);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'proof-object', detail: po.name, span }));
}
} catch (err) {
diagnostics.push(new Diagnostic({
code: (err && err.code) || 'E064',
message: err && err.message ? err.message : String(err),
span: (err && err.span) || span,
}));
}
continue;
}
// Pure-links strict mode (issue #97, Phase 6). The `(strict-foundation
// pure-links)` form flips the audit on for every subsequent query; the
// `(allow-host-primitive ...)` form whitelists specific host primitives
// so a program can opt in gradually instead of all at once.
if (Array.isArray(form) && form[0] === 'strict-foundation') {
try {
const decl = parseStrictFoundationForm(form);
env.strictPureLinks = true;
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'strict-foundation', detail: decl.profile, span }));
}
} catch (err) {
diagnostics.push(new Diagnostic({
code: (err && err.code) || 'E065',
message: err && err.message ? err.message : String(err),
span: (err && err.span) || span,
}));
}
continue;
}
if (Array.isArray(form) && form[0] === 'allow-host-primitive') {
try {
const decl = parseAllowHostPrimitiveForm(form);
for (const n of decl.names) env.allowedHostPrimitives.add(n);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'allow-host-primitive', detail: decl.names.join(','), span }));
}
} catch (err) {
diagnostics.push(new Diagnostic({
code: (err && err.code) || 'E065',
message: err && err.message ? err.message : String(err),
span: (err && err.span) || span,
}));
}
continue;
}
if (Array.isArray(form) && form[0] === 'check-proof') {
if (form.length !== 2 || typeof form[1] !== 'string') {
diagnostics.push(new Diagnostic({
code: 'E064',
message: '(check-proof <name>) requires a proof-object name',
span,
}));
continue;
}
const verdict = checkProofObject(env, form[1]);
results.push(verdict.ok ? 1 : 0);
if (proofs !== null) proofs.push(null);
if (!verdict.ok) {
diagnostics.push(new Diagnostic({
code: 'E064',
message: verdict.error,
span,
}));
}
if (traceEnabled && trace) {
trace.push(new TraceEvent({
kind: 'check-proof',
detail: `${form[1]} → ${verdict.ok ? 'ok' : 'fail'}`,
span,
}));
}
continue;
}
if (Array.isArray(form) && form[0] === 'proof-report') {
if (form.length !== 2 || typeof form[1] !== 'string' || !form[1]) {
diagnostics.push(new Diagnostic({
code: 'E064',
message: '(proof-report <name>) requires a proof-object name',
span,
}));
continue;
}
const report = env.proofReport(form[1]);
results.push(report);
if (proofs !== null) proofs.push(null);
if (traceEnabled && trace) {
trace.push(new TraceEvent({
kind: 'proof-report',
detail: form[1],
span,
}));
}
continue;
}
if (Array.isArray(form) && form[0] === 'eval-nat') {
if (form.length !== 2) {
diagnostics.push(new Diagnostic({
code: 'E067',
message: '(eval-nat <term>) requires exactly one term argument',
span,
}));
continue;
}
try {
const { value, normalForm, steps } = evalNatTerm(env, form[1]);
results.push(value);
if (proofs !== null) proofs.push(null);
if (provenance !== null) provenance.push(null);
if (traceEnabled && trace) {
trace.push(new TraceEvent({
kind: 'eval-nat',
detail: `${formatTraceValue(form[1])} -> normal-form ${keyOf(normalForm)} -> ${value}; rules-used: ${steps.join(', ') || '<none>'}; host-primitives-used: structural-matcher; renderer: nat-normal-form-to-host-number`,
span,
}));
}
} catch (err) {
const diagSpan = (err && err.span) || span;
const code = (err && err.code) || 'E067';
const message = err && err.message ? err.message : String(err);
diagnostics.push(new Diagnostic({ code, message, span: diagSpan }));
}
continue;
}
try {
const expandedForm = expandTemplates(form, env);
const res = evalNode(expandedForm, env);
if (traceEnabled) {
const formKey = keyOf(expandedForm);
let summary;
if (res && res.query) {
const tag = res.typeQuery ? 'type' : 'query';
summary = `${formKey} → ${tag} ${formatTraceValue(res.value)}`;
} else if (isTermResult(res)) {
summary = `${formKey} → term ${keyOf(res.term)}`;
} else {
summary = `${formKey} → ${formatTraceValue(res)}`;
}
trace.push(new TraceEvent({ kind: 'eval', detail: summary, span }));
}
if (res && res.query) {
results.push(res.value);
// Per-query proof: the global `withProofs` flag forces a proof for
// every query; the inline `with proof` keyword pair opts a single
// query in without the global flag. Lazily allocate the proofs
// array on first per-query opt-in so callers that never use
// proofs still get the original `{results, diagnostics}` shape.
const wantsProof = proofsEnabled || _queryRequestsProof(expandedForm);
if (wantsProof) {
if (proofs === null) {
// Backfill nulls for any prior bare queries so indexes align.
proofs = results.slice(0, -1).map(() => null);
}
// Strip the surrounding (? ...) so the proof attaches to the
// queried expression directly; this matches the issue example
// `(by structural-equality (a a))` rather than nesting under
// `(by query ...)`.
const inner = _stripWithProof(expandedForm.slice(1));
const target = inner.length === 1 ? inner[0] : inner;
proofs.push(buildProof(target, env));
} else if (proofs !== null) {
proofs.push(null);
}
recordProvenance(expandedForm, span);
const carrierErr = env.checkCarrierValue(res.value);
if (carrierErr) {
diagnostics.push(new Diagnostic({
code: 'E063',
message: `Query result ${formatTraceValue(res.value)} violates active foundation carrier: ${carrierErr}`,
span,
}));
}
if (env.strictPureLinks === true) {
const inner = _stripWithProof(expandedForm.slice(1));
const target = inner.length === 1 ? inner[0] : inner;
const offenders = scanPureLinksOffenders(target, env);
if (offenders.length > 0) {
diagnostics.push(new Diagnostic({
code: 'E065',
message: `Query depends on host-primitive construct(s) under pure-links strict mode: ${offenders.join(', ')}`,
span,
}));
}
}
}
} catch (err) {
const diagSpan = (err && err.span) || span;
const code = (err && err.code) || 'E000';
const message = err && err.message ? err.message : String(err);
diagnostics.push(new Diagnostic({ code, message, span: diagSpan }));
}
}
env._currentSpan = null;
env._tracer = null;
// Surface shadow diagnostics (E008) collected during defineForm calls.
if (Array.isArray(env._shadowDiagnostics) && env._shadowDiagnostics.length > 0) {
for (const d of env._shadowDiagnostics) diagnostics.push(d);
env._shadowDiagnostics.length = 0;
}
const out = { results, diagnostics };
if (traceEnabled) out.trace = trace;
if (proofs !== null) out.proofs = proofs;
if (provenance !== null) {
// Pad in the rare case where late queries returned without going
// through `recordProvenance` (defensive — keeps the array aligned
// with `results` for consumers iterating by index).
while (provenance.length < results.length) provenance.push(null);
out.provenance = provenance;
}
return out;
}
// ---------- File imports (issue #33) ----------
// Strip surrounding quotes (LiNo passes them through unmodified for some
// shapes; the LiNo parser strips double-quotes for most inputs).
function _unquotePath(s) {
if (typeof s !== 'string') return s;
if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[s.length - 1] === s[0]) {
return s.slice(1, -1);
}
return s;
}
// Resolve an import target relative to the importing file's directory.
// When `importingFile` is null (e.g. evaluating a string literal in tests),
// resolve relative to the current working directory.
function _resolveImportPath(target, importingFile) {
const cleaned = _unquotePath(target);
if (path.isAbsolute(cleaned)) return path.resolve(cleaned);
const baseDir = importingFile ? path.dirname(path.resolve(importingFile)) : process.cwd();
return path.resolve(baseDir, cleaned);
}
// Process a top-level (import <path>) directive. Returns a Diagnostic or null.
// `alias`, when non-null, comes from the `(import "<path>" as <alias>)` form
// (issue #34): after the imported file finishes evaluating, the alias is
// recorded in `env.aliases` mapping `alias -> imported namespace`, so
// references like `<alias>.foo` resolve against that namespace.
function handleImport(rawTarget, alias, span, importingFile, env, importStack, importedFiles, diagnostics, traceEnabled, trace) {
const target = _unquotePath(rawTarget);
if (typeof target !== 'string' || !target) {
return new Diagnostic({
code: 'E007',
message: 'Import target must be a string path',
span,
});
}
if (alias !== null && alias !== undefined) {
if (typeof alias !== 'string' || !alias || alias.includes('.')) {
return new Diagnostic({
code: 'E009',
message: `Import alias must be a non-empty bare identifier (got "${alias}")`,
span,
});
}
if (env.aliases.has(alias) || env.namespace === alias) {
return new Diagnostic({
code: 'E009',
message: `Import alias "${alias}" collides with an existing namespace or alias`,
span,
});
}
}
const resolved = _resolveImportPath(target, importingFile);
// Cycle detection: if the resolved path is already on the active import
// stack, the import would loop forever.
if (importStack.includes(resolved)) {
const cycle = [...importStack, resolved].join(' -> ');
return new Diagnostic({
code: 'E007',
message: `Import cycle detected: ${cycle}`,
span,
});
}
// Cache: each file is loaded once. Repeated imports (e.g. diamond pattern)
// are silent no-ops — but the alias still needs to register, since the
// imported namespace is already loaded into the env.
if (importedFiles.has(resolved)) {
if (alias) {
const recordedNs = (env._fileNamespaces && env._fileNamespaces.get(resolved)) || alias;
env.aliases.set(alias, recordedNs);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'import', detail: `${resolved} as ${alias} (cached)`, span }));
}
} else if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'import', detail: `${resolved} (cached)`, span }));
}
return null;
}
let text;
try {
text = fs.readFileSync(resolved, 'utf8');
} catch (err) {
return new Diagnostic({
code: 'E007',
message: `Failed to read import "${target}": ${err.message}`,
span,
});
}
importedFiles.add(resolved);
importStack.push(resolved);
if (traceEnabled && trace) {
trace.push(new TraceEvent({ kind: 'import', detail: alias ? `${resolved} as ${alias}` : resolved, span }));
}
// Track names introduced by this import so the importing file can fire a
// shadowing diagnostic (E008) if it later rebinds them. Snapshot the
// current bindings before evaluating the imported file, then diff.
const beforeOps = new Set(env.ops.keys());
const beforeSyms = new Set(env.symbolProb.keys());
const beforeTerms = new Set(env.terms);
const beforeLambdas = new Set(env.lambdas.keys());
const beforeTemplates = new Set(env.templates.keys());
const beforeNamespace = env.namespace;
const inner = evaluate(text, {
env,
file: resolved,
trace: traceEnabled,
_importStack: importStack,
_importedFiles: importedFiles,
});
// The imported file may have declared its own (namespace ...) — capture it
// before restoring the importing file's namespace so we can wire up the
// alias and remember the file's namespace for cached re-imports.
const importedNamespace = env.namespace;
env.namespace = beforeNamespace;
if (importedNamespace) {
if (!env._fileNamespaces) env._fileNamespaces = new Map();
env._fileNamespaces.set(resolved, importedNamespace);
}
importStack.pop();
// Record names added by the import for shadowing detection. Skip this when
// the import is itself nested inside another import — only the top-level
// file should warn.
if (importStack.length === 0 || (importingFile && importStack[importStack.length - 1] === importingFile)) {
for (const k of env.ops.keys()) if (!beforeOps.has(k)) env.imported.add(k);
for (const k of env.symbolProb.keys()) if (!beforeSyms.has(k)) env.imported.add(k);
for (const k of env.terms) if (!beforeTerms.has(k)) env.imported.add(k);
for (const k of env.lambdas.keys()) if (!beforeLambdas.has(k)) env.imported.add(k);
for (const k of env.templates.keys()) if (!beforeTemplates.has(k)) env.imported.add(k);
}
// Wire up the alias once the imported file has finished evaluating. If the
// imported file declared a namespace, alias maps to it; otherwise it maps
// to the alias name itself (so qualified references through the alias still
// work for namespace-less files — symbols defined as top-level become
// accessible via `<alias>.<name>` only if pre-existing under that key).
if (alias) {
env.aliases.set(alias, importedNamespace || alias);
}
// Forward inner diagnostics so the importer surfaces errors from the
// imported file with their original spans intact.
for (const diag of inner.diagnostics) diagnostics.push(diag);
if (traceEnabled && trace && Array.isArray(inner.trace)) {
for (const ev of inner.trace) trace.push(ev);
}
return null;
}
// Read a file from disk and evaluate it, honouring (import ...) directives.
// Mirrors `evaluate()` but takes a path on disk and resolves relative imports
// against the file's directory.
/**
* Read and evaluate a LiNo file, resolving imports relative to that file.
*/
function evaluateFile(filePath, options) {
const opts = options || {};
const resolved = path.resolve(filePath);
let text;
try {
text = fs.readFileSync(resolved, 'utf8');
} catch (err) {
const diag = new Diagnostic({
code: 'E007',
message: `Failed to read "${filePath}": ${err.message}`,
span: { file: filePath, line: 1, col: 1, length: 0 },
});
return { results: [], diagnostics: [diag] };
}
return evaluate(text, {
...opts,
file: resolved,
_importStack: opts._importStack || [resolved],
_importedFiles: opts._importedFiles || new Set([resolved]),
});
}
// ---------- Meta-expression adapter ----------
function normalizeInterpretation(interpretation) {
if (!interpretation) return {};
if (typeof interpretation === 'string') return { kind: interpretation, summary: interpretation };
return interpretation;
}
function normalizeQuestionExpression(text) {
return String(text || '')
.trim()
.replace(/\?+$/g, '')
.replace(/^what\s+is\s+/i, '')
.trim();
}
function splitTopLevelEquals(expression) {
let depth = 0;
const s = String(expression);
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === '(') depth++;
else if (c === ')') depth--;
else if (c === '=' && depth === 0) {
if (s[i - 1] === '!' || s[i + 1] === '=') continue;
return [s.slice(0, i).trim(), s.slice(i + 1).trim()];
}
}
return null;
}
function parseExpressionShape(expression, options = {}) {
const trimmed = String(expression || '').trim();
if (!trimmed) throw new RmlError('E005', 'empty expression');
const source = trimmed.startsWith('(') && trimmed.endsWith(')') ? trimmed : `(${trimmed})`;
let ast = parseOne(tokenizeOne(source));
while (
Array.isArray(ast) &&
ast.length === 1 &&
(options.unwrapSingle || Array.isArray(ast[0]))
) {
ast = ast[0];
}
return ast;
}
function buildArithmeticFormalization(expression, valueKind) {
const eq = valueKind === 'truth-value' ? splitTopLevelEquals(expression) : null;
const ast = eq
? [parseExpressionShape(eq[0], { unwrapSingle: true }), '=', parseExpressionShape(eq[1], { unwrapSingle: true })]
: parseExpressionShape(expression, { unwrapSingle: true });
return {
ast,
lino: keyOf(ast),
valueKind,
};
}
function partialFormalization(request, interpretation, unknowns, level = 2) {
const uniqueUnknowns = [...new Set(unknowns)];
return {
type: 'rml-formalization',
sourceText: request?.text || '',
interpretation,
formalSystem: request?.formalSystem || request?.formal_system || 'rml',
dependencies: request?.dependencies || [],
computable: false,
formalizationLevel: level,
unknowns: uniqueUnknowns,
valueKind: 'partial',
ast: null,
lino: null,
};
}
function formalizeSelectedInterpretation(request = {}) {
const interpretation = normalizeInterpretation(request.interpretation);
const kind = String(interpretation.kind || '').toLowerCase();
const formalSystem = request.formalSystem || request.formal_system || 'rml';
const dependencies = request.dependencies || [];
const rawExpression =
interpretation.expression ||
interpretation.formalExpression ||
interpretation.formal_expression ||
interpretation.lino ||
normalizeQuestionExpression(request.text);
const canUseArithmetic =
formalSystem === 'rml-arithmetic' ||
formalSystem === 'arithmetic' ||
kind.startsWith('arithmetic');
if (canUseArithmetic && rawExpression) {
const valueKind = kind.includes('equal') || splitTopLevelEquals(rawExpression) ? 'truth-value' : 'number';
try {
const formal = buildArithmeticFormalization(rawExpression, valueKind);
return {
type: 'rml-formalization',
sourceText: request.text || '',
interpretation,
formalSystem,
dependencies,
computable: true,
formalizationLevel: 3,
unknowns: [],
valueKind: formal.valueKind,
ast: formal.ast,
lino: formal.lino,
};
} catch (error) {
return partialFormalization(request, interpretation, ['unsupported-arithmetic-shape', error.message], 1);
}
}
if ((interpretation.lino || interpretation.formalExpression || interpretation.formal_expression) && rawExpression) {
try {
const ast = parseExpressionShape(rawExpression);
return {
type: 'rml-formalization',
sourceText: request.text || '',
interpretation,
formalSystem,
dependencies,
computable: true,
formalizationLevel: 3,
unknowns: [],
valueKind: Array.isArray(ast) && ast[0] === '?' ? 'query' : 'truth-value',
ast,
lino: keyOf(ast),
};
} catch (error) {
return partialFormalization(request, interpretation, ['unsupported-lino-shape', error.message], 1);
}
}
const dependencyUnknowns = dependencies
.filter(dep => dep && ['missing', 'unknown', 'partial'].includes(dep.status))
.map(dep => `dependency:${dep.id || 'unknown'}`);
return partialFormalization(request, interpretation, [
'selected-subject',
'selected-relation',
'evidence-source',
'formal-shape',
...dependencyUnknowns,
]);
}
function evaluateFormalization(formalization, options = {}) {
if (!formalization || !formalization.computable || !formalization.ast) {
return {
computable: false,
formalizationLevel: formalization?.formalizationLevel || 0,
unknowns: formalization?.unknowns || ['formalization'],
result: { kind: 'partial', value: 'unknown', deterministic: false },
};
}
const env = new Env(options.env || options);
const evaluated = evalNode(formalization.ast, env);
const value = evaluated && evaluated.query ? evaluated.value : evaluated;
const kind =
formalization.valueKind === 'truth-value' ? 'truth-value' :
formalization.valueKind === 'query' && typeof value === 'string' ? 'type' :
'number';
return {
computable: true,
formalizationLevel: formalization.formalizationLevel,
unknowns: [],
result: { kind, value, deterministic: true },
};
}
// ---------- Program extraction (issue #66) ----------
const EXTRACT_JS_RESERVED = new Set([
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger',
'default', 'delete', 'do', 'else', 'export', 'extends', 'finally', 'for',
'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'return',
'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void', 'while',
'with', 'yield',
]);
const EXTRACT_RUST_RESERVED = new Set([
'as', 'break', 'const', 'continue', 'crate', 'else', 'enum', 'extern',
'false', 'fn', 'for', 'if', 'impl', 'in', 'let', 'loop', 'match', 'mod',
'move', 'mut', 'pub', 'ref', 'return', 'self', 'Self', 'static', 'struct',
'super', 'trait', 'true', 'type', 'unsafe', 'use', 'where', 'while', 'async',
'await', 'dyn',
]);
const EXTRACT_LOGIC_TOKENS = new Set(['and', 'or', 'not', 'both', 'neither', 'has', 'probability']);
const EXTRACT_SPECIAL_FORMS = new Set([
'range', 'valence', 'mode', 'relation', 'world', 'total', 'coverage',
'terminating', 'coinductive', 'template', 'import', 'namespace',
]);
function normalizeExtractTarget(target) {
if (target === 'js' || target === 'javascript') return 'js';
if (target === 'rust' || target === 'rs') return 'rust';
throw new RmlError('E041', `Unknown extraction target "${target}"`);
}
function extractIdentifier(name, target, used) {
const reserved = target === 'rust' ? EXTRACT_RUST_RESERVED : EXTRACT_JS_RESERVED;
let out = String(name).replace(/[^A-Za-z0-9_]/g, '_');
if (!out || /^[0-9]/.test(out)) out = '_' + out;
if (reserved.has(out)) out = out + '_';
const base = out;
let i = 2;
while (used.has(out)) {
out = `${base}_${i}`;
i++;
}
used.add(out);
return out;
}
function extractNumberLiteral(token, target) {
const raw = String(token);
if (target === 'rust' && /^-?\d+$/.test(raw)) return `${raw}.0`;
return raw;
}
function extractCompileError(message) {
return new RmlError('E041', message);
}
function isProbabilityAssignment(node) {
return Array.isArray(node) &&
node.length === 4 &&
node[1] === 'has' &&
node[2] === 'probability';
}
function isQueryForm(node) {
return Array.isArray(node) && node[0] === '?';
}
function isLambdaDefinition(node) {
return Array.isArray(node) &&
node.length >= 3 &&
typeof node[0] === 'string' &&
node[0].endsWith(':') &&
node[1] === 'lambda' &&
Array.isArray(node[2]);
}
function isTypeOnlyForm(node) {
if (!Array.isArray(node) || node.length === 0) return true;
if (node[0] === 'Type' || node[0] === 'Prop' || node[0] === 'Pi') return true;
if (typeof node[0] !== 'string' || !node[0].endsWith(':')) return false;
const head = node[0].slice(0, -1);
const rhs = node.slice(1);
if (rhs.length === 2 && rhs[1] === head) return true;
if (rhs.length === 3 && rhs[0] === head && rhs[1] === 'is' && rhs[2] === head) return true;
if (rhs.length === 1 && Array.isArray(rhs[0])) return true;
return false;
}
function containsExtractLogic(node) {
if (typeof node === 'string') return EXTRACT_LOGIC_TOKENS.has(node);
if (!Array.isArray(node)) return false;
if (isProbabilityAssignment(node)) return true;
return node.some(containsExtractLogic);
}
function extractLambdaDeclaration(form) {
const name = form[0].slice(0, -1);
const bindings = parseBindings(form[2]);
if (!bindings || bindings.length === 0) {
throw extractCompileError(`Cannot extract "${name}": malformed lambda binding`);
}
if (form.length !== 4) {
throw extractCompileError(`Cannot extract "${name}": lambda definitions must have one body`);
}
return {
name,
params: bindings.map(b => b.paramName),
body: form[3],
};
}
function collectApplySpine(node) {
const args = [];
let head = node;
while (Array.isArray(head) && head.length === 3 && head[0] === 'apply') {
args.unshift(head[2]);
head = head[1];
}
return { head, args };
}
function makeExtractNameMap(names, target) {
const used = new Set();
const map = new Map();
for (const name of names) {
map.set(name, extractIdentifier(name, target, used));
}
return map;
}
function compileExtractExpr(node, ctx) {
if (typeof node === 'string') {
if (isNum(node)) return extractNumberLiteral(node, ctx.target);
if (ctx.locals.has(node)) return ctx.locals.get(node);
if (ctx.nameMap.has(node)) return ctx.nameMap.get(node);
throw extractCompileError(`Cannot extract unresolved symbol "${node}"`);
}
if (!Array.isArray(node) || node.length === 0) {
throw extractCompileError(`Cannot extract malformed expression "${keyOf(node)}"`);
}
if (containsExtractLogic(node)) {
throw extractCompileError(`Cannot extract probabilistic or logical expression "${keyOf(node)}"`);
}
if (node.length === 3 && typeof node[1] === 'string' && ['+', '-', '*', '/'].includes(node[1])) {
return `(${compileExtractExpr(node[0], ctx)} ${node[1]} ${compileExtractExpr(node[2], ctx)})`;
}
if (node.length === 3 && node[0] === 'apply') {
const { head, args } = collectApplySpine(node);
if (typeof head !== 'string') {
throw extractCompileError(`Cannot extract higher-order application "${keyOf(node)}"`);
}
const fn = compileExtractExpr(head, ctx);
return `${fn}(${args.map(arg => compileExtractExpr(arg, ctx)).join(', ')})`;
}
if (typeof node[0] === 'string' && ctx.nameMap.has(node[0])) {
const fn = ctx.nameMap.get(node[0]);
return `${fn}(${node.slice(1).map(arg => compileExtractExpr(arg, ctx)).join(', ')})`;
}
throw extractCompileError(`Cannot extract expression "${keyOf(node)}"`);
}
function parseExtractQuery(form) {
const parts = _stripWithProof(form.slice(1));
const target = parts.length === 1 ? parts[0] : parts;
if (Array.isArray(target) && target.length === 3 && target[1] === '=') {
return { left: target[0], right: target[2] };
}
throw extractCompileError(`Cannot extract query "${keyOf(form)}"; expected (? (<left> = <right>))`);
}
function parseExtractProgram(code) {
const forms = parseLinoForms(String(code));
const lambdas = [];
const tests = [];
for (const form of forms) {
if (isProbabilityAssignment(form)) {
throw extractCompileError('Cannot extract probability assignments');
}
if (isLambdaDefinition(form)) {
if (containsExtractLogic(form[3])) {
throw extractCompileError(`Cannot extract probabilistic or logical lambda "${form[0].slice(0, -1)}"`);
}
lambdas.push(extractLambdaDeclaration(form));
continue;
}
if (isQueryForm(form)) {
tests.push(parseExtractQuery(form));
continue;
}
if (Array.isArray(form) && typeof form[0] === 'string') {
const head = form[0].endsWith(':') ? form[0].slice(0, -1) : form[0];
if (EXTRACT_SPECIAL_FORMS.has(head) || ['and', 'or', 'not', 'both', 'neither', '=', '!='].includes(head)) {
throw extractCompileError(`Cannot extract unsupported form "${keyOf(form)}"`);
}
}
if (!isTypeOnlyForm(form)) {
throw extractCompileError(`Cannot extract unsupported form "${keyOf(form)}"`);
}
}
if (lambdas.length === 0) {
throw extractCompileError('Cannot extract program: no lambda definitions found');
}
return { lambdas, tests };
}
function compileJavaScriptProgram(parsed) {
const nameMap = makeExtractNameMap(parsed.lambdas.map(l => l.name), 'js');
const lines = [
'// Generated by rml extract js. Do not edit by hand.',
"import { pathToFileURL } from 'node:url';",
'',
];
for (const lambda of parsed.lambdas) {
const used = new Set(nameMap.values());
const locals = new Map();
for (const param of lambda.params) {
locals.set(param, extractIdentifier(param, 'js', used));
}
const ctx = { target: 'js', nameMap, locals };
const params = lambda.params.map(param => locals.get(param)).join(', ');
lines.push(`export function ${nameMap.get(lambda.name)}(${params}) {`);
lines.push(` return ${compileExtractExpr(lambda.body, ctx)};`);
lines.push('}');
lines.push('');
}
lines.push('function __rmlApproxEq(left, right) {');
lines.push(' return Object.is(left, right) || Math.abs(left - right) <= 1e-9;');
lines.push('}');
lines.push('');
lines.push('export function __runRmlExtractedTests() {');
if (parsed.tests.length === 0) {
lines.push(' return true;');
} else {
parsed.tests.forEach((test, idx) => {
const ctx = { target: 'js', nameMap, locals: new Map() };
const left = compileExtractExpr(test.left, ctx);
const right = compileExtractExpr(test.right, ctx);
lines.push(` if (!__rmlApproxEq(${left}, ${right})) {`);
lines.push(` throw new Error('RML extracted test ${idx + 1} failed');`);
lines.push(' }');
});
lines.push(' return true;');
}
lines.push('}');
lines.push('');
lines.push("if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {");
lines.push(' __runRmlExtractedTests();');
lines.push('}');
lines.push('');
return lines.join('\n');
}
function compileRustProgram(parsed) {
const nameMap = makeExtractNameMap(parsed.lambdas.map(l => l.name), 'rust');
const lines = ['// Generated by rml extract rust. Do not edit by hand.', ''];
for (const lambda of parsed.lambdas) {
const used = new Set(nameMap.values());
const locals = new Map();
for (const param of lambda.params) {
locals.set(param, extractIdentifier(param, 'rust', used));
}
const ctx = { target: 'rust', nameMap, locals };
const params = lambda.params.map(param => `${locals.get(param)}: f64`).join(', ');
lines.push(`pub fn ${nameMap.get(lambda.name)}(${params}) -> f64 {`);
lines.push(` ${compileExtractExpr(lambda.body, ctx)}`);
lines.push('}');
lines.push('');
}
if (parsed.tests.length > 0) {
lines.push('#[cfg(test)]');
lines.push('mod tests {');
lines.push(' use super::*;');
lines.push('');
lines.push(' fn rml_approx_eq(left: f64, right: f64) -> bool {');
lines.push(' (left - right).abs() <= 1e-9');
lines.push(' }');
lines.push('');
parsed.tests.forEach((test, idx) => {
const ctx = { target: 'rust', nameMap, locals: new Map() };
const left = compileExtractExpr(test.left, ctx);
const right = compileExtractExpr(test.right, ctx);
lines.push(' #[test]');
lines.push(` fn rml_query_${idx + 1}() {`);
lines.push(` assert!(rml_approx_eq(${left}, ${right}), "RML query ${idx + 1} failed");`);
lines.push(' }');
lines.push('');
});
lines.push('}');
lines.push('');
}
return lines.join('\n');
}
/**
* Extract selected LiNo definitions into JavaScript or Rust source code.
*/
function extractProgram(code, target = 'js') {
const normalized = normalizeExtractTarget(target);
const parsed = parseExtractProgram(code);
return normalized === 'js' ? compileJavaScriptProgram(parsed) : compileRustProgram(parsed);
}
// ---------- Isabelle/HOL exporter ----------
// The exporter targets the simply typed fragment shared by RML's prototype
// type layer and Isabelle/HOL: type declarations, constants, simple Pi-types,
// first-order inductive datatypes, and lambda definitions whose types can be
// inferred locally. Dependent Pi codomains and probabilistic/operator forms
// are rejected instead of being approximated silently.
class IsabelleExportError extends Error {
constructor(message, node = null) {
const suffix = node ? `: ${keyOf(node)}` : '';
super(`Isabelle export: ${message}${suffix}`);
this.name = 'IsabelleExportError';
this.node = node;
}
}
const ISABELLE_RESERVED = new Set([
'and', 'assumes', 'begin', 'binder', 'case', 'class', 'consts', 'datatype',
'definition', 'else', 'end', 'fixes', 'for', 'fun', 'if', 'imports', 'in',
'infix', 'infixl', 'infixr', 'let', 'locale', 'module', 'notation', 'of',
'open', 'or', 'shows', 'structure', 'syntax', 'then', 'theory', 'type',
'typedecl', 'where',
]);
function _isUniverseAnnotation(node) {
if (node === 'Type') return true;
return Array.isArray(node) &&
node.length === 2 &&
node[0] === 'Type' &&
parseUniverseLevelToken(node[1]) !== null;
}
function _isTypeSelfDeclaration(name, rhs) {
return rhs.length === 2 &&
rhs[1] === name &&
_isUniverseAnnotation(rhs[0]);
}
function _isTypedSelfDeclaration(name, rhs) {
return rhs.length === 2 && rhs[1] === name && !_isUniverseAnnotation(rhs[0]);
}
function _isProbabilisticAssignment(node) {
return Array.isArray(node) &&
node.length === 4 &&
node[1] === 'has' &&
node[2] === 'probability' &&
isNum(node[3]);
}
function _isOperatorHead(head) {
return ['=','!=','and','or','not','is','?:','both','neither'].includes(head) || /[=!]/.test(head);
}
function _nodeContainsSymbol(node, symbol) {
if (typeof node === 'string') return node === symbol;
return Array.isArray(node) && node.some(child => _nodeContainsSymbol(child, symbol));
}
function _escapeIsabelleString(s) {
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function _isabelleBaseName(raw, fallback = 'x') {
let base = String(raw || '')
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/[^A-Za-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.toLowerCase();
if (!base) base = fallback;
if (/^[0-9]/.test(base)) base = `${fallback}_${base}`;
return base;
}
function _isabelleTheoryName(raw) {
const stem = path.basename(String(raw || 'RML_Export')).replace(/\.[^.]*$/, '');
const parts = stem
.split(/[^A-Za-z0-9]+/)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1).replace(/([a-z0-9])([A-Z])/g, '$1_$2'));
let name = parts.length ? parts.join('_') : 'RML_Export';
name = name.replace(/[^A-Za-z0-9_]/g, '_');
if (!/^[A-Z]/.test(name)) name = `RML_${name}`;
return name;
}
class IsabelleExportContext {
constructor(options = {}) {
this.theoryName = options.theoryName || _isabelleTheoryName(options.outputFile || options.file || 'RML_Export');
this.sourceFile = options.file || null;
this.typeNames = new Map();
this.termNames = new Map();
this.usedTypeNames = new Map();
this.usedTermNames = new Map();
this.typeDecls = [];
this.typeDeclSet = new Set();
this.datatypes = [];
this.datatypeNames = new Set();
this.datatypeConstructors = new Set();
this.consts = [];
this.constNames = new Set();
this.definitions = [];
this.definitionNames = new Set();
this.termTypes = new Map();
}
makeUnique(original, base, used) {
if (used.has(base) && used.get(base) === original) return base;
let candidate = base;
let index = 2;
while (used.has(candidate) && used.get(candidate) !== original) {
candidate = `${base}_${index}`;
index++;
}
used.set(candidate, original);
return candidate;
}
typeName(name) {
if (name === 'Prop') return 'bool';
if (this.typeNames.has(name)) return this.typeNames.get(name);
const base = `rml_${_isabelleBaseName(name, 'type')}`;
const unique = this.makeUnique(name, base, this.usedTypeNames);
this.typeNames.set(name, unique);
return unique;
}
termName(name) {
if (this.termNames.has(name)) return this.termNames.get(name);
const base = `rml_${_isabelleBaseName(name, 'term')}`;
const unique = this.makeUnique(name, base, this.usedTermNames);
this.termNames.set(name, unique);
return unique;
}
localName(name) {
let base = _isabelleBaseName(name, 'x');
if (ISABELLE_RESERVED.has(base)) base = `x_${base}`;
return base;
}
ensureTypedecl(name) {
if (name === 'Type' || name === 'Prop') return;
if (!this.typeDeclSet.has(name)) {
this.typeDeclSet.add(name);
this.typeDecls.push(name);
}
}
addConst(name, typeNode) {
if (this.datatypeConstructors.has(name) || this.definitionNames.has(name)) return;
if (!this.constNames.has(name)) {
this.constNames.add(name);
this.consts.push({ name, typeNode });
}
this.termTypes.set(name, typeNode);
}
addDefinition(name, typeNode, valueNode) {
if (this.definitionNames.has(name)) {
throw new IsabelleExportError(`duplicate definition "${name}"`);
}
this.definitionNames.add(name);
this.termTypes.set(name, typeNode);
this.definitions.push({ name, typeNode, valueNode });
}
addDatatype(decl) {
if (this.datatypeNames.has(decl.name)) {
throw new IsabelleExportError(`duplicate datatype "${decl.name}"`);
}
this.datatypeNames.add(decl.name);
for (const ctor of decl.constructors) {
this.datatypeConstructors.add(ctor.name);
this.termTypes.set(ctor.name, ctor.type);
}
this.datatypes.push(decl);
}
typeExpr(node) {
if (typeof node === 'string') {
if (node === 'Type') {
throw new IsabelleExportError('universe Type is not an Isabelle/HOL value type', node);
}
this.ensureTypedecl(node);
return this.typeName(node);
}
if (!Array.isArray(node)) {
throw new IsabelleExportError('unsupported type expression', node);
}
if (_isUniverseAnnotation(node)) {
throw new IsabelleExportError('universe levels cannot be exported as HOL value types', node);
}
if (node.length === 1 && node[0] === 'Prop') return 'bool';
if (node.length === 3 && node[0] === 'Pi') {
const binding = parseBinding(node[1]);
if (!binding) {
throw new IsabelleExportError('malformed Pi binder', node);
}
if (_nodeContainsSymbol(node[2], binding.paramName)) {
throw new IsabelleExportError(
`dependent Pi codomain mentions "${binding.paramName}", which is outside the Isabelle/HOL exporter subset`,
node,
);
}
const domain = this.typeExpr(binding.paramType);
const codomain = this.typeExpr(node[2]);
const left = Array.isArray(binding.paramType) &&
binding.paramType.length === 3 &&
binding.paramType[0] === 'Pi'
? `(${domain})`
: domain;
return `${left} => ${codomain}`;
}
throw new IsabelleExportError('unsupported type expression', node);
}
inferTermType(node, locals = new Map()) {
if (typeof node === 'string') {
if (locals.has(node)) return locals.get(node).typeNode;
if (this.termTypes.has(node)) return this.termTypes.get(node);
throw new IsabelleExportError(`cannot infer type of "${node}"`, node);
}
if (!Array.isArray(node)) {
throw new IsabelleExportError('cannot infer type of term', node);
}
if (node.length === 3 && node[0] === 'lambda') {
const binding = parseBinding(node[1]);
if (!binding) throw new IsabelleExportError('malformed lambda binder', node);
const nextLocals = new Map(locals);
nextLocals.set(binding.paramName, {
typeNode: binding.paramType,
localName: this.localName(binding.paramName),
});
return ['Pi', [binding.paramType, binding.paramName], this.inferTermType(node[2], nextLocals)];
}
if (node.length === 3 && node[0] === 'apply') {
const fnType = this.inferTermType(node[1], locals);
const flat = _flattenPi(fnType);
if (!flat || flat.params.length === 0) {
throw new IsabelleExportError('application head does not have a Pi type', node);
}
return _buildPi(flat.params.slice(1), flat.result);
}
if (node.length > 0 && typeof node[0] === 'string') {
const fnType = this.termTypes.get(node[0]);
const flat = fnType ? _flattenPi(fnType) : null;
if (flat && flat.params.length >= node.length - 1) {
return _buildPi(flat.params.slice(node.length - 1), flat.result);
}
}
throw new IsabelleExportError('cannot infer type of term', node);
}
termExpr(node, locals = new Map()) {
if (typeof node === 'string') {
if (isNum(node)) return node;
if (locals.has(node)) return locals.get(node).localName;
return this.termName(node);
}
if (!Array.isArray(node)) {
throw new IsabelleExportError('unsupported term expression', node);
}
if (node.length === 3 && node[0] === 'lambda') {
const binding = parseBinding(node[1]);
if (!binding) throw new IsabelleExportError('malformed lambda binder', node);
const localName = this.localName(binding.paramName);
const nextLocals = new Map(locals);
nextLocals.set(binding.paramName, { typeNode: binding.paramType, localName });
return `(%${localName}. ${this.termExpr(node[2], nextLocals)})`;
}
if (node.length === 3 && node[0] === 'apply') {
return `(${this.termExpr(node[1], locals)} ${this.termExpr(node[2], locals)})`;
}
if (node.length === 3 && node[1] === '=') {
return `(${this.termExpr(node[0], locals)} = ${this.termExpr(node[2], locals)})`;
}
if (node.length > 0) {
const parts = node.map(part => this.termExpr(part, locals));
return `(${parts.join(' ')})`;
}
throw new IsabelleExportError('empty term expression', node);
}
processDefinition(head, rhs, node) {
if (head === 'range' || head === 'valence' || _isOperatorHead(head)) {
throw new IsabelleExportError('probabilistic configuration and operator definitions are outside the Isabelle exporter subset', node);
}
if (rhs.length === 1 && isNum(rhs[0])) {
throw new IsabelleExportError('symbol probability priors are outside the Isabelle exporter subset', node);
}
if (_isTypeSelfDeclaration(head, rhs)) {
if (head !== 'Type') this.ensureTypedecl(head);
return;
}
if (_isTypedSelfDeclaration(head, rhs)) {
this.addConst(head, rhs[0]);
return;
}
if (rhs.length === 1 && Array.isArray(rhs[0])) {
this.addConst(head, rhs[0]);
return;
}
if (rhs.length === 3 && rhs[0] === 'lambda') {
const typeNode = this.inferTermType(rhs);
this.addDefinition(head, typeNode, rhs);
return;
}
throw new IsabelleExportError('unsupported top-level definition form', node);
}
processForm(rawForm) {
let node = rawForm;
while (Array.isArray(node) && node.length === 1 && Array.isArray(node[0])) {
node = node[0];
}
if (!Array.isArray(node) || node.length === 0) return;
if (_isProbabilisticAssignment(node)) {
throw new IsabelleExportError('probability assignments are outside the Isabelle exporter subset', node);
}
if (node[0] === '?' || _isUniverseAnnotation(node)) return;
if (node[0] === 'inductive') {
this.addDatatype(parseInductiveForm(node));
return;
}
if (typeof node[0] === 'string' && node[0].endsWith(':')) {
this.processDefinition(node[0].slice(0, -1), node.slice(1), node);
return;
}
throw new IsabelleExportError('unsupported top-level form', node);
}
renderTypedecls() {
const lines = [];
for (const name of this.typeDecls) {
if (this.datatypeNames.has(name)) continue;
lines.push(`typedecl ${this.typeName(name)}`);
}
return lines;
}
renderDatatypes() {
const sections = [];
for (const decl of this.datatypes) {
const lines = [`datatype ${this.typeName(decl.name)} =`];
decl.constructors.forEach((ctor, index) => {
const args = ctor.params.map(param => this.typeExpr(param.type));
const rhs = [this.termName(ctor.name), ...args].join(' ');
lines.push(` ${index === 0 ? '' : '| '}${rhs}`);
});
sections.push(lines.join('\n'));
}
return sections;
}
renderConsts() {
const lines = [];
for (const decl of this.consts) {
if (this.datatypeConstructors.has(decl.name) || this.definitionNames.has(decl.name)) continue;
lines.push(` ${this.termName(decl.name)} :: "${_escapeIsabelleString(this.typeExpr(decl.typeNode))}"`);
}
return lines.length ? ['consts', ...lines] : [];
}
renderDefinitions() {
const sections = [];
for (const decl of this.definitions) {
const name = this.termName(decl.name);
const type = _escapeIsabelleString(this.typeExpr(decl.typeNode));
const body = _escapeIsabelleString(this.termExpr(decl.valueNode));
sections.push(`definition ${name} :: "${type}" where\n "${name} = ${body}"`);
}
return sections;
}
render() {
const bodySections = [];
const typedecls = this.renderTypedecls();
if (typedecls.length) bodySections.push(typedecls.join('\n'));
const datatypes = this.renderDatatypes();
bodySections.push(...datatypes);
const consts = this.renderConsts();
if (consts.length) bodySections.push(consts.join('\n'));
bodySections.push(...this.renderDefinitions());
const lines = [
`theory ${this.theoryName}`,
' imports Main',
'begin',
'',
'(* Generated by RML Isabelle exporter. *)',
];
if (this.sourceFile) {
lines.push(`(* Source: ${_escapeIsabelleString(this.sourceFile)} *)`);
}
if (bodySections.length) {
lines.push('', bodySections.join('\n\n'));
}
lines.push('', 'end', '');
return lines.join('\n');
}
}
/**
* Export the supported typed LiNo fragment to Isabelle/HOL source text.
*/
function exportIsabelle(sourceText, options = {}) {
const ctx = new IsabelleExportContext(options);
let forms;
try {
forms = parseLinoForms(sourceText);
} catch (err) {
throw new IsabelleExportError(`LiNo parse failure: ${err && err.message ? err.message : String(err)}`);
}
for (const form of forms) ctx.processForm(form);
return ctx.render();
}
// ---------- Runner ----------
/**
* Compatibility wrapper that evaluates LiNo text and returns only query values.
*/
function run(text, options){
return evaluate(text, options).results;
}
// CLI (runs only when invoked directly, not when imported as a library).
// The REPL subcommand lives in `./rml-repl.mjs` so we can `await import` it
// without triggering an ESM circular-dependency deadlock.
function _printMainUsage() {
console.error('Usage: rml [--trace] <kb.lino> | rml repl | rml extract <js|rust> <kb.lino> | rml export <lean|rocq|isabelle> <file.lino> [-o <file>] [--theory <Name>]');
}
async function runCli() {
const argv = process.argv.slice(2);
let trace = false;
const positionals = [];
for (const arg of argv) {
if (arg === '--trace') trace = true;
else positionals.push(arg);
}
const arg = positionals[0];
if (!arg) {
_printMainUsage();
process.exit(1);
}
if (arg === 'extract') {
const target = positionals[1];
const file = positionals[2];
if (!target || !file) {
console.error('Usage: rml extract <js|rust> <kb.lino>');
process.exit(1);
}
const text = fs.readFileSync(file, 'utf8');
try {
process.stdout.write(extractProgram(text, target));
process.stdout.write('\n');
} catch (err) {
console.error(err && err.message ? err.message : String(err));
process.exit(1);
}
return;
}
if (arg === 'export') {
const status = await runExportCli(positionals.slice(1));
process.exit(status);
}
if (arg === 'repl') {
const replUrl = new URL('./rml-repl.mjs', import.meta.url).href;
const { runRepl } = await import(replUrl);
await runRepl();
return;
}
const text = fs.readFileSync(arg, 'utf8');
const out = evaluate(text, { file: arg, trace });
if (trace && out.trace) {
for (const event of out.trace) {
console.error(formatTraceEvent(event));
}
}
for (const v of out.results) {
if (typeof v === 'string') {
console.log(v);
} else {
console.log(String(+v.toFixed(6)).replace(/\.0+$/,''));
}
}
for (const diag of out.diagnostics) {
console.error(formatDiagnostic(diag, text));
}
if (out.diagnostics.length > 0) process.exit(1);
}
async function runExportCli(args) {
const [target, input] = args;
if (args.length < 2 || (target !== 'lean' && target !== 'rocq' && target !== 'isabelle')) {
console.error('Usage: rml export <lean|rocq|isabelle> <file.lino> [-o <file>] [--theory <Name>]');
return 2;
}
let output = null;
let theoryName = null;
for (let i = 2; i < args.length; i++) {
if ((args[i] === '-o' || args[i] === '--output') && i + 1 < args.length) {
output = args[i + 1];
i++;
continue;
}
if (target === 'isabelle' && args[i] === '--theory' && i + 1 < args.length) {
theoryName = args[i + 1];
i++;
continue;
}
console.error(`Unknown export option: ${args[i]}`);
console.error(`Usage: rml export ${target} <file.lino> [-o <file>]${target === 'isabelle' ? ' [--theory <Name>]' : ''}`);
return 2;
}
if (target === 'lean' && !output) {
console.error('Usage: rml export lean <file.lino> -o <file.lean>');
return 2;
}
let text;
try {
text = fs.readFileSync(input, 'utf8');
} catch (err) {
console.error(`Error reading ${input}: ${err.message}`);
return 1;
}
let rendered;
if (target === 'lean') {
const { exportLean } = await import(new URL('./lean-export.mjs', import.meta.url).href);
const out = exportLean(text, { file: input });
if (out.diagnostics.length > 0) {
for (const diag of out.diagnostics) {
console.error(formatDiagnostic(diag, text));
}
return 1;
}
rendered = out.source;
} else if (target === 'rocq') {
const { exportRocq } = await import(new URL('./rml-rocq.mjs', import.meta.url).href);
rendered = exportRocq(text, { sourcePath: input });
} else {
rendered = exportIsabelle(text, {
file: input,
outputFile: output,
theoryName: theoryName || _isabelleTheoryName(output || input),
});
}
try {
if (output) fs.writeFileSync(output, rendered, 'utf8');
else process.stdout.write(rendered);
} catch (err) {
console.error(`Error writing ${output}: ${err.message}`);
return 1;
}
return 0;
}
if (import.meta.url === `file://${process.argv[1]}`) {
runCli().catch(err => {
console.error(err && err.stack ? err.stack : err);
process.exit(1);
});
}
export {
run,
evaluate,
evaluateFile,
Diagnostic,
RmlError,
TraceEvent,
formatDiagnostic,
formatTraceEvent,
computeFormSpans,
extractLiterateLino,
parseLino,
tokenizeOne,
parseOne,
Env,
evalNode,
buildProof,
runTactics,
rewrite,
simplify,
goalToTptp,
parseAtpStatus,
quantize,
decRound,
keyOf,
isNum,
isStructurallySame,
isConvertible,
whnf,
nf,
parseBinding,
parseBindings,
subst,
substitute,
synth,
check,
isTotal,
isTerminating,
parseDefineForm,
isCovered,
parseInductiveForm,
buildEliminatorType,
parseCoinductiveForm,
buildCorecursorType,
automaticSequencesDomainPlugin,
decideAutomaticSequenceTheorem,
formalizeSelectedInterpretation,
evaluateFormalization,
extractProgram,
exportIsabelle,
parseModeFlag,
parseRootConstructForm,
parseFoundationForm,
formatFoundationReport,
parseRuleForm,
parseProofAssumptionForm,
parseProofObjectForm,
matchProofPattern,
checkProofObject,
parseStrictFoundationForm,
parseAllowHostPrimitiveForm,
scanPureLinksOffenders,
buildDependencyGraph,
encodeAnum,
decodeAnum,
};