/** * Deterministic first-order logic evaluation engine. * * Evaluates Imhotep logic formulas exhaustively over a finite geometry * world. The engine supports: * * - Universal quantification (forall) with short-circuit on first failure * - Existential quantification (exists) with short-circuit on first success * - Boolean connectives: And, Or, Not, Implies * - Predicate calls against a registered predicate registry * * Invariants: * 1. Closed-world semantics: every domain is finite and enumerated. * 2. No silent defaults: missing facts produce indeterminate results. * 3. Rich diagnostics: every failing assignment is reported with proof ids * and trace events. * 4. Deterministic vs sampled is explicit in the result mode. */ import { performance } from 'node:perf_hooks'; import type { DiagnosticCode } from 'imhotep-core' import type { GeometryWorld, Proof, Witness, Diagnostic, TraceEvent, } from './registry.js'; import type { PredicateEvaluator, PredicateResult, PredicateTruth, } from './predicates.js'; import { getPredicateEvaluator, registerDefaultPredicates, PredicateRegistry, globalPredicateRegistry, } from './predicates.js'; import { BindingEnv, type BindingEntry, type TupleBindingSpec, enumerateTuples, tupleToBindings, } from './bindings.js'; import { createCartesianIterator, type JoinSpec, } from './joins.js'; import type { FormulaNode, ForAllFormula, ExistsFormula, AndFormula, OrFormula, NotFormula, ImpliesFormula, PredicateCall, DomainRef, DomainValue, } from 'imhotep-core'; // --------------------------------------------------------------------------- // Evaluation Result Contract // --------------------------------------------------------------------------- export type EvaluationMode = 'scene-determinate' | 'scene-indeterminate'; export interface DeterministicSceneEvaluation { mode: EvaluationMode; sceneId: string; passed: boolean; formulaResults: FormulaResult[]; proofs: Proof[]; diagnostics: Diagnostic[]; trace: TraceEvent[]; /** Failing variable assignments for diagnostics. */ failingAssignments?: Record[]; } export interface FormulaResult { formulaId: string; outcome: 'pass' | 'fail' | 'indeterminate'; truth: 'determinate' | 'indeterminate'; witness?: number[]; metrics?: Record; } // --------------------------------------------------------------------------- // Domain Resolver // --------------------------------------------------------------------------- export interface DomainResolver { resolve(domain: DomainRef, env?: BindingEnv): DomainValue | undefined; } // --------------------------------------------------------------------------- // Engine Options // --------------------------------------------------------------------------- export interface LogicEngineOptions { skipMissingPredicates?: boolean; maxDiagnostics?: number; predicateRegistry?: PredicateRegistry; /** Enable detailed trace logging. Default true. Disable for production speed. */ trace?: boolean; } // --------------------------------------------------------------------------- // Internal Evaluation State // --------------------------------------------------------------------------- interface EvalState { world: GeometryWorld; resolver: DomainResolver; options: LogicEngineOptions; diagnostics: Diagnostic[]; trace: TraceEvent[]; proofs: Proof[]; proofCounter: number; formulaCounter: number; predicateRegistry: PredicateRegistry; } // --------------------------------------------------------------------------- // Proof Generation // --------------------------------------------------------------------------- function nextProofId(state: EvalState): string { return `proof_${++state.proofCounter}`; } function nextFormulaId(state: EvalState): string { return `formula_${++state.formulaCounter}`; } function makeProof( state: EvalState, formulaId: string, outcome: 'pass' | 'fail' | 'indeterminate', truth: 'determinate' | 'indeterminate', witness?: number[], usedFacts: number[] = [], ): Proof { // Map indeterminate outcome to 'error' for ClauseStatus compatibility. const clauseOutcome = outcome === 'indeterminate' ? 'error' : outcome; return { proofId: nextProofId(state), clauseId: formulaId, outcome: clauseOutcome, truth, usedFacts, derivedFacts: [], witness: witness ? { subjectId: witness[0], referenceId: witness[1], snapshotId: state.world.snapshotId, } : undefined, }; } function addTrace( state: EvalState, phase: string, clauseId?: string, payload?: Record, ): void { if (state.options.trace === false) return; state.trace.push({ traceEventId: `trace_${state.trace.length}`, phase, at: performance.now(), refs: clauseId ? { clauseId } : {}, payload: payload ?? {}, }); } function addDiagnostic( state: EvalState, code: DiagnosticCode, severity: 'error' | 'warning' | 'info', message: string, clauseId?: string, ): void { const max = state.options.maxDiagnostics ?? 100; if (state.diagnostics.length >= max) { return; } state.diagnostics.push({ code, severity, category: severity === 'error' ? 'contract-failure' : 'internal-error', message, clauseId, }); } // --------------------------------------------------------------------------- // Formula Evaluation // --------------------------------------------------------------------------- function evaluateFormula( formula: FormulaNode, env: BindingEnv, state: EvalState, ): FormulaResult { const formulaId = nextFormulaId(state); if (state.options.trace !== false) { addTrace(state, 'evaluate-formula', formulaId, { kind: formula.kind, bindings: env.toObject(), }); } switch (formula.kind) { case 'forall': return evaluateForAll(formula as ForAllFormula, env, state, formulaId); case 'exists': return evaluateExists(formula as ExistsFormula, env, state, formulaId); case 'and': return evaluateAnd(formula as AndFormula, env, state, formulaId); case 'or': return evaluateOr(formula as OrFormula, env, state, formulaId); case 'not': return evaluateNot(formula as NotFormula, env, state, formulaId); case 'implies': return evaluateImplies(formula as ImpliesFormula, env, state, formulaId); case 'predicate': return evaluatePredicate(formula as PredicateCall, env, state, formulaId); default: addDiagnostic( state, 'IMH_LOGIC_UNKNOWN_FORMULA_KIND', 'error', `Unknown formula kind: ${(formula as FormulaNode).kind}`, formulaId, ); return { formulaId, outcome: 'indeterminate', truth: 'indeterminate', }; } } function evaluateForAll( formula: ForAllFormula, env: BindingEnv, state: EvalState, formulaId: string, ): FormulaResult { addTrace(state, 'evaluate-forall-start', formulaId); // Resolve domains and build join specs. const specs: JoinSpec[] = []; for (const binding of formula.bindings) { const domain = state.resolver.resolve(binding.domain, env); if (!domain) { addDiagnostic( state, 'IMH_LOGIC_DOMAIN_UNRESOLVED', 'error', `Domain ${binding.domain.domain} could not be resolved.`, formulaId, ); const result: FormulaResult = { formulaId, outcome: 'indeterminate', truth: 'indeterminate', }; state.proofs.push(makeProof(state, formulaId, 'indeterminate', 'indeterminate')); return result; } if (domain.subjectIds.length === 0) { // Empty domain: forall over empty set is vacuously true. // When ANY quantified variable has an empty domain, the entire // universal quantification is vacuously true (no tuples to check). addDiagnostic( state, 'IMH_LOGIC_VACUOUS_FORALL', 'info', `Forall over empty domain "${binding.domain.domain}" (selector: ${binding.domain.selector ?? 'none'}) is vacuously true.`, formulaId, ); addTrace(state, 'evaluate-forall-empty-domain', formulaId, { domainId: domain.domainId, }); const result: FormulaResult = { formulaId, outcome: 'pass', truth: 'determinate', }; state.proofs.push(makeProof(state, formulaId, 'pass', 'determinate')); addTrace(state, 'evaluate-forall-vacuous', formulaId); return result; } for (const varName of binding.variables) { specs.push({ variableName: varName, domain, }); } } if (specs.length === 0) { // No variables or all domains empty: vacuously true. const result: FormulaResult = { formulaId, outcome: 'pass', truth: 'determinate', }; state.proofs.push(makeProof(state, formulaId, 'pass', 'determinate')); addTrace(state, 'evaluate-forall-vacuous', formulaId); return result; } // Enumerate all tuples exhaustively. const iterator = createCartesianIterator(specs); let failed = false; let indeterminate = false; let failingTuple: Record | undefined; let failingMetrics: Record | undefined; iterator.forEach((tuple, indices) => { // Build extended environment. const entries: BindingEntry[] = []; for (let i = 0; i < specs.length; i++) { entries.push({ variableName: specs[i].variableName, subjectId: tuple[i], }); } const childEnv = env.bindTuple(entries); const bodyResult = evaluateFormula(formula.body, childEnv, state); if (bodyResult.outcome === 'indeterminate') { indeterminate = true; addTrace(state, 'evaluate-forall-indeterminate', formulaId, { tuple: Object.fromEntries(specs.map((s, i) => [s.variableName, tuple[i]])), bodyOutcome: bodyResult.outcome, }); return false; // Short-circuit. } if (bodyResult.outcome === 'fail') { failed = true; failingTuple = {}; failingMetrics = bodyResult.metrics; for (let i = 0; i < specs.length; i++) { failingTuple[specs[i].variableName] = tuple[i]; } addTrace(state, 'evaluate-forall-failure', formulaId, { failingTuple, bodyOutcome: bodyResult.outcome, }); return false; // Short-circuit. } return true; }); let outcome: 'pass' | 'fail' | 'indeterminate'; if (indeterminate) { outcome = 'indeterminate'; } else if (failed) { outcome = 'fail'; } else { outcome = 'pass'; } const result: FormulaResult = { formulaId, outcome, truth: outcome === 'indeterminate' ? 'indeterminate' : 'determinate', metrics: failingMetrics, }; state.proofs.push(makeProof(state, formulaId, outcome, 'determinate')); addTrace(state, 'evaluate-forall-end', formulaId, { outcome }); return result; } function evaluateExists( formula: ExistsFormula, env: BindingEnv, state: EvalState, formulaId: string, ): FormulaResult { addTrace(state, 'evaluate-exists-start', formulaId); // Resolve domains and build join specs. const specs: JoinSpec[] = []; for (const binding of formula.bindings) { const domain = state.resolver.resolve(binding.domain, env); if (!domain) { addDiagnostic( state, 'IMH_LOGIC_DOMAIN_UNRESOLVED', 'error', `Domain ${binding.domain.domain} could not be resolved.`, formulaId, ); const result: FormulaResult = { formulaId, outcome: 'indeterminate', truth: 'indeterminate', }; state.proofs.push(makeProof(state, formulaId, 'indeterminate', 'indeterminate')); return result; } if (domain.subjectIds.length === 0) { // Empty domain: exists over empty set is false. // When ANY quantified variable has an empty domain, the entire // existential quantification is false (no witness possible). addDiagnostic( state, 'IMH_LOGIC_EMPTY_DOMAIN_EXISTS', 'info', `Exists over empty domain "${binding.domain.domain}" (selector: ${binding.domain.selector ?? 'none'}) is false.`, formulaId, ); const result: FormulaResult = { formulaId, outcome: 'fail', truth: 'determinate', }; state.proofs.push(makeProof(state, formulaId, 'fail', 'determinate')); addTrace(state, 'evaluate-exists-empty', formulaId); return result; } for (const varName of binding.variables) { specs.push({ variableName: varName, domain, }); } } if (specs.length === 0) { // No variables or all domains empty: exists is false. const result: FormulaResult = { formulaId, outcome: 'fail', truth: 'determinate', }; state.proofs.push(makeProof(state, formulaId, 'fail', 'determinate')); addTrace(state, 'evaluate-exists-empty', formulaId); return result; } // Enumerate tuples looking for a witness. const iterator = createCartesianIterator(specs); let found = false; let indeterminate = false; let witnessTuple: Record | undefined; iterator.forEach((tuple, indices) => { const entries: BindingEntry[] = []; for (let i = 0; i < specs.length; i++) { entries.push({ variableName: specs[i].variableName, subjectId: tuple[i], }); } const childEnv = env.bindTuple(entries); const bodyResult = evaluateFormula(formula.body, childEnv, state); if (bodyResult.outcome === 'pass') { found = true; witnessTuple = {}; for (let i = 0; i < specs.length; i++) { witnessTuple[specs[i].variableName] = tuple[i]; } addTrace(state, 'evaluate-exists-witness', formulaId, { witnessTuple, }); return false; // Short-circuit. } if (bodyResult.outcome === 'indeterminate') { indeterminate = true; } return true; }); let outcome: 'pass' | 'fail' | 'indeterminate'; if (found) { outcome = 'pass'; } else if (indeterminate) { outcome = 'indeterminate'; } else { outcome = 'fail'; } const result: FormulaResult = { formulaId, outcome, truth: outcome === 'indeterminate' ? 'indeterminate' : 'determinate', witness: witnessTuple ? Object.values(witnessTuple) : undefined, }; state.proofs.push(makeProof(state, formulaId, outcome, 'determinate', result.witness)); addTrace(state, 'evaluate-exists-end', formulaId, { outcome }); return result; } function evaluateAnd( formula: AndFormula, env: BindingEnv, state: EvalState, formulaId: string, ): FormulaResult { addTrace(state, 'evaluate-and-start', formulaId); const leftResult = evaluateFormula(formula.left, env, state); if (leftResult.outcome === 'indeterminate') { const result: FormulaResult = { formulaId, outcome: 'indeterminate', truth: 'indeterminate', }; state.proofs.push(makeProof(state, formulaId, 'indeterminate', 'indeterminate')); addTrace(state, 'evaluate-and-short-circuit', formulaId, { side: 'left', reason: 'indeterminate' }); return result; } if (leftResult.outcome === 'fail') { const result: FormulaResult = { formulaId, outcome: 'fail', truth: 'determinate', metrics: leftResult.metrics, }; state.proofs.push(makeProof(state, formulaId, 'fail', 'determinate')); addTrace(state, 'evaluate-and-short-circuit', formulaId, { side: 'left', reason: 'fail' }); return result; } const rightResult = evaluateFormula(formula.right, env, state); let outcome: 'pass' | 'fail' | 'indeterminate'; let truth: 'determinate' | 'indeterminate'; let metrics: Record | undefined; if (rightResult.outcome === 'pass') { outcome = 'pass'; truth = 'determinate'; } else if (rightResult.outcome === 'indeterminate') { outcome = 'indeterminate'; truth = 'indeterminate'; metrics = rightResult.metrics; } else { outcome = 'fail'; truth = 'determinate'; metrics = rightResult.metrics; } const result: FormulaResult = { formulaId, outcome, truth, metrics, }; state.proofs.push(makeProof(state, formulaId, result.outcome, result.truth)); addTrace(state, 'evaluate-and-end', formulaId, { outcome }); return result; } function evaluateOr( formula: OrFormula, env: BindingEnv, state: EvalState, formulaId: string, ): FormulaResult { addTrace(state, 'evaluate-or-start', formulaId); const leftResult = evaluateFormula(formula.left, env, state); if (leftResult.outcome === 'pass') { const result: FormulaResult = { formulaId, outcome: 'pass', truth: 'determinate', metrics: leftResult.metrics, }; state.proofs.push(makeProof(state, formulaId, 'pass', 'determinate')); addTrace(state, 'evaluate-or-short-circuit', formulaId, { side: 'left', reason: 'pass' }); return result; } const rightResult = evaluateFormula(formula.right, env, state); let outcome: 'pass' | 'fail' | 'indeterminate'; let truth: 'determinate' | 'indeterminate'; let metrics: Record | undefined; if (rightResult.outcome === 'pass') { outcome = 'pass'; truth = 'determinate'; metrics = rightResult.metrics; } else if (leftResult.outcome === 'indeterminate' || rightResult.outcome === 'indeterminate') { outcome = 'indeterminate'; truth = 'indeterminate'; metrics = rightResult.metrics ?? leftResult.metrics; } else { outcome = 'fail'; truth = 'determinate'; metrics = { ...leftResult.metrics, ...rightResult.metrics }; } const result: FormulaResult = { formulaId, outcome, truth, metrics, }; state.proofs.push(makeProof(state, formulaId, result.outcome, result.truth)); addTrace(state, 'evaluate-or-end', formulaId, { outcome }); return result; } function evaluateNot( formula: NotFormula, env: BindingEnv, state: EvalState, formulaId: string, ): FormulaResult { addTrace(state, 'evaluate-not-start', formulaId); // Capture diagnostic count before evaluating operand so we can // suppress inner diagnostics when the not inverts a failure into a pass. const diagnosticCountBefore = state.diagnostics.length; const operandResult = evaluateFormula(formula.operand, env, state); let outcome: 'pass' | 'fail' | 'indeterminate'; let truth: 'determinate' | 'indeterminate'; let metrics: Record | undefined; if (operandResult.outcome === 'pass') { outcome = 'fail'; truth = 'determinate'; metrics = operandResult.metrics; } else if (operandResult.outcome === 'fail') { outcome = 'pass'; truth = 'determinate'; // The inner predicate failed, but not() inverts it to a pass. // Remove diagnostics produced by the inner evaluation since they // describe a condition that the user explicitly negated. state.diagnostics.splice(diagnosticCountBefore, state.diagnostics.length - diagnosticCountBefore); } else { outcome = 'indeterminate'; truth = 'indeterminate'; metrics = operandResult.metrics; } const result: FormulaResult = { formulaId, outcome, truth, metrics, }; state.proofs.push(makeProof(state, formulaId, result.outcome, result.truth)); addTrace(state, 'evaluate-not-end', formulaId, { outcome }); return result; } function evaluateImplies( formula: ImpliesFormula, env: BindingEnv, state: EvalState, formulaId: string, ): FormulaResult { addTrace(state, 'evaluate-implies-start', formulaId); const antecedentResult = evaluateFormula(formula.antecedent, env, state); if (antecedentResult.outcome === 'indeterminate') { const result: FormulaResult = { formulaId, outcome: 'indeterminate', truth: 'indeterminate', }; state.proofs.push(makeProof(state, formulaId, 'indeterminate', 'indeterminate')); addTrace(state, 'evaluate-implies-indeterminate', formulaId, { reason: 'antecedent' }); return result; } if (antecedentResult.outcome === 'fail') { // Vacuously true. const result: FormulaResult = { formulaId, outcome: 'pass', truth: 'determinate', }; state.proofs.push(makeProof(state, formulaId, 'pass', 'determinate')); addTrace(state, 'evaluate-implies-vacuous', formulaId); return result; } const consequentResult = evaluateFormula(formula.consequent, env, state); let outcome: 'pass' | 'fail' | 'indeterminate'; let truth: 'determinate' | 'indeterminate'; let metrics: Record | undefined; if (consequentResult.outcome === 'pass') { outcome = 'pass'; truth = 'determinate'; } else if (consequentResult.outcome === 'indeterminate') { outcome = 'indeterminate'; truth = 'indeterminate'; metrics = consequentResult.metrics; } else { outcome = 'fail'; truth = 'determinate'; metrics = consequentResult.metrics; } const result: FormulaResult = { formulaId, outcome, truth, metrics, }; state.proofs.push(makeProof(state, formulaId, result.outcome, result.truth)); addTrace(state, 'evaluate-implies-end', formulaId, { outcome }); return result; } function evaluatePredicate( formula: PredicateCall, env: BindingEnv, state: EvalState, formulaId: string, ): FormulaResult { addTrace(state, 'evaluate-predicate-start', formulaId, { predicate: formula.predicate, }); const evaluator = state.predicateRegistry.get(formula.predicate); if (!evaluator) { const msg = `No predicate evaluator registered for: ${formula.predicate}`; addDiagnostic( state, 'IMH_LOGIC_PREDICATE_MISSING', 'error', msg, formulaId, ); const result: FormulaResult = { formulaId, outcome: 'indeterminate', truth: 'indeterminate', }; state.proofs.push(makeProof(state, formulaId, 'indeterminate', 'indeterminate')); return result; } // Resolve arguments from bindings. const tuple: number[] = []; for (const arg of formula.args) { switch (arg.type) { case 'VariableRef': { const subjectId = env.lookup(arg.name); if (subjectId === undefined) { addDiagnostic( state, 'IMH_LOGIC_UNBOUND_VARIABLE', 'error', `Variable ${arg.name} is not bound in the current scope.`, formulaId, ); const result: FormulaResult = { formulaId, outcome: 'indeterminate', truth: 'indeterminate', }; state.proofs.push(makeProof(state, formulaId, 'indeterminate', 'indeterminate')); return result; } tuple.push(subjectId); break; } case 'AccessorTerm': { const subjectId = env.lookup(arg.variable); if (subjectId === undefined) { addDiagnostic( state, 'IMH_LOGIC_UNBOUND_VARIABLE', 'error', `Variable ${arg.variable} is not bound in the current scope.`, formulaId, ); const result: FormulaResult = { formulaId, outcome: 'indeterminate', truth: 'indeterminate', }; state.proofs.push(makeProof(state, formulaId, 'indeterminate', 'indeterminate')); return result; } // For now, accessors evaluate to the subject id itself. // Property access (e.g., width, height) is handled by the predicate. tuple.push(subjectId); break; } default: addDiagnostic( state, 'IMH_LOGIC_UNSUPPORTED_TERM', 'error', `Unsupported term type: ${arg.type}`, formulaId, ); const result: FormulaResult = { formulaId, outcome: 'indeterminate', truth: 'indeterminate', }; state.proofs.push(makeProof(state, formulaId, 'indeterminate', 'indeterminate')); return result; } } const predicateResult = evaluator.evaluateTuple(state.world, tuple, (formula as any).options); // Propagate predicate-level diagnostics to engine state. if (predicateResult.diagnostics) { for (const d of predicateResult.diagnostics) { addDiagnostic(state, d.code, d.severity, d.message, formulaId); } } const outcome: 'pass' | 'fail' | 'indeterminate' = predicateResult.truth === 'true' ? 'pass' : predicateResult.truth === 'false' ? 'fail' : 'indeterminate'; const result: FormulaResult = { formulaId, outcome, truth: outcome === 'indeterminate' ? 'indeterminate' : 'determinate', witness: predicateResult.witness?.subjectIds, metrics: predicateResult.metrics, }; state.proofs.push( makeProof( state, formulaId, result.outcome, result.truth, result.witness, ), ); addTrace(state, 'evaluate-predicate-end', formulaId, { predicate: formula.predicate, outcome, }); return result; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export interface LogicEngineInput { formula: FormulaNode; world: GeometryWorld; resolver: DomainResolver; options?: LogicEngineOptions; } /** * Evaluate a logic formula deterministically against a geometry world. * * This is the entry point for Stream 1 deterministic scene evaluation. */ export function evaluateLogic(input: LogicEngineInput): DeterministicSceneEvaluation { const { formula, world, resolver, options = {} } = input; const state: EvalState = { world, resolver, options, diagnostics: [], trace: [], proofs: [], proofCounter: 0, formulaCounter: 0, predicateRegistry: options.predicateRegistry ?? globalPredicateRegistry, }; addTrace(state, 'evaluate-logic-start'); const env = new BindingEnv(); const formulaResult = evaluateFormula(formula, env, state); addTrace(state, 'evaluate-logic-end'); const mode: EvaluationMode = formulaResult.outcome === 'indeterminate' ? 'scene-indeterminate' : 'scene-determinate'; const failingAssignments: Record[] = []; if (formulaResult.outcome === 'fail') { // Collect failing assignments from trace events. for (const event of state.trace) { if (event.payload && typeof event.payload === 'object' && 'failingTuple' in event.payload) { const ft = event.payload.failingTuple as Record; failingAssignments.push(ft); } } } return { mode, sceneId: world.sceneId, passed: formulaResult.outcome === 'pass', formulaResults: [formulaResult], proofs: state.proofs, diagnostics: state.diagnostics, trace: state.trace, failingAssignments: failingAssignments.length > 0 ? failingAssignments : undefined, }; } /** * Convenience: evaluate a formula with default predicates registered. */ export function evaluateLogicWithDefaults(input: LogicEngineInput): DeterministicSceneEvaluation { registerDefaultPredicates(); return evaluateLogic(input); }