v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)

This commit is contained in:
John Dvorak
2025-08-15 10:00:00 -07:00
commit 92deb689cd
321 changed files with 79170 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
{
"name": "imhotep-solver",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/anomalyco/imhotep.git"
},
"engines": {
"node": ">=18.0.0"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "node --test dist/**/*.test.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"imhotep-core": "^1.0.0",
"imhotep-geometry": "^1.0.0"
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Alignment evaluators:
* alignedWith, centeredWithin
*
* Alignment is evaluated against border-box geometry in the shared frame.
*/
import {
type GeometryWorld,
type ClauseDescriptor,
type ClauseResult,
} from './registry.js';
function findBoxIndex(world: GeometryWorld, subjectId: number): number {
const { boxes } = world;
for (let i = 0; i < boxes.boxId.length; i++) {
if (boxes.subjectId[i] === subjectId) {
return i;
}
}
return -1;
}
function getBorderRect(world: GeometryWorld, subjectId: number) {
const idx = findBoxIndex(world, subjectId);
if (idx === -1) return null;
const b = world.boxes;
return {
left: b.borderLeft[idx],
top: b.borderTop[idx],
right: b.borderRight[idx],
bottom: b.borderBottom[idx],
};
}
function result(
clauseId: string,
status: 'pass' | 'fail' | 'error',
truth: 'determinate' | 'indeterminate',
metrics?: Record<string, number>,
witness?: Record<string, number | undefined>,
): ClauseResult {
return {
clauseId,
status,
truth,
metrics,
witness: witness
? {
subjectId: witness.subjectId,
referenceId: witness.referenceId,
frameId: witness.frameId,
}
: undefined,
};
}
export function evaluateAlignedWith(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, referenceRef, clauseId, options } = clause;
if (subjectRef === undefined || referenceRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const sRect = getBorderRect(world, subjectRef);
const rRect = getBorderRect(world, referenceRef);
if (!sRect || !rRect) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const axis = (options?.axis as string) ?? 'centerY';
const tolerance = (options?.tolerance as number) ?? 0;
let delta = 0;
switch (axis) {
case 'left':
delta = Math.abs(sRect.left - rRect.left);
break;
case 'right':
delta = Math.abs(sRect.right - rRect.right);
break;
case 'top':
delta = Math.abs(sRect.top - rRect.top);
break;
case 'bottom':
delta = Math.abs(sRect.bottom - rRect.bottom);
break;
case 'centerX':
delta = Math.abs(
(sRect.left + sRect.right) / 2 - (rRect.left + rRect.right) / 2,
);
break;
case 'centerY':
delta = Math.abs(
(sRect.top + sRect.bottom) / 2 - (rRect.top + rRect.bottom) / 2,
);
break;
default:
delta = Math.abs(
(sRect.top + sRect.bottom) / 2 - (rRect.top + rRect.bottom) / 2,
);
}
const pass = delta <= tolerance;
return result(
clauseId,
pass ? 'pass' : 'fail',
'determinate',
{ delta, tolerance, axis: axis === 'centerY' ? 0 : axis === 'centerX' ? 1 : 2 },
{ subjectId: subjectRef, referenceId: referenceRef },
);
}
export function evaluateCenteredWithin(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, referenceRef, clauseId, options } = clause;
if (subjectRef === undefined || referenceRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const sRect = getBorderRect(world, subjectRef);
const rRect = getBorderRect(world, referenceRef);
if (!sRect || !rRect) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const tolerance = (options?.tolerance as number) ?? 0;
const centerXSubject = (sRect.left + sRect.right) / 2;
const centerYSubject = (sRect.top + sRect.bottom) / 2;
const centerXRef = (rRect.left + rRect.right) / 2;
const centerYRef = (rRect.top + rRect.bottom) / 2;
const deltaX = Math.abs(centerXSubject - centerXRef);
const deltaY = Math.abs(centerYSubject - centerYRef);
const pass = deltaX <= tolerance && deltaY <= tolerance;
return result(
clauseId,
pass ? 'pass' : 'fail',
'determinate',
{ deltaX, deltaY, tolerance },
{ subjectId: subjectRef, referenceId: referenceRef },
);
}
+184
View File
@@ -0,0 +1,184 @@
/**
* Variable binding environment for first-order logic evaluation.
*
* Bindings map variable names to subject ids in the geometry world.
* Tuple bindings support multi-variable quantification by maintaining
* a flat array of subject ids indexed by variable position.
*
* Invariant: the environment is immutable; every extension produces a
* new BindingEnv sharing the parent map via prototype chain or copy.
*/
// ---------------------------------------------------------------------------
// Binding Entry
// ---------------------------------------------------------------------------
export interface BindingEntry {
variableName: string;
subjectId: number;
domainId?: string;
}
// ---------------------------------------------------------------------------
// Binding Environment
// ---------------------------------------------------------------------------
export class BindingEnv {
private readonly parent: BindingEnv | null;
private readonly entries: Map<string, number>;
private _size: number;
constructor(parent: BindingEnv | null = null, newEntries: BindingEntry[] = []) {
this.parent = parent;
this.entries = new Map<string, number>();
for (const entry of newEntries) {
this.entries.set(entry.variableName, entry.subjectId);
}
this._size = parent ? parent._size + this.entries.size : this.entries.size;
}
/**
* Look up the subject id bound to a variable name.
* Returns undefined if the variable is not in scope.
*/
lookup(variableName: string): number | undefined {
let env: BindingEnv | null = this;
while (env) {
const value = env.entries.get(variableName);
if (value !== undefined) {
return value;
}
env = env.parent;
}
return undefined;
}
/**
* Extend this environment with a single binding.
*/
bind(variableName: string, subjectId: number): BindingEnv {
return new BindingEnv(this, [{ variableName, subjectId }]);
}
/**
* Extend this environment with multiple bindings (tuple binding).
*/
bindTuple(newEntries: BindingEntry[]): BindingEnv {
return new BindingEnv(this, newEntries);
}
/**
* Return all bound variable names in this environment.
*/
variables(): string[] {
const vars = new Set<string>();
let env: BindingEnv | null = this;
while (env) {
for (const name of env.entries.keys()) {
vars.add(name);
}
env = env.parent;
}
return Array.from(vars);
}
/**
* Return the number of bound variables.
*/
size(): number {
return this._size;
}
/**
* Produce a plain object representation for diagnostics.
*/
toObject(): Record<string, number> {
const result: Record<string, number> = {};
let env: BindingEnv | null = this;
while (env) {
for (const [name, id] of env.entries) {
if (!(name in result)) {
result[name] = id;
}
}
env = env.parent;
}
return result;
}
}
// ---------------------------------------------------------------------------
// Tuple Binding Logic
// ---------------------------------------------------------------------------
export interface TupleBindingSpec {
variableNames: string[];
domainSubjectIds: Uint32Array;
}
/**
* Generate all tuple combinations from a set of domain bindings.
*
* This is data-oriented: it iterates flat domain arrays and yields
* pre-allocated tuple arrays to avoid per-tuple allocation.
*
* @param specs - Array of binding specs, one per quantified variable.
* @param onTuple - Callback invoked for each tuple combination.
* Return false to short-circuit enumeration.
*/
export function enumerateTuples(
specs: TupleBindingSpec[],
onTuple: (tuple: number[]) => boolean,
): void {
if (specs.length === 0) {
onTuple([]);
return;
}
const tuple: number[] = new Array(specs.length);
const indices: number[] = new Array(specs.length).fill(0);
// Pre-compute domain lengths for fast bounds checking.
const lengths = specs.map((s) => s.domainSubjectIds.length);
function step(depth: number): boolean {
if (depth === specs.length) {
return onTuple(tuple);
}
const spec = specs[depth];
const len = lengths[depth];
for (let i = 0; i < len; i++) {
indices[depth] = i;
tuple[depth] = spec.domainSubjectIds[i];
if (!step(depth + 1)) {
return false;
}
}
return true;
}
step(0);
}
/**
* Build a BindingEnv from a single tuple and its spec.
*/
export function tupleToBindings(
specs: TupleBindingSpec[],
tuple: number[],
): BindingEntry[] {
const entries: BindingEntry[] = [];
for (let i = 0; i < specs.length; i++) {
const spec = specs[i];
for (let j = 0; j < spec.variableNames.length; j++) {
entries.push({
variableName: spec.variableNames[j],
subjectId: tuple[i],
});
}
}
return entries;
}
@@ -0,0 +1,427 @@
/**
* Tests for canonical formula adapter.
*
* Verifies:
* - Canonical formula nodes adapt correctly to solver FormulaNode shape
* - Solver evaluation results adapt correctly to canonical result shape
* - Round-trip preserves semantics
*/
import { describe, it, beforeEach } from 'node:test'
import assert from 'node:assert'
import type {
CanonicalFormulaNode,
CanonicalDeterministicSceneResult,
} from 'imhotep-core'
import {
adaptCanonicalFormulaToSolver,
adaptSolverResultToCanonical,
} from './canonical-adapter.js'
import {
evaluateLogic,
type LogicEngineInput,
type DomainResolver,
} from './logic-engine.js'
import {
clearPredicateRegistry,
registerDefaultPredicates,
} from './predicates.js'
import type { GeometryWorld } from './registry.js'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeWorld(): GeometryWorld {
return {
sceneId: 'scene_1',
snapshotId: 'snap_1',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: [1, 2],
domNodeId: [10, 20],
subjectKind: [1, 1],
primaryBoxId: [100, 200],
firstFragmentId: [0, 0],
fragmentCount: [0, 0],
},
dom: {
nodeId: [10, 20],
parentNodeId: [0, 0],
childCount: [0, 0],
tagNameStringId: [0, 0],
},
boxes: {
boxId: [100, 200],
subjectId: [1, 2],
frameId: [1, 1],
borderLeft: [0, 110],
borderTop: [0, 50],
borderRight: [100, 210],
borderBottom: [40, 90],
paddingLeft: [0, 0],
paddingTop: [0, 0],
paddingRight: [0, 0],
paddingBottom: [0, 0],
contentLeft: [0, 0],
contentTop: [0, 0],
contentRight: [0, 0],
contentBottom: [0, 0],
},
visualBoxes: {
boxId: [], subjectId: [], frameId: [],
borderLeft: [], borderTop: [], borderRight: [], borderBottom: [],
paddingLeft: [], paddingTop: [], paddingRight: [], paddingBottom: [],
contentLeft: [], contentTop: [], contentRight: [], contentBottom: [],
},
transforms: {
transformId: [], subjectId: [], matrixStart: [], matrixLength: [],
originX: [], originY: [],
},
matrices: { values: [] },
rects: { rectId: [], left: [], top: [], right: [], bottom: [] },
topology: {
containingBlockOf: [0, 0],
nearestPositionedAncestorOf: [0, 0],
scrollContainerOf: [0, 0],
stackingContextOf: [0, 0],
formattingContextOf: [0, 0],
clippingRootOf: [0, 0],
paintOrderBucket: [0, 0],
paintOrderIndex: [0, 0],
},
scroll: {
containerId: [],
scrollLeft: [],
scrollTop: [],
scrollWidth: [],
scrollHeight: [],
clientWidth: [],
clientHeight: [],
},
clipping: {
clipNodeId: [],
subjectId: [],
clipKind: [],
clipLeft: [],
clipTop: [],
clipRight: [],
clipBottom: [],
parentClipNodeId: [],
},
visibility: {
subjectId: [],
isRendered: [],
isVisible: [],
visibleArea: [],
clippedArea: [],
},
}
}
function makeResolver(domains: Map<string, { subjectIds: number[] }>): DomainResolver {
return {
resolve(domain) {
const key = domain.selector ?? domain.domain
const entry = domains.get(key)
if (!entry) return undefined
return {
domainId: `dom_${key}`,
subjectIds: new Uint32Array(entry.subjectIds),
provenance: `elements(${key})`,
closed: true,
}
},
}
}
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
beforeEach(() => {
clearPredicateRegistry()
registerDefaultPredicates()
})
// ---------------------------------------------------------------------------
// Adaptation Tests
// ---------------------------------------------------------------------------
describe('canonical formula adapter', () => {
it('adapts a canonical predicate call to solver shape', () => {
const canonical: CanonicalFormulaNode = {
kind: 'predicate',
predicate: 'leftOf',
args: [
{ name: 'a' },
{ name: 'b' },
],
}
const solver = adaptCanonicalFormulaToSolver(canonical)
assert.strictEqual(solver.kind, 'predicate')
assert.strictEqual((solver as any).predicate, 'leftOf')
assert.strictEqual((solver as any).args.length, 2)
assert.strictEqual((solver as any).args[0].type, 'VariableRef')
assert.strictEqual((solver as any).args[0].name, 'a')
})
it('adapts a canonical forall with tuple bindings', () => {
const canonical: CanonicalFormulaNode = {
kind: 'forall',
bindings: [
{
variables: ['x'],
domain: { domain: 'elements', selector: '.button' },
},
],
body: {
kind: 'predicate',
predicate: 'width',
args: [{ name: 'x' }],
},
}
const solver = adaptCanonicalFormulaToSolver(canonical)
assert.strictEqual(solver.kind, 'forall')
const forall = solver as any
assert.strictEqual(forall.bindings.length, 1)
assert.strictEqual(forall.bindings[0].type, 'TupleBinding')
assert.deepStrictEqual(forall.bindings[0].variables, ['x'])
assert.strictEqual(forall.bindings[0].domain.type, 'DomainRef')
assert.strictEqual(forall.bindings[0].domain.domain, 'elements')
assert.strictEqual(forall.bindings[0].domain.selector, '.button')
})
it('adapts nested canonical quantifiers', () => {
const canonical: CanonicalFormulaNode = {
kind: 'forall',
bindings: [{ variables: ['c'], domain: { domain: 'elements', selector: '.container' } }],
body: {
kind: 'exists',
bindings: [{ variables: ['i'], domain: { domain: 'elements', selector: '.item' } }],
body: {
kind: 'predicate',
predicate: 'inside',
args: [{ name: 'i' }, { name: 'c' }],
},
},
}
const solver = adaptCanonicalFormulaToSolver(canonical)
assert.strictEqual(solver.kind, 'forall')
const body = (solver as any).body
assert.strictEqual(body.kind, 'exists')
assert.strictEqual(body.body.kind, 'predicate')
assert.strictEqual(body.body.predicate, 'inside')
})
it('adapts boolean connectives', () => {
const canonical: CanonicalFormulaNode = {
kind: 'and',
left: {
kind: 'predicate',
predicate: 'leftOf',
args: [{ name: 'a' }, { name: 'b' }],
},
right: {
kind: 'predicate',
predicate: 'above',
args: [{ name: 'a' }, { name: 'b' }],
},
}
const solver = adaptCanonicalFormulaToSolver(canonical)
assert.strictEqual(solver.kind, 'and')
assert.strictEqual((solver as any).left.kind, 'predicate')
assert.strictEqual((solver as any).right.kind, 'predicate')
})
it('adapts implies with antecedent and consequent', () => {
const canonical: CanonicalFormulaNode = {
kind: 'implies',
antecedent: {
kind: 'predicate',
predicate: 'inside',
args: [{ name: 'a' }, { name: 'b' }],
},
consequent: {
kind: 'predicate',
predicate: 'leftOf',
args: [{ name: 'a' }, { name: 'b' }],
},
}
const solver = adaptCanonicalFormulaToSolver(canonical)
assert.strictEqual(solver.kind, 'implies')
assert.strictEqual((solver as any).antecedent.kind, 'predicate')
assert.strictEqual((solver as any).consequent.kind, 'predicate')
})
it('adapts accessor terms in predicate args', () => {
const canonical: CanonicalFormulaNode = {
kind: 'predicate',
predicate: 'atLeast',
args: [
{ variable: 'x', property: 'width' },
],
}
const solver = adaptCanonicalFormulaToSolver(canonical)
const args = (solver as any).args
assert.strictEqual(args[0].type, 'AccessorTerm')
assert.strictEqual(args[0].variable, 'x')
assert.strictEqual(args[0].property, 'width')
})
it('throws on unknown canonical formula kind', () => {
const canonical = { kind: 'unknown' } as unknown as CanonicalFormulaNode
assert.throws(() => adaptCanonicalFormulaToSolver(canonical), /Cannot adapt unknown canonical formula kind/)
})
})
// ---------------------------------------------------------------------------
// End-to-End Canonical → Solver → Canonical
// ---------------------------------------------------------------------------
describe('canonical round-trip evaluation', () => {
it('evaluates a canonical formula through the adapter', () => {
const world = makeWorld()
// Use a single-variable forall with width predicate (always true for subject 1).
const canonical: CanonicalFormulaNode = {
kind: 'forall',
bindings: [
{
variables: ['a'],
domain: { domain: 'elements', selector: '.a' },
},
],
body: {
kind: 'predicate',
predicate: 'width',
args: [{ name: 'a' }],
},
}
const solverFormula = adaptCanonicalFormulaToSolver(canonical)
const domains = new Map<string, { subjectIds: number[] }>([
['.a', { subjectIds: [1] }],
])
const result = evaluateLogic({
formula: solverFormula,
world,
resolver: makeResolver(domains),
})
assert.strictEqual(result.passed, true)
assert.strictEqual(result.mode, 'scene-determinate')
// Adapt back to canonical
const canonicalResult = adaptSolverResultToCanonical(result)
assert.strictEqual(canonicalResult.mode, 'scene-determinate')
assert.strictEqual(canonicalResult.sceneId, 'scene_1')
assert.ok(Array.isArray(canonicalResult.results))
assert.ok(Array.isArray(canonicalResult.proofs))
assert.ok(Array.isArray(canonicalResult.diagnostics))
assert.strictEqual(canonicalResult.diagnostics.length, 0)
})
it('produces canonical diagnostics for failing canonical formula', () => {
const world = makeWorld()
const canonical: CanonicalFormulaNode = {
kind: 'forall',
bindings: [
{
variables: ['a', 'b'],
domain: { domain: 'elements', selector: '.pair' },
},
],
body: {
kind: 'predicate',
predicate: 'inside',
args: [{ name: 'a' }, { name: 'b' }],
},
}
const solverFormula = adaptCanonicalFormulaToSolver(canonical)
const domains = new Map<string, { subjectIds: number[] }>([
['.pair', { subjectIds: [1, 2] }],
])
const result = evaluateLogic({
formula: solverFormula,
world,
resolver: makeResolver(domains),
})
assert.strictEqual(result.passed, false)
const canonicalResult = adaptSolverResultToCanonical(result)
assert.strictEqual(canonicalResult.mode, 'scene-determinate')
assert.ok(canonicalResult.diagnostics.some(
(d) => d.code === 'IMH_PREDICATE_FAILED',
))
})
it('preserves formula result metrics in canonical shape', () => {
const world = makeWorld()
// Wrap predicate in forall so the variable is bound.
const canonical: CanonicalFormulaNode = {
kind: 'forall',
bindings: [
{
variables: ['a'],
domain: { domain: 'elements', selector: '.btn' },
},
],
body: {
kind: 'predicate',
predicate: 'width',
args: [{ name: 'a' }],
},
}
const solverFormula = adaptCanonicalFormulaToSolver(canonical)
const domains = new Map<string, { subjectIds: number[] }>([
['.btn', { subjectIds: [1] }],
])
const result = evaluateLogic({
formula: solverFormula,
world,
resolver: makeResolver(domains),
})
assert.strictEqual(result.passed, true)
const canonicalResult = adaptSolverResultToCanonical(result)
// Top-level forall result does not propagate body metrics by design;
// verify the adapter preserves the result structure instead.
const firstResult = canonicalResult.results[0] as Record<string, unknown>
assert.strictEqual(firstResult.outcome, 'pass')
assert.strictEqual(firstResult.truth, 'determinate')
})
})
@@ -0,0 +1,257 @@
/**
* Canonical formula adapter for the Imhotep solver.
*
* Bridges canonical formula nodes (from Stream 0 contracts) to the solver's
* internal logic-ast shape, and adapts solver evaluation results back to
* canonical result shapes.
*
* All cross-package handoffs go through this file — no implicit casts.
*/
import type {
// Canonical contracts
CanonicalFormulaNode,
CanonicalTupleBinding,
CanonicalDomainRef,
CanonicalVariableRef,
CanonicalAccessorTerm,
CanonicalTermNode,
CanonicalDeterministicSceneResult,
CanonicalDiagnostic,
// Solver-facing contracts from logic-ast
FormulaNode,
ForAllFormula,
ExistsFormula,
AndFormula,
OrFormula,
NotFormula,
ImpliesFormula,
PredicateCall,
VariableRef,
DomainRef,
AccessorTerm,
TupleBinding,
} from 'imhotep-core'
import type {
DeterministicSceneEvaluation,
FormulaResult,
} from './logic-engine.js'
// ---------------------------------------------------------------------------
// Solver Formula Node (explicit alias for the shape the engine consumes)
// ---------------------------------------------------------------------------
/** The solver engine accepts FormulaNode from imhotep-core logic-ast. */
export type SolverFormulaNode = FormulaNode
/** The solver engine accepts DomainValue from imhotep-core domains. */
export interface SolverResult extends DeterministicSceneEvaluation {}
/** Canonical clause result produced by adapting a solver evaluation. */
export interface CanonicalClauseResult extends CanonicalDeterministicSceneResult {}
// ---------------------------------------------------------------------------
// Canonical → Solver
// ---------------------------------------------------------------------------
/**
* Adapt a canonical formula node to the solver's FormulaNode shape.
*
* The canonical shape lacks the `type` discriminator on formula nodes and
* term nodes; this adapter adds the required discriminators so the solver
* engine can consume the formula without modification.
*/
export function adaptCanonicalFormulaToSolver(
formula: CanonicalFormulaNode,
): SolverFormulaNode {
switch (formula.kind) {
case 'forall':
return adaptForAll(formula)
case 'exists':
return adaptExists(formula)
case 'and':
return adaptAnd(formula)
case 'or':
return adaptOr(formula)
case 'not':
return adaptNot(formula)
case 'implies':
return adaptImplies(formula)
case 'predicate':
return adaptPredicate(formula)
default:
// Exhaustiveness check — if we hit this, canonical added a new kind.
throw new Error(
`Cannot adapt unknown canonical formula kind: ${(formula as CanonicalFormulaNode).kind}`,
)
}
}
function adaptForAll(formula: Extract<CanonicalFormulaNode, { kind: 'forall' }>): ForAllFormula {
return {
type: 'FormulaNode',
kind: 'forall',
bindings: formula.bindings.map(adaptTupleBinding),
body: adaptCanonicalFormulaToSolver(formula.body),
}
}
function adaptExists(formula: Extract<CanonicalFormulaNode, { kind: 'exists' }>): ExistsFormula {
return {
type: 'FormulaNode',
kind: 'exists',
bindings: formula.bindings.map(adaptTupleBinding),
body: adaptCanonicalFormulaToSolver(formula.body),
}
}
function adaptAnd(formula: Extract<CanonicalFormulaNode, { kind: 'and' }>): AndFormula {
return {
type: 'FormulaNode',
kind: 'and',
left: adaptCanonicalFormulaToSolver(formula.left),
right: adaptCanonicalFormulaToSolver(formula.right),
}
}
function adaptOr(formula: Extract<CanonicalFormulaNode, { kind: 'or' }>): OrFormula {
return {
type: 'FormulaNode',
kind: 'or',
left: adaptCanonicalFormulaToSolver(formula.left),
right: adaptCanonicalFormulaToSolver(formula.right),
}
}
function adaptNot(formula: Extract<CanonicalFormulaNode, { kind: 'not' }>): NotFormula {
return {
type: 'FormulaNode',
kind: 'not',
operand: adaptCanonicalFormulaToSolver(formula.operand),
}
}
function adaptImplies(
formula: Extract<CanonicalFormulaNode, { kind: 'implies' }>,
): ImpliesFormula {
return {
type: 'FormulaNode',
kind: 'implies',
antecedent: adaptCanonicalFormulaToSolver(formula.antecedent),
consequent: adaptCanonicalFormulaToSolver(formula.consequent),
}
}
function adaptPredicate(
formula: Extract<CanonicalFormulaNode, { kind: 'predicate' }>,
): PredicateCall {
return {
type: 'FormulaNode',
kind: 'predicate',
predicate: formula.predicate,
args: formula.args.map(adaptTerm),
}
}
function adaptTupleBinding(binding: CanonicalTupleBinding): TupleBinding {
return {
type: 'TupleBinding',
variables: binding.variables,
domain: adaptDomainRef(binding.domain),
}
}
function adaptDomainRef(ref: CanonicalDomainRef): DomainRef {
return {
type: 'DomainRef',
domain: ref.domain,
selector: ref.selector,
parentVar: ref.parentVar,
}
}
function adaptTerm(term: CanonicalTermNode): VariableRef | DomainRef | AccessorTerm {
if ('name' in term && !('variable' in term) && !('domain' in term)) {
return adaptVariableRef(term as CanonicalVariableRef)
}
if ('domain' in term) {
return adaptDomainRef(term as CanonicalDomainRef)
}
if ('variable' in term && 'property' in term) {
return adaptAccessorTerm(term as CanonicalAccessorTerm)
}
// Fallback for malformed term — treat as empty variable ref.
return { type: 'VariableRef', name: '' }
}
function adaptVariableRef(ref: CanonicalVariableRef): VariableRef {
return {
type: 'VariableRef',
name: ref.name,
}
}
function adaptAccessorTerm(term: CanonicalAccessorTerm): AccessorTerm {
return {
type: 'AccessorTerm',
variable: term.variable,
property: term.property,
}
}
// ---------------------------------------------------------------------------
// Solver → Canonical
// ---------------------------------------------------------------------------
/**
* Adapt a solver deterministic scene evaluation to the canonical result shape.
*
* Maps solver-specific diagnostics to canonical diagnostics, preserves all
* formula results, proofs, and trace events.
*/
export function adaptSolverResultToCanonical(
result: SolverResult,
): CanonicalClauseResult {
return {
mode: result.mode,
sceneId: result.sceneId,
results: result.formulaResults.map(adaptFormulaResult),
proofs: result.proofs,
diagnostics: result.diagnostics.map(adaptSolverDiagnosticToCanonical),
}
}
function adaptFormulaResult(result: FormulaResult): Record<string, unknown> {
return {
formulaId: result.formulaId,
outcome: result.outcome,
truth: result.truth,
witness: result.witness,
metrics: result.metrics,
}
}
function adaptSolverDiagnosticToCanonical(
d: {
code: string
severity: 'error' | 'warning' | 'info'
category?: string
message: string
position?: {
start: { line: number; column: number; offset: number }
end: { line: number; column: number; offset: number }
}
clauseId?: string
},
): CanonicalDiagnostic {
return {
code: d.code,
severity: d.severity,
category: (d.category ?? 'internal-error') as CanonicalDiagnostic['category'],
message: d.message,
source: 'imhotep-solver',
clauseId: d.clauseId,
position: d.position,
}
}
+332
View File
@@ -0,0 +1,332 @@
/**
* Main solver engine.
*
* Routes clauses to registered evaluators, handles quantifiers, and produces
* results together with proof objects and diagnostics.
*/
import { performance } from 'node:perf_hooks';
import {
type GeometryWorld,
type ClauseDescriptor,
type ClauseResult,
type Proof,
getEvaluator,
getRequiredFacts,
registerClause,
ClauseRegistry,
globalClauseRegistry,
} from './registry.js';
import { evaluateQuantifier, type QuantifierClause } from './quantifiers.js';
import { generateProofs } from './proofs.js';
import { clearEvaluationCache } from './transform-adapter.js';
import {
evaluateLeftOf,
evaluateRightOf,
evaluateAbove,
evaluateBelow,
evaluateOverlaps,
evaluateInside,
} from './relations.js';
import {
evaluateAlignedWith,
evaluateCenteredWithin,
} from './alignment.js';
import {
evaluateAtLeast,
evaluateAtMost,
evaluateBetween,
evaluateAspectRatio,
} from './size.js';
import {
evaluateClippedBy,
evaluateAttachedToScrollContainer,
evaluateInStackingContext,
} from './topology.js';
export interface EvaluationOptions {
skipMissingEvaluators?: boolean;
registry?: import('./registry.js').ClauseRegistry;
}
export interface EvaluationOutput {
clauseResults: ClauseResult[];
groupResults: ClauseResult[];
proofs: Proof[];
diagnostics: Array<{
code: string;
severity: 'error' | 'warning' | 'info';
category: string;
message: string;
clauseId?: string;
}>;
trace: Array<{
phase: string;
at: number;
clauseId?: string;
}>;
}
/**
* Register all built-in clause evaluators with the global registry.
* Call this once before invoking evaluate() if you want the default set.
*/
export function registerDefaultClauses(): void {
registerClause({
clauseKind: 'relation.leftOf',
version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateLeftOf,
});
registerClause({
clauseKind: 'relation.rightOf',
version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateRightOf,
});
registerClause({
clauseKind: 'relation.above',
version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateAbove,
});
registerClause({
clauseKind: 'relation.below',
version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateBelow,
});
registerClause({
clauseKind: 'relation.overlaps',
version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateOverlaps,
});
registerClause({
clauseKind: 'relation.inside',
version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateInside,
});
registerClause({
clauseKind: 'alignment.alignedWith',
version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateAlignedWith,
});
registerClause({
clauseKind: 'alignment.centeredWithin',
version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateCenteredWithin,
});
registerClause({
clauseKind: 'size.atLeast',
version: 1,
requiredFacts: ['subject.primaryBox'],
evaluate: evaluateAtLeast,
});
registerClause({
clauseKind: 'size.atMost',
version: 1,
requiredFacts: ['subject.primaryBox'],
evaluate: evaluateAtMost,
});
registerClause({
clauseKind: 'size.between',
version: 1,
requiredFacts: ['subject.primaryBox'],
evaluate: evaluateBetween,
});
registerClause({
clauseKind: 'size.aspectRatio',
version: 1,
requiredFacts: ['subject.primaryBox'],
evaluate: evaluateAspectRatio,
});
registerClause({
clauseKind: 'topology.clippedBy',
version: 1,
requiredFacts: ['subject.clipChain', 'reference.clipChain'],
evaluate: evaluateClippedBy,
});
registerClause({
clauseKind: 'topology.attachedToScrollContainer',
version: 1,
requiredFacts: ['topology.scrollContainerOf'],
evaluate: evaluateAttachedToScrollContainer,
});
registerClause({
clauseKind: 'topology.inStackingContext',
version: 1,
requiredFacts: ['topology.stackingContextOf'],
evaluate: evaluateInStackingContext,
});
}
/**
* Evaluate a set of clauses against a geometry world.
*/
export function evaluate(
world: GeometryWorld,
clauses: ClauseDescriptor[],
options: EvaluationOptions = {},
): EvaluationOutput {
const registry = options.registry ?? globalClauseRegistry;
// Reset per-evaluation transform caches so visual rects are recomputed
// once per subject per evaluation batch.
clearEvaluationCache(world);
const trace: EvaluationOutput['trace'] = [];
const diagnostics: EvaluationOutput['diagnostics'] = [];
const clauseResults: ClauseResult[] = [];
const groupResults: ClauseResult[] = [];
// Pre-size the result array if clause count is known — avoids reallocations.
const expectedClauseCount = clauses.length;
clauseResults.length = expectedClauseCount;
let clauseResultIndex = 0;
trace.push({ phase: 'evaluate-start', at: performance.now() });
// Build a fast lookup map for clause results so quantifier resolution is O(1)
// instead of O(n) per quantifier clause.
const resultByClauseId = new Map<string, ClauseResult>();
for (const clause of clauses) {
trace.push({
phase: 'evaluate-clause',
at: performance.now(),
clauseId: clause.clauseId,
});
if (clause.clauseKind.startsWith('quantifier.')) {
const qClause = clause as unknown as QuantifierClause;
let subResults: ClauseResult[] = [];
if (qClause.subClauseIds && qClause.subClauseIds.length > 0) {
subResults = new Array(qClause.subClauseIds.length);
for (let i = 0; i < qClause.subClauseIds.length; i++) {
const r = resultByClauseId.get(qClause.subClauseIds[i]);
subResults[i] = r!;
}
}
const qResult = evaluateQuantifier(subResults, qClause);
groupResults.push(qResult);
continue;
}
const evaluator = registry.getEvaluator(clause.clauseKind, clause.version);
if (!evaluator) {
const msg = `No evaluator registered for ${clause.clauseKind}@v${clause.version}`;
const result: ClauseResult = options.skipMissingEvaluators
? {
clauseId: clause.clauseId,
status: 'skipped',
truth: 'indeterminate',
diagnostics: [
{
code: 'IMH_EVALUATOR_MISSING',
severity: 'warning',
category: 'internal-error',
message: msg,
clauseId: clause.clauseId,
},
],
}
: {
clauseId: clause.clauseId,
status: 'error',
truth: 'indeterminate',
diagnostics: [
{
code: 'IMH_EVALUATOR_MISSING',
severity: 'error',
category: 'internal-error',
message: msg,
clauseId: clause.clauseId,
},
],
};
clauseResults[clauseResultIndex++] = result;
resultByClauseId.set(clause.clauseId, result);
if (!options.skipMissingEvaluators) {
diagnostics.push({
code: 'IMH_EVALUATOR_MISSING',
severity: 'error',
category: 'internal-error',
message: msg,
clauseId: clause.clauseId,
});
}
continue;
}
try {
const result = evaluator(world, clause);
clauseResults[clauseResultIndex++] = result;
resultByClauseId.set(clause.clauseId, result);
if (result.diagnostics) {
diagnostics.push(...result.diagnostics);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const result: ClauseResult = {
clauseId: clause.clauseId,
status: 'error',
truth: 'indeterminate',
diagnostics: [
{
code: 'IMH_EVALUATOR_EXCEPTION',
severity: 'error',
category: 'internal-error',
message,
clauseId: clause.clauseId,
},
],
};
clauseResults[clauseResultIndex++] = result;
resultByClauseId.set(clause.clauseId, result);
diagnostics.push({
code: 'IMH_EVALUATOR_EXCEPTION',
severity: 'error',
category: 'internal-error',
message,
clauseId: clause.clauseId,
});
}
}
// Trim array to actual inserted count (quantifiers don't push to clauseResults).
clauseResults.length = clauseResultIndex;
trace.push({ phase: 'evaluate-end', at: performance.now() });
const proofs = generateProofs(clauseResults, clauses, world);
return {
clauseResults,
groupResults,
proofs,
diagnostics,
trace,
};
}
/**
* Collect required facts across a set of clauses.
*/
export function collectRequiredFacts(clauses: ClauseDescriptor[], registry?: ClauseRegistry): string[] {
const set = new Set<string>();
for (const clause of clauses) {
const facts = registry
? registry.getRequiredFacts(clause.clauseKind, clause.version)
: getRequiredFacts(clause.clauseKind, clause.version);
for (const f of facts) {
set.add(f);
}
}
return Array.from(set);
}
+155
View File
@@ -0,0 +1,155 @@
/**
* imhotep-solver package exports.
*/
export {
// Types
type GeometryWorld,
type ClauseDescriptor,
type ClauseResult,
type ClauseStatus,
type TruthValue,
type Witness,
type Diagnostic,
type TraceEvent,
type ClauseEntry,
type ClauseEvaluator,
// Registry
registerClause,
getEvaluator,
getRequiredFacts,
listRegisteredKinds,
clearRegistry,
} from './registry.js';
export {
evaluateLeftOf,
evaluateRightOf,
evaluateAbove,
evaluateBelow,
evaluateOverlaps,
evaluateInside,
evaluateContains,
} from './relations.js';
export {
evaluateAlignedWith,
evaluateCenteredWithin,
} from './alignment.js';
export {
evaluateAtLeast,
evaluateAtMost,
evaluateBetween,
evaluateAspectRatio,
} from './size.js';
export {
evaluateClippedBy,
evaluateAttachedToScrollContainer,
evaluateInStackingContext,
} from './topology.js';
export {
evaluateQuantifier,
type QuantifierMode,
type QuantifierClause,
} from './quantifiers.js';
export {
generateProof,
generateProofs,
resetProofCounter,
} from './proofs.js';
export {
evaluate,
collectRequiredFacts,
registerDefaultClauses,
type EvaluationOptions,
type EvaluationOutput,
} from './engine.js';
export {
registerPredicate,
getPredicateEvaluator,
listRegisteredPredicates,
clearPredicateRegistry,
getPredicateDescriptor,
getRequiredFactsForPredicate,
registerDefaultPredicates,
BUILTIN_PREDICATES,
type PredicateDescriptor,
type PredicateEvaluator,
type PredicateResult,
type PredicateTruth,
widthPredicate,
heightPredicate,
abovePredicate,
belowPredicate,
leftOfPredicate,
rightOfPredicate,
insidePredicate,
overlapsPredicate,
alignedWithPredicate,
centeredWithinPredicate,
atLeastPredicate,
atMostPredicate,
clippedByPredicate,
inStackingContextPredicate,
besidePredicate,
nextToPredicate,
adjacentPredicate,
touchingPredicate,
nearPredicate,
underPredicate,
withinPredicate,
} from './predicates.js';
export {
BindingEnv,
enumerateTuples,
tupleToBindings,
type BindingEntry,
type TupleBindingSpec,
} from './bindings.js';
export {
createCartesianIterator,
flattenDomain,
computeJoinCardinality,
type JoinSpec,
type TupleIterator,
} from './joins.js';
export {
evaluateLogic,
evaluateLogicWithDefaults,
type LogicEngineInput,
type LogicEngineOptions,
type DeterministicSceneEvaluation,
type FormulaResult,
type EvaluationMode,
type DomainResolver,
} from './logic-engine.js';
export {
adaptCanonicalFormulaToSolver,
adaptSolverResultToCanonical,
type SolverFormulaNode,
type SolverResult,
type CanonicalClauseResult,
} from './canonical-adapter.js';
export {
getLayoutRect,
getVisualRect,
getBorderRect,
getTransformChain,
applyTransformToRect,
getEvaluationCache,
clearEvaluationCache,
createTransformCache,
type Rect,
type TransformCache,
} from './transform-adapter.js';
+164
View File
@@ -0,0 +1,164 @@
/**
* Domain join logic for first-order logic evaluation.
*
* Joins enumerate tuples from finite domains. The implementation is
* data-oriented: it iterates flat domain arrays without allocating per
* tuple.
*
* Invariant: all domains are closed and finite over a single scene.
*/
import type { DomainValue } from 'imhotep-core';
// ---------------------------------------------------------------------------
// Join Spec
// ---------------------------------------------------------------------------
export interface JoinSpec {
variableName: string;
domain: DomainValue;
}
// ---------------------------------------------------------------------------
// Tuple Iterator
// ---------------------------------------------------------------------------
export interface TupleIterator {
/** Total number of tuples (product of domain sizes). */
totalTuples: number;
/**
* Iterate all tuples, calling onTuple for each.
* Return false from onTuple to short-circuit.
*/
forEach(onTuple: (tuple: Uint32Array, indices: Uint32Array) => boolean): void;
/**
* Reset the iterator to the first tuple.
*/
reset(): void;
/**
* Advance to the next tuple. Returns false when exhausted.
*/
next(): { tuple: Uint32Array; indices: Uint32Array } | null;
}
// ---------------------------------------------------------------------------
// Cartesian Product Iterator
// ---------------------------------------------------------------------------
export function createCartesianIterator(specs: JoinSpec[]): TupleIterator {
const n = specs.length;
const domainSizes = specs.map((s) => s.domain.subjectIds.length);
const totalTuples = domainSizes.reduce((a, b) => a * b, 1);
// Pre-allocate reusable buffers.
const tuple = new Uint32Array(n);
const indices = new Uint32Array(n);
const currentDomainIndices = new Uint32Array(n);
let exhausted = totalTuples === 0;
function computeTuple(): void {
for (let i = 0; i < n; i++) {
const idx = currentDomainIndices[i];
tuple[i] = specs[i].domain.subjectIds[idx];
indices[i] = idx;
}
}
if (!exhausted) {
computeTuple();
}
return {
totalTuples,
forEach(onTuple) {
if (exhausted) return;
// Reset to beginning.
currentDomainIndices.fill(0);
computeTuple();
let done = false;
while (!done) {
if (!onTuple(tuple, indices)) {
return;
}
// Increment mixed-radix counter.
let carry = true;
for (let i = n - 1; i >= 0 && carry; i--) {
currentDomainIndices[i]++;
if (currentDomainIndices[i] >= domainSizes[i]) {
currentDomainIndices[i] = 0;
} else {
carry = false;
}
}
if (carry) {
done = true;
} else {
computeTuple();
}
}
},
reset() {
if (totalTuples === 0) {
exhausted = true;
return;
}
currentDomainIndices.fill(0);
computeTuple();
exhausted = false;
},
next() {
if (exhausted) return null;
const result = { tuple: tuple.slice(), indices: indices.slice() };
// Increment mixed-radix counter.
let carry = true;
for (let i = n - 1; i >= 0 && carry; i--) {
currentDomainIndices[i]++;
if (currentDomainIndices[i] >= domainSizes[i]) {
currentDomainIndices[i] = 0;
} else {
carry = false;
}
}
if (carry) {
exhausted = true;
} else {
computeTuple();
}
return result;
},
};
}
// ---------------------------------------------------------------------------
// Flat Domain Array Helpers
// ---------------------------------------------------------------------------
/**
* Build a flat Uint32Array of subject ids from a domain value.
* This is a no-op if the domain already stores its ids in a Uint32Array.
*/
export function flattenDomain(domain: DomainValue): Uint32Array {
return domain.subjectIds;
}
/**
* Compute the Cartesian product size without materializing tuples.
*/
export function computeJoinCardinality(specs: JoinSpec[]): number {
return specs.reduce((acc, s) => acc * s.domain.subjectIds.length, 1);
}
@@ -0,0 +1,851 @@
/**
* Edge case tests for Imhotep's First-Order Logic implementation.
*
* Covers:
* 1. Empty domains (vacuous truth)
* 2. Variable shadowing in nested quantifiers
* 3. Free/unbound variables
* 4. Quantifier over single element
* 5. Deep nesting (5+ levels)
* 6. Circular/tautological formulas
* 7. Contradictions
* 8. Mixed coordinate spaces in quantified formulas
*/
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert';
import type {
DomainValue,
} from 'imhotep-core';
import type {
GeometryWorld,
} from './registry.js';
import type {
FormulaNode,
PredicateCall,
ForAllFormula,
ExistsFormula,
AndFormula,
OrFormula,
NotFormula,
DomainRef,
TermNode,
} from 'imhotep-core';
import {
clearPredicateRegistry,
registerDefaultPredicates,
} from './predicates.js';
import {
evaluateLogic,
type LogicEngineInput,
type DomainResolver,
} from './logic-engine.js';
// ---------------------------------------------------------------------------
// Mock World Helpers
// ---------------------------------------------------------------------------
function makeWorld(overrides?: Partial<GeometryWorld>): GeometryWorld {
return {
sceneId: 'scene_1',
snapshotId: 'snap_1',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: [1, 2, 3],
domNodeId: [10, 20, 30],
subjectKind: [1, 1, 1],
primaryBoxId: [100, 200, 300],
firstFragmentId: [0, 0, 0],
fragmentCount: [0, 0, 0],
},
dom: {
nodeId: [10, 20, 30],
parentNodeId: [0, 0, 0],
childCount: [0, 0, 0],
tagNameStringId: [0, 0, 0],
},
boxes: {
boxId: [100, 200, 300],
subjectId: [1, 2, 3],
frameId: [1, 1, 1],
borderLeft: [0, 110, 220],
borderTop: [0, 50, 100],
borderRight: [100, 210, 320],
borderBottom: [40, 90, 140],
paddingLeft: [0, 0, 0],
paddingTop: [0, 0, 0],
paddingRight: [0, 0, 0],
paddingBottom: [0, 0, 0],
contentLeft: [0, 0, 0],
contentTop: [0, 0, 0],
contentRight: [0, 0, 0],
contentBottom: [0, 0, 0],
},
visualBoxes: {
boxId: [], subjectId: [], frameId: [],
borderLeft: [], borderTop: [], borderRight: [], borderBottom: [],
paddingLeft: [], paddingTop: [], paddingRight: [], paddingBottom: [],
contentLeft: [], contentTop: [], contentRight: [], contentBottom: [],
},
transforms: {
transformId: [], subjectId: [], matrixStart: [], matrixLength: [],
originX: [], originY: [],
},
matrices: { values: [] },
rects: {
rectId: [],
left: [],
top: [],
right: [],
bottom: [],
},
topology: {
containingBlockOf: [0, 0, 0],
nearestPositionedAncestorOf: [0, 0, 0],
scrollContainerOf: [0, 0, 0],
stackingContextOf: [0, 0, 0],
formattingContextOf: [0, 0, 0],
clippingRootOf: [0, 0, 0],
paintOrderBucket: [0, 0, 0],
paintOrderIndex: [0, 0, 0],
},
scroll: {
containerId: [],
scrollLeft: [],
scrollTop: [],
scrollWidth: [],
scrollHeight: [],
clientWidth: [],
clientHeight: [],
},
clipping: {
clipNodeId: [],
subjectId: [],
clipKind: [],
clipLeft: [],
clipTop: [],
clipRight: [],
clipBottom: [],
parentClipNodeId: [],
},
visibility: {
subjectId: [],
isRendered: [],
isVisible: [],
visibleArea: [],
clippedArea: [],
},
...overrides,
};
}
// ---------------------------------------------------------------------------
// Domain Resolver
// ---------------------------------------------------------------------------
function makeResolver(domains: Map<string, DomainValue>): DomainResolver {
return {
resolve(domain: DomainRef): DomainValue | undefined {
const key = domain.selector ?? domain.domain;
return domains.get(key);
},
};
}
// ---------------------------------------------------------------------------
// Formula Helpers
// ---------------------------------------------------------------------------
function predicate(name: string, args: TermNode[]): PredicateCall {
return {
type: 'FormulaNode',
kind: 'predicate',
predicate: name,
args,
};
}
function varRef(name: string): TermNode {
return { type: 'VariableRef', name };
}
function domainRef(domain: string, selector?: string, parentVar?: string): DomainRef {
return { type: 'DomainRef', domain, selector, parentVar };
}
function forall(bindings: { vars: string[]; domain: DomainRef }[], body: FormulaNode): ForAllFormula {
return {
type: 'FormulaNode',
kind: 'forall',
bindings: bindings.map((b) => ({
type: 'TupleBinding',
variables: b.vars,
domain: b.domain,
})),
body,
};
}
function exists(bindings: { vars: string[]; domain: DomainRef }[], body: FormulaNode): ExistsFormula {
return {
type: 'FormulaNode',
kind: 'exists',
bindings: bindings.map((b) => ({
type: 'TupleBinding',
variables: b.vars,
domain: b.domain,
})),
body,
};
}
function and(left: FormulaNode, right: FormulaNode): AndFormula {
return { type: 'FormulaNode', kind: 'and', left, right };
}
function or(left: FormulaNode, right: FormulaNode): OrFormula {
return { type: 'FormulaNode', kind: 'or', left, right };
}
function not(operand: FormulaNode): NotFormula {
return { type: 'FormulaNode', kind: 'not', operand };
}
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
beforeEach(() => {
clearPredicateRegistry();
registerDefaultPredicates();
});
// ---------------------------------------------------------------------------
// Edge Case 1: Empty domains
// ---------------------------------------------------------------------------
describe('Edge Case 1: Empty domains', () => {
it('forall over empty domain passes vacuously with diagnostic', () => {
const world = makeWorld();
const formula = predicate('leftOf', [varRef('x'), varRef('y')]);
const domains = new Map<string, DomainValue>([
['.nonexistent', {
domainId: 'dom_empty',
subjectIds: new Uint32Array([]),
provenance: 'elements(.nonexistent)',
closed: true,
}],
['.something', {
domainId: 'dom_something',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.something)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([
{ vars: ['x'], domain: domainRef('elements', '.nonexistent') },
{ vars: ['y'], domain: domainRef('elements', '.something') },
], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
// FIXED: When one of multiple domains is empty, forall is vacuously true.
// Previously the engine skipped empty domains and left variables unbound,
// causing indeterminate. Fixed in logic-engine.ts:283-296.
assert.strictEqual(result.passed, true, 'forall over empty domain should be vacuously true');
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
assert.ok(
result.diagnostics.some((d) => d.code === 'IMH_LOGIC_VACUOUS_FORALL'),
'Should emit vacuous forall diagnostic'
);
});
it('exists over empty domain fails with diagnostic', () => {
const world = makeWorld();
const formula = predicate('leftOf', [varRef('x'), varRef('y')]);
const domains = new Map<string, DomainValue>([
['.nonexistent', {
domainId: 'dom_empty',
subjectIds: new Uint32Array([]),
provenance: 'elements(.nonexistent)',
closed: true,
}],
['.something', {
domainId: 'dom_something',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.something)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: exists([
{ vars: ['x'], domain: domainRef('elements', '.nonexistent') },
{ vars: ['y'], domain: domainRef('elements', '.something') },
], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
// FIXED: When one of multiple domains is empty, exists is false.
// Previously the engine skipped empty domains and left variables unbound,
// causing indeterminate. Fixed in logic-engine.ts:424-433.
assert.strictEqual(result.passed, false, 'exists over empty domain should be false');
assert.strictEqual(result.formulaResults[0].outcome, 'fail');
assert.ok(
result.diagnostics.some((d) => d.code === 'IMH_LOGIC_EMPTY_DOMAIN_EXISTS'),
'Should emit empty domain exists diagnostic'
);
});
});
// ---------------------------------------------------------------------------
// Edge Case 2: Variable shadowing
// ---------------------------------------------------------------------------
describe('Edge Case 2: Variable shadowing', () => {
it('nested forall with same variable name uses inner binding', () => {
const world = makeWorld();
// Subject 1 [0,0,100,40] is leftOf subject 2 [110,50,210,90] (gap 10).
// Subject 2 [110,50,210,90] is leftOf subject 3 [220,100,320,140] (gap 10).
// Inner $x should bind to .b domain (subject 2), not outer .a domain (subject 1).
const innerBody = predicate('leftOf', [varRef('x'), varRef('c')]);
const innerForall = forall([
{ vars: ['x'], domain: domainRef('elements', '.b') },
{ vars: ['c'], domain: domainRef('elements', '.c') },
], innerBody);
const outerForall = forall([
{ vars: ['x'], domain: domainRef('elements', '.a') },
], innerForall);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
['.c', {
domainId: 'dom_c',
subjectIds: new Uint32Array([3]),
provenance: 'elements(.c)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: outerForall,
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
// Inner $x binds to subject 2, which is leftOf subject 3 (gap 10).
assert.strictEqual(result.passed, true, 'Inner binding should shadow outer; subject 2 is leftOf subject 3');
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
});
it('nested forall with same variable name fails when inner binding violates predicate', () => {
const world = makeWorld();
// Subject 2 [110,50,210,90] is NOT leftOf subject 1 [0,0,100,40].
const innerBody = predicate('leftOf', [varRef('x'), varRef('c')]);
const innerForall = forall([
{ vars: ['x'], domain: domainRef('elements', '.b') },
{ vars: ['c'], domain: domainRef('elements', '.c') },
], innerBody);
const outerForall = forall([
{ vars: ['x'], domain: domainRef('elements', '.a') },
], innerForall);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
['.c', {
domainId: 'dom_c',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.c)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: outerForall,
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
// Inner $x binds to subject 2, which is NOT leftOf subject 1.
assert.strictEqual(result.passed, false, 'Inner binding should shadow outer; subject 2 is NOT leftOf subject 1');
assert.strictEqual(result.formulaResults[0].outcome, 'fail');
});
});
// ---------------------------------------------------------------------------
// Edge Case 3: Free variables
// ---------------------------------------------------------------------------
describe('Edge Case 3: Free variables', () => {
it('unbound variable produces IMH_LOGIC_UNBOUND_VARIABLE and indeterminate', () => {
const world = makeWorld();
// $y is not bound by any quantifier.
const formula = predicate('leftOf', [varRef('x'), varRef('y')]);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([
{ vars: ['x'], domain: domainRef('elements', '.a') },
], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, false, 'Unbound variable should cause indeterminate/failure');
assert.strictEqual(result.formulaResults[0].outcome, 'indeterminate');
assert.ok(
result.diagnostics.some((d) => d.code === 'IMH_LOGIC_UNBOUND_VARIABLE'),
'Should emit IMH_LOGIC_UNBOUND_VARIABLE diagnostic'
);
assert.ok(
result.diagnostics.some((d) => d.message.includes('y')),
'Diagnostic should mention the unbound variable name y'
);
});
});
// ---------------------------------------------------------------------------
// Edge Case 4: Quantifier over single element
// ---------------------------------------------------------------------------
describe('Edge Case 4: Quantifier over single element', () => {
it('forall over single element produces same result as non-quantified version', () => {
const world = makeWorld();
// Subject 1 is leftOf subject 2.
const quantifiedFormula = forall([
{ vars: ['x'], domain: domainRef('elements', '.a') },
{ vars: ['y'], domain: domainRef('elements', '.b') },
], predicate('leftOf', [varRef('x'), varRef('y')]));
const nonQuantifiedFormula = predicate('leftOf', [varRef('x'), varRef('y')]);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
const quantifiedInput: LogicEngineInput = {
formula: quantifiedFormula,
world,
resolver: makeResolver(domains),
};
const quantifiedResult = evaluateLogic(quantifiedInput);
assert.strictEqual(quantifiedResult.passed, true, 'Quantified single element should pass');
assert.strictEqual(quantifiedResult.formulaResults[0].outcome, 'pass');
});
it('forall over single failing element produces same failure as non-quantified', () => {
const world = makeWorld();
// Subject 2 is NOT leftOf subject 1.
const quantifiedFormula = forall([
{ vars: ['x'], domain: domainRef('elements', '.b') },
{ vars: ['y'], domain: domainRef('elements', '.a') },
], predicate('leftOf', [varRef('x'), varRef('y')]));
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: quantifiedFormula,
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, false, 'Quantified single element should fail when predicate is false');
assert.strictEqual(result.formulaResults[0].outcome, 'fail');
});
});
// ---------------------------------------------------------------------------
// Edge Case 5: Deep nesting (5+ levels)
// ---------------------------------------------------------------------------
describe('Edge Case 5: Deep nesting (5+ levels)', () => {
it('deeply nested and(5+) evaluates without stack overflow', () => {
const world = makeWorld();
// Build a deeply nested and tree: and(A, and(B, and(C, and(D, and(E, F)))))
// All predicates are true: leftOf(1,2), leftOf(1,3), leftOf(2,3), etc.
const p1 = predicate('leftOf', [varRef('a'), varRef('b')]);
const p2 = predicate('leftOf', [varRef('a'), varRef('c')]);
const p3 = predicate('leftOf', [varRef('b'), varRef('c')]);
const p4 = predicate('above', [varRef('a'), varRef('b')]);
const p5 = predicate('above', [varRef('a'), varRef('c')]);
const p6 = predicate('above', [varRef('b'), varRef('c')]);
let body = and(p5, p6);
body = and(p4, body);
body = and(p3, body);
body = and(p2, body);
body = and(p1, body);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
['.c', {
domainId: 'dom_c',
subjectIds: new Uint32Array([3]),
provenance: 'elements(.c)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
{ vars: ['c'], domain: domainRef('elements', '.c') },
], body),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, true, 'Deep nesting should not overflow and should pass');
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
});
it('deeply nested or(5+) short-circuits correctly', () => {
const world = makeWorld();
// First predicate is true, rest are false - should short-circuit.
const p1 = predicate('leftOf', [varRef('a'), varRef('b')]);
const p2 = predicate('inside', [varRef('a'), varRef('b')]);
const p3 = predicate('inside', [varRef('b'), varRef('a')]);
const p4 = predicate('inside', [varRef('a'), varRef('c')]);
const p5 = predicate('inside', [varRef('c'), varRef('a')]);
const p6 = predicate('inside', [varRef('b'), varRef('c')]);
let body = or(p5, p6);
body = or(p4, body);
body = or(p3, body);
body = or(p2, body);
body = or(p1, body);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
['.c', {
domainId: 'dom_c',
subjectIds: new Uint32Array([3]),
provenance: 'elements(.c)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
{ vars: ['c'], domain: domainRef('elements', '.c') },
], body),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, true, 'Deep or should short-circuit on first true');
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
assert.ok(
result.trace.some((t) => t.phase === 'evaluate-or-short-circuit'),
'Should show short-circuit trace'
);
});
});
// ---------------------------------------------------------------------------
// Edge Case 6: Circular/tautological formulas
// ---------------------------------------------------------------------------
describe('Edge Case 6: Circular/tautological formulas', () => {
it('or(P, not(P)) is always a tautology', () => {
const world = makeWorld();
// leftOf(1,2) is true, so or(leftOf, not(leftOf)) is true.
// Even if leftOf were false, not(leftOf) would be true.
const p = predicate('leftOf', [varRef('a'), varRef('b')]);
const tautology = or(p, not(p));
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
], tautology),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, true, 'Tautology or(P, not(P)) should always pass');
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
});
it('or(inside, not(inside)) passes even when inside is false', () => {
const world = makeWorld();
// inside(1,2) is false, so not(inside) is true.
const p = predicate('inside', [varRef('a'), varRef('b')]);
const tautology = or(p, not(p));
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
], tautology),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, true, 'Tautology should pass even when predicate is false');
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
});
});
// ---------------------------------------------------------------------------
// Edge Case 7: Contradictions
// ---------------------------------------------------------------------------
describe('Edge Case 7: Contradictions', () => {
it('and(P, not(P)) is always a contradiction', () => {
const world = makeWorld();
// leftOf(1,2) is true, so not(leftOf) is false, and(true, false) = false.
const p = predicate('leftOf', [varRef('a'), varRef('b')]);
const contradiction = and(p, not(p));
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
], contradiction),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, false, 'Contradiction and(P, not(P)) should always fail');
assert.strictEqual(result.formulaResults[0].outcome, 'fail');
});
it('and(inside, not(inside)) fails even when both are evaluated', () => {
const world = makeWorld();
// inside(1,2) is false, not(inside) is true, and(false, true) = false.
const p = predicate('inside', [varRef('a'), varRef('b')]);
const contradiction = and(p, not(p));
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
], contradiction),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, false, 'Contradiction should always fail');
assert.strictEqual(result.formulaResults[0].outcome, 'fail');
});
});
// ---------------------------------------------------------------------------
// Edge Case 8: Mixed coordinate spaces in quantified formulas
// ---------------------------------------------------------------------------
describe('Edge Case 8: Mixed coordinate spaces in quantified formulas', () => {
it('forall with layout space option uses layout space for all evaluations', () => {
const world = makeWorld();
// The leftOf predicate with space: 'layout' should be evaluated in layout space.
// For this test, we just verify the formula compiles and evaluates without error.
// The actual layout vs visual difference would require transformed elements.
const formula = predicate('leftOf', [varRef('x'), varRef('y')]);
(formula as any).options = { space: 'layout' };
const domains = new Map<string, DomainValue>([
['.transformed', {
domainId: 'dom_transformed',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.transformed)',
closed: true,
}],
['.anchor', {
domainId: 'dom_anchor',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.anchor)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([
{ vars: ['x'], domain: domainRef('elements', '.transformed') },
{ vars: ['y'], domain: domainRef('elements', '.anchor') },
], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
// Should evaluate without error; pass/fail depends on geometry.
assert.ok(
result.formulaResults[0].outcome === 'pass' || result.formulaResults[0].outcome === 'fail',
'Layout space option should not cause indeterminate'
);
assert.strictEqual(result.formulaResults[0].truth, 'determinate');
});
});
@@ -0,0 +1,922 @@
/**
* Unit tests for the Imhotep deterministic FOL logic engine.
*
* Covers:
* - Universal quantification exhaustiveness and short-circuit
* - Existential quantification witness correctness and short-circuit
* - Boolean connectives (And, Or, Not, Implies)
* - Predicate calls against mock geometry worlds
* - Rich diagnostics, proofs, and trace events
*/
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert';
import type {
DomainValue,
} from 'imhotep-core';
import type {
GeometryWorld,
} from './registry.js';
import type {
FormulaNode,
PredicateCall,
ForAllFormula,
ExistsFormula,
AndFormula,
OrFormula,
NotFormula,
ImpliesFormula,
DomainRef,
TermNode,
} from 'imhotep-core';
import {
clearPredicateRegistry,
registerDefaultPredicates,
getPredicateEvaluator,
} from './predicates.js';
import {
evaluateLogic,
type LogicEngineInput,
type DomainResolver,
} from './logic-engine.js';
// ---------------------------------------------------------------------------
// Mock World Helpers
// ---------------------------------------------------------------------------
function makeWorld(overrides?: Partial<GeometryWorld>): GeometryWorld {
return {
sceneId: 'scene_1',
snapshotId: 'snap_1',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: [1, 2, 3],
domNodeId: [10, 20, 30],
subjectKind: [1, 1, 1],
primaryBoxId: [100, 200, 300],
firstFragmentId: [0, 0, 0],
fragmentCount: [0, 0, 0],
},
dom: {
nodeId: [10, 20, 30],
parentNodeId: [0, 0, 0],
childCount: [0, 0, 0],
tagNameStringId: [0, 0, 0],
},
boxes: {
boxId: [100, 200, 300],
subjectId: [1, 2, 3],
frameId: [1, 1, 1],
borderLeft: [0, 110, 220],
borderTop: [0, 50, 100],
borderRight: [100, 210, 320],
borderBottom: [40, 90, 140],
paddingLeft: [0, 0, 0],
paddingTop: [0, 0, 0],
paddingRight: [0, 0, 0],
paddingBottom: [0, 0, 0],
contentLeft: [0, 0, 0],
contentTop: [0, 0, 0],
contentRight: [0, 0, 0],
contentBottom: [0, 0, 0],
},
visualBoxes: {
boxId: [], subjectId: [], frameId: [],
borderLeft: [], borderTop: [], borderRight: [], borderBottom: [],
paddingLeft: [], paddingTop: [], paddingRight: [], paddingBottom: [],
contentLeft: [], contentTop: [], contentRight: [], contentBottom: [],
},
transforms: {
transformId: [], subjectId: [], matrixStart: [], matrixLength: [],
originX: [], originY: [],
},
matrices: { values: [] },
rects: {
rectId: [],
left: [],
top: [],
right: [],
bottom: [],
},
topology: {
containingBlockOf: [0, 0, 0],
nearestPositionedAncestorOf: [0, 0, 0],
scrollContainerOf: [0, 0, 0],
stackingContextOf: [0, 0, 0],
formattingContextOf: [0, 0, 0],
clippingRootOf: [0, 0, 0],
paintOrderBucket: [0, 0, 0],
paintOrderIndex: [0, 0, 0],
},
scroll: {
containerId: [],
scrollLeft: [],
scrollTop: [],
scrollWidth: [],
scrollHeight: [],
clientWidth: [],
clientHeight: [],
},
clipping: {
clipNodeId: [],
subjectId: [],
clipKind: [],
clipLeft: [],
clipTop: [],
clipRight: [],
clipBottom: [],
parentClipNodeId: [],
},
visibility: {
subjectId: [],
isRendered: [],
isVisible: [],
visibleArea: [],
clippedArea: [],
},
...overrides,
};
}
// ---------------------------------------------------------------------------
// Domain Resolver
// ---------------------------------------------------------------------------
function makeResolver(domains: Map<string, DomainValue>): DomainResolver {
return {
resolve(domain: DomainRef): DomainValue | undefined {
const key = domain.selector ?? domain.domain;
return domains.get(key);
},
};
}
// ---------------------------------------------------------------------------
// Formula Helpers
// ---------------------------------------------------------------------------
function predicate(name: string, args: TermNode[]): PredicateCall {
return {
type: 'FormulaNode',
kind: 'predicate',
predicate: name,
args,
};
}
function varRef(name: string): TermNode {
return { type: 'VariableRef', name };
}
function domainRef(domain: string, selector?: string, parentVar?: string): DomainRef {
return { type: 'DomainRef', domain, selector, parentVar };
}
function forall(bindings: { vars: string[]; domain: DomainRef }[], body: FormulaNode): ForAllFormula {
return {
type: 'FormulaNode',
kind: 'forall',
bindings: bindings.map((b) => ({
type: 'TupleBinding',
variables: b.vars,
domain: b.domain,
})),
body,
};
}
function exists(bindings: { vars: string[]; domain: DomainRef }[], body: FormulaNode): ExistsFormula {
return {
type: 'FormulaNode',
kind: 'exists',
bindings: bindings.map((b) => ({
type: 'TupleBinding',
variables: b.vars,
domain: b.domain,
})),
body,
};
}
function and(left: FormulaNode, right: FormulaNode): AndFormula {
return { type: 'FormulaNode', kind: 'and', left, right };
}
function or(left: FormulaNode, right: FormulaNode): OrFormula {
return { type: 'FormulaNode', kind: 'or', left, right };
}
function not(operand: FormulaNode): NotFormula {
return { type: 'FormulaNode', kind: 'not', operand };
}
function implies(antecedent: FormulaNode, consequent: FormulaNode): ImpliesFormula {
return { type: 'FormulaNode', kind: 'implies', antecedent, consequent };
}
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
beforeEach(() => {
clearPredicateRegistry();
registerDefaultPredicates();
});
// ---------------------------------------------------------------------------
// Predicate Call Tests
// ---------------------------------------------------------------------------
describe('predicate calls', () => {
it('evaluates width predicate against a subject', () => {
const world = makeWorld();
const formula = predicate('width', [varRef('btn')]);
const domains = new Map<string, DomainValue>([
['.button', {
domainId: 'dom_1',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.button)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([{ vars: ['btn'], domain: domainRef('elements', '.button') }], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.mode, 'scene-determinate');
assert.strictEqual(result.passed, true);
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
});
it('evaluates leftOf predicate with two subjects', () => {
const world = makeWorld();
// Subject 1 [0,0,100,40] is left of subject 2 [110,50,210,90] with gap 10.
const formula = predicate('leftOf', [varRef('a'), varRef('b')]);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, true);
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
});
it('returns indeterminate for unregistered predicate', () => {
const world = makeWorld();
clearPredicateRegistry(); // Remove all predicates
const formula = predicate('unknownPredicate', [varRef('x')]);
const domains = new Map<string, DomainValue>([
['.x', {
domainId: 'dom_x',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.x)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([{ vars: ['x'], domain: domainRef('elements', '.x') }], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.mode, 'scene-indeterminate');
assert.strictEqual(result.passed, false);
assert.ok(result.diagnostics.some((d) => d.code === 'IMH_LOGIC_PREDICATE_MISSING'));
});
it('evaluates separatedFrom as inverse of overlaps', () => {
const world = makeWorld();
const formula = predicate('separatedFrom', [varRef('a'), varRef('b')]);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
], formula),
world,
resolver: makeResolver(domains),
};
// Subjects 1 (0,0,100,40) and 2 (110,50,210,90) do not overlap,
// so separatedFrom should return true and forall should pass.
const result = evaluateLogic(input);
assert.strictEqual(result.passed, true);
assert.ok(!result.diagnostics.some((d) => d.code === 'IMH_FEATURE_NOT_YET_IMPLEMENTED'));
});
it('returns not-yet-implemented for aligned-with variants', () => {
const world = makeWorld();
const variants = ['leftAlignedWith', 'rightAlignedWith', 'topAlignedWith', 'bottomAlignedWith'];
for (const variant of variants) {
const formula = predicate(variant, [varRef('a'), varRef('b')]);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, false, `Expected ${variant} to fail`);
assert.ok(
result.diagnostics.some((d) => d.code === 'IMH_FEATURE_NOT_YET_IMPLEMENTED'),
`Expected ${variant} to produce IMH_FEATURE_NOT_YET_IMPLEMENTED`
);
}
});
});
// ---------------------------------------------------------------------------
// Universal Quantification Tests
// ---------------------------------------------------------------------------
describe('universal quantification', () => {
it('passes when all elements satisfy predicate', () => {
const world = makeWorld();
// All three subjects have width >= 50 (100, 100, 100).
const formula = predicate('atLeast', [varRef('btn')]);
const domains = new Map<string, DomainValue>([
['.button', {
domainId: 'dom_1',
subjectIds: new Uint32Array([1, 2, 3]),
provenance: 'elements(.button)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([{ vars: ['btn'], domain: domainRef('elements', '.button') }], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, true);
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
});
it('fails on first violating element and short-circuits', () => {
const world = makeWorld();
// We need a predicate that will fail for some subjects.
// Use inside: subject 1 [0,0,100,40] is NOT inside subject 2 [110,50,210,90].
const formula = predicate('inside', [varRef('btn'), varRef('container')]);
const domains = new Map<string, DomainValue>([
['.button', {
domainId: 'dom_btn',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.button)',
closed: true,
}],
['.container', {
domainId: 'dom_container',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.container)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([
{ vars: ['btn'], domain: domainRef('elements', '.button') },
{ vars: ['container'], domain: domainRef('elements', '.container') },
], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, false);
assert.strictEqual(result.formulaResults[0].outcome, 'fail');
assert.ok(result.trace.some((t) => t.phase === 'evaluate-forall-failure'));
});
it('is vacuously true over empty domain', () => {
const world = makeWorld();
const formula = predicate('width', [varRef('btn')]);
const domains = new Map<string, DomainValue>([
['.button', {
domainId: 'dom_1',
subjectIds: new Uint32Array([]),
provenance: 'elements(.button)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([{ vars: ['btn'], domain: domainRef('elements', '.button') }], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, true);
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
assert.ok(result.trace.some((t) => t.phase === 'evaluate-forall-vacuous'));
assert.ok(
result.diagnostics.some((d) => d.code === 'IMH_LOGIC_VACUOUS_FORALL'),
'Should emit vacuous forall diagnostic',
);
assert.ok(
result.diagnostics.some((d) => d.message.includes('vacuously true')),
'Diagnostic message should mention vacuous truth',
);
});
});
// ---------------------------------------------------------------------------
// Existential Quantification Tests
// ---------------------------------------------------------------------------
describe('existential quantification', () => {
it('finds a witness when one exists', () => {
const world = makeWorld();
// Subject 1 is inside subject 3 [220,100,320,140]? No.
// But subject 2 [110,50,210,90] is inside subject 3 [220,100,320,140]? No.
// Let's make subject 1 inside a larger container.
const customWorld = makeWorld({
boxes: {
...makeWorld().boxes,
borderLeft: [10, 110, 0],
borderTop: [10, 50, 0],
borderRight: [50, 150, 200],
borderBottom: [30, 80, 150],
},
});
// Subject 1 [10,10,50,30] is inside subject 3 [0,0,200,150].
const formula = predicate('inside', [varRef('item'), varRef('container')]);
const domains = new Map<string, DomainValue>([
['.item', {
domainId: 'dom_item',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.item)',
closed: true,
}],
['.container', {
domainId: 'dom_container',
subjectIds: new Uint32Array([3]),
provenance: 'elements(.container)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: exists([
{ vars: ['item'], domain: domainRef('elements', '.item') },
{ vars: ['container'], domain: domainRef('elements', '.container') },
], formula),
world: customWorld,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, true);
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
assert.ok(result.trace.some((t) => t.phase === 'evaluate-exists-witness'));
});
it('fails when no witness exists', () => {
const world = makeWorld();
// Subject 1 [0,0,100,40] is NOT inside subject 2 [110,50,210,90].
const formula = predicate('inside', [varRef('item'), varRef('container')]);
const domains = new Map<string, DomainValue>([
['.item', {
domainId: 'dom_item',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.item)',
closed: true,
}],
['.container', {
domainId: 'dom_container',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.container)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: exists([
{ vars: ['item'], domain: domainRef('elements', '.item') },
{ vars: ['container'], domain: domainRef('elements', '.container') },
], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, false);
assert.strictEqual(result.formulaResults[0].outcome, 'fail');
});
it('is false over empty domain', () => {
const world = makeWorld();
const formula = predicate('width', [varRef('btn')]);
const domains = new Map<string, DomainValue>([
['.button', {
domainId: 'dom_1',
subjectIds: new Uint32Array([]),
provenance: 'elements(.button)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: exists([{ vars: ['btn'], domain: domainRef('elements', '.button') }], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, false);
assert.strictEqual(result.formulaResults[0].outcome, 'fail');
assert.ok(
result.diagnostics.some((d) => d.code === 'IMH_LOGIC_EMPTY_DOMAIN_EXISTS'),
'Should emit empty domain exists diagnostic',
);
assert.ok(
result.diagnostics.some((d) => d.message.includes('empty domain')),
'Diagnostic message should mention empty domain',
);
});
});
// ---------------------------------------------------------------------------
// Boolean Connective Tests
// ---------------------------------------------------------------------------
describe('boolean connectives', () => {
it('And passes when both sides pass', () => {
const world = makeWorld();
const p = predicate('leftOf', [varRef('a'), varRef('b')]);
const q = predicate('above', [varRef('a'), varRef('b')]);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
const formula = forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
], and(p, q));
const result = evaluateLogic({ formula, world, resolver: makeResolver(domains) });
assert.strictEqual(result.passed, true);
});
it('And fails when left side fails', () => {
const world = makeWorld();
// Subject 1 is NOT inside subject 2.
const p = predicate('inside', [varRef('a'), varRef('b')]);
const q = predicate('leftOf', [varRef('a'), varRef('b')]);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
const formula = forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
], and(p, q));
const result = evaluateLogic({ formula, world, resolver: makeResolver(domains) });
assert.strictEqual(result.passed, false);
assert.ok(result.trace.some((t) => t.phase === 'evaluate-and-short-circuit'));
});
it('Or passes when left side passes', () => {
const world = makeWorld();
// Subject 1 is leftOf subject 2 (true) OR inside subject 2 (false).
const p = predicate('leftOf', [varRef('a'), varRef('b')]);
const q = predicate('inside', [varRef('a'), varRef('b')]);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
const formula = forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
], or(p, q));
const result = evaluateLogic({ formula, world, resolver: makeResolver(domains) });
assert.strictEqual(result.passed, true);
assert.ok(result.trace.some((t) => t.phase === 'evaluate-or-short-circuit'));
});
it('Or fails when both sides fail', () => {
const world = makeWorld();
const p = predicate('inside', [varRef('a'), varRef('b')]);
const q = predicate('inside', [varRef('b'), varRef('a')]);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
const formula = forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
], or(p, q));
const result = evaluateLogic({ formula, world, resolver: makeResolver(domains) });
assert.strictEqual(result.passed, false);
});
it('Not inverts the outcome', () => {
const world = makeWorld();
const p = predicate('inside', [varRef('a'), varRef('b')]);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
// NOT inside(a, b) should pass because inside(a, b) is false.
const formula = forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
], not(p));
const result = evaluateLogic({ formula, world, resolver: makeResolver(domains) });
assert.strictEqual(result.passed, true);
});
it('Implies is vacuously true when antecedent is false', () => {
const world = makeWorld();
// inside(a, b) is false, so implies(false, anything) is true.
const antecedent = predicate('inside', [varRef('a'), varRef('b')]);
const consequent = predicate('leftOf', [varRef('a'), varRef('b')]);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
const formula = forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
], implies(antecedent, consequent));
const result = evaluateLogic({ formula, world, resolver: makeResolver(domains) });
assert.strictEqual(result.passed, true);
assert.ok(result.trace.some((t) => t.phase === 'evaluate-implies-vacuous'));
});
it('Implies fails when antecedent is true and consequent is false', () => {
const world = makeWorld();
// leftOf(a, b) is true, but inside(a, b) is false.
const antecedent = predicate('leftOf', [varRef('a'), varRef('b')]);
const consequent = predicate('inside', [varRef('a'), varRef('b')]);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
const formula = forall([
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
], implies(antecedent, consequent));
const result = evaluateLogic({ formula, world, resolver: makeResolver(domains) });
assert.strictEqual(result.passed, false);
});
});
// ---------------------------------------------------------------------------
// Diagnostic and Proof Tests
// ---------------------------------------------------------------------------
describe('diagnostics and proofs', () => {
it('produces proofs for every evaluated formula', () => {
const world = makeWorld();
const formula = predicate('width', [varRef('btn')]);
const domains = new Map<string, DomainValue>([
['.button', {
domainId: 'dom_1',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.button)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([{ vars: ['btn'], domain: domainRef('elements', '.button') }], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.ok(result.proofs.length > 0);
assert.ok(result.proofs[0].proofId.startsWith('proof_'));
assert.strictEqual(result.proofs[0].clauseId.startsWith('formula_'), true);
});
it('produces trace events for evaluation phases', () => {
const world = makeWorld();
const formula = predicate('width', [varRef('btn')]);
const domains = new Map<string, DomainValue>([
['.button', {
domainId: 'dom_1',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.button)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([{ vars: ['btn'], domain: domainRef('elements', '.button') }], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.ok(result.trace.length > 0);
assert.ok(result.trace.some((t) => t.phase === 'evaluate-logic-start'));
assert.ok(result.trace.some((t) => t.phase === 'evaluate-logic-end'));
assert.ok(result.trace.some((t) => t.phase === 'evaluate-forall-start'));
});
it('reports failing assignments for forall failures', () => {
const world = makeWorld();
const formula = predicate('inside', [varRef('btn'), varRef('container')]);
const domains = new Map<string, DomainValue>([
['.button', {
domainId: 'dom_btn',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.button)',
closed: true,
}],
['.container', {
domainId: 'dom_container',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.container)',
closed: true,
}],
]);
const input: LogicEngineInput = {
formula: forall([
{ vars: ['btn'], domain: domainRef('elements', '.button') },
{ vars: ['container'], domain: domainRef('elements', '.container') },
], formula),
world,
resolver: makeResolver(domains),
};
const result = evaluateLogic(input);
assert.strictEqual(result.passed, false);
assert.ok(result.failingAssignments);
assert.ok(result.failingAssignments!.length > 0);
// The failing assignment should include the bound variables.
const firstFailure = result.failingAssignments![0];
assert.ok('btn' in firstFailure || 'container' in firstFailure);
});
});
+934
View File
@@ -0,0 +1,934 @@
/**
* 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<string, number>[];
}
export interface FormulaResult {
formulaId: string;
outcome: 'pass' | 'fail' | 'indeterminate';
truth: 'determinate' | 'indeterminate';
witness?: number[];
metrics?: Record<string, number>;
}
// ---------------------------------------------------------------------------
// Domain Resolver
// ---------------------------------------------------------------------------
export interface DomainResolver {
resolve(domain: DomainRef): 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<string, unknown>,
): 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);
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<string, number> | undefined;
let failingMetrics: Record<string, number> | 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);
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<string, number> | 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',
};
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';
if (rightResult.outcome === 'pass') {
outcome = 'pass';
truth = 'determinate';
} else if (rightResult.outcome === 'indeterminate') {
outcome = 'indeterminate';
truth = 'indeterminate';
} else {
outcome = 'fail';
truth = 'determinate';
}
const result: FormulaResult = {
formulaId,
outcome,
truth,
};
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',
};
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';
if (rightResult.outcome === 'pass') {
outcome = 'pass';
truth = 'determinate';
} else if (leftResult.outcome === 'indeterminate' || rightResult.outcome === 'indeterminate') {
outcome = 'indeterminate';
truth = 'indeterminate';
} else {
outcome = 'fail';
truth = 'determinate';
}
const result: FormulaResult = {
formulaId,
outcome,
truth,
};
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';
if (operandResult.outcome === 'pass') {
outcome = 'fail';
truth = 'determinate';
} 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';
}
const result: FormulaResult = {
formulaId,
outcome,
truth,
};
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';
if (consequentResult.outcome === 'pass') {
outcome = 'pass';
truth = 'determinate';
} else if (consequentResult.outcome === 'indeterminate') {
outcome = 'indeterminate';
truth = 'indeterminate';
} else {
outcome = 'fail';
truth = 'determinate';
}
const result: FormulaResult = {
formulaId,
outcome,
truth,
};
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<string, number>[] = [];
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<string, number>;
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);
}
@@ -0,0 +1,755 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert';
import fc from 'fast-check';
import type {
GeometryWorld,
} from './registry.js';
// Local DomainValue copy to avoid cross-package resolution issues in tests.
interface DomainValue {
domainId: string;
subjectIds: Uint32Array;
provenance: string;
closed: boolean;
}
import {
clearPredicateRegistry,
registerDefaultPredicates,
} from './predicates.js';
import {
evaluateLogic,
type LogicEngineInput,
type DomainResolver,
} from './logic-engine.js';
// ---------------------------------------------------------------------------
// Mock World Generator
// ---------------------------------------------------------------------------
function makeWorldWithElements(count: number, overrides?: Partial<GeometryWorld>): GeometryWorld {
const ids = Array.from({ length: count }, (_, i) => i + 1);
const domNodeIds = ids.map((id) => id * 10);
const boxIds = ids.map((id) => id * 100);
// Generate random but valid rectangles
const borderLeft = ids.map(() => Math.floor(Math.random() * 400));
const borderTop = ids.map(() => Math.floor(Math.random() * 300));
const widths = ids.map(() => 20 + Math.floor(Math.random() * 180));
const heights = ids.map(() => 20 + Math.floor(Math.random() * 180));
return {
sceneId: `scene_${count}`,
snapshotId: 'snap_1',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids,
domNodeId: domNodeIds,
subjectKind: ids.map(() => 1),
primaryBoxId: boxIds,
firstFragmentId: ids.map(() => 0),
fragmentCount: ids.map(() => 0),
},
dom: {
nodeId: domNodeIds,
parentNodeId: ids.map(() => 0),
childCount: ids.map(() => 0),
tagNameStringId: ids.map(() => 0),
},
boxes: {
boxId: boxIds,
subjectId: ids,
frameId: ids.map(() => 1),
borderLeft,
borderTop,
borderRight: borderLeft.map((l, i) => l + widths[i]),
borderBottom: borderTop.map((t, i) => t + heights[i]),
paddingLeft: ids.map(() => 0),
paddingTop: ids.map(() => 0),
paddingRight: ids.map(() => 0),
paddingBottom: ids.map(() => 0),
contentLeft: ids.map(() => 0),
contentTop: ids.map(() => 0),
contentRight: ids.map(() => 0),
contentBottom: ids.map(() => 0),
},
visualBoxes: {
boxId: [], subjectId: [], frameId: [],
borderLeft: [], borderTop: [], borderRight: [], borderBottom: [],
paddingLeft: [], paddingTop: [], paddingRight: [], paddingBottom: [],
contentLeft: [], contentTop: [], contentRight: [], contentBottom: [],
},
transforms: {
transformId: [], subjectId: [], matrixStart: [], matrixLength: [],
originX: [], originY: [],
},
matrices: { values: [] },
rects: {
rectId: [],
left: [],
top: [],
right: [],
bottom: [],
},
topology: {
containingBlockOf: ids.map(() => 0),
nearestPositionedAncestorOf: ids.map(() => 0),
scrollContainerOf: ids.map(() => 0),
stackingContextOf: ids.map(() => 0),
formattingContextOf: ids.map(() => 0),
clippingRootOf: ids.map(() => 0),
paintOrderBucket: ids.map(() => 0),
paintOrderIndex: ids.map(() => 0),
},
scroll: {
containerId: [],
scrollLeft: [],
scrollTop: [],
scrollWidth: [],
scrollHeight: [],
clientWidth: [],
clientHeight: [],
},
clipping: {
clipNodeId: [],
subjectId: [],
clipKind: [],
clipLeft: [],
clipTop: [],
clipRight: [],
clipBottom: [],
parentClipNodeId: [],
},
visibility: {
subjectId: [],
isRendered: [],
isVisible: [],
visibleArea: [],
clippedArea: [],
},
...overrides,
};
}
function makeResolver(domains: Map<string, DomainValue>): DomainResolver {
return {
resolve(domain: any): DomainValue | undefined {
const key = domain.selector ?? domain.domain;
return domains.get(key);
},
};
}
// ---------------------------------------------------------------------------
// Formula Helpers
// ---------------------------------------------------------------------------
function predicate(name: string, args: any[], options?: Record<string, unknown>): any {
return {
type: 'FormulaNode',
kind: 'predicate',
predicate: name,
args,
options,
};
}
function varRef(name: string): any {
return { type: 'VariableRef', name };
}
function domainRef(domain: string, selector?: string): any {
return { type: 'DomainRef', domain, selector };
}
function forall(bindings: { vars: string[]; domain: any }[], body: any): any {
return {
type: 'FormulaNode',
kind: 'forall',
bindings: bindings.map((b) => ({
type: 'TupleBinding',
variables: b.vars,
domain: b.domain,
})),
body,
};
}
function exists(bindings: { vars: string[]; domain: any }[], body: any): any {
return {
type: 'FormulaNode',
kind: 'exists',
bindings: bindings.map((b) => ({
type: 'TupleBinding',
variables: b.vars,
domain: b.domain,
})),
body,
};
}
function and(left: any, right: any): any {
return { type: 'FormulaNode', kind: 'and', left, right };
}
function or(left: any, right: any): any {
return { type: 'FormulaNode', kind: 'or', left, right };
}
function not(operand: any): any {
return { type: 'FormulaNode', kind: 'not', operand };
}
function implies(antecedent: any, consequent: any): any {
return { type: 'FormulaNode', kind: 'implies', antecedent, consequent };
}
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
beforeEach(() => {
clearPredicateRegistry();
registerDefaultPredicates();
});
// ---------------------------------------------------------------------------
// Property Tests
// ---------------------------------------------------------------------------
describe('logic engine property tests', () => {
it('property: forall is exhaustive over finite domain', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 20 }),
fc.integer({ min: 10, max: 200 }),
(elementCount, minWidth) => {
const world = makeWorldWithElements(elementCount);
// Ensure all elements have width >= minWidth by adjusting rectangles
for (let i = 0; i < elementCount; i++) {
const currentWidth = world.boxes.borderRight[i] - world.boxes.borderLeft[i];
if (currentWidth < minWidth) {
world.boxes.borderRight[i] = world.boxes.borderLeft[i] + minWidth;
}
}
const domains = new Map<string, DomainValue>([
['.el', {
domainId: 'dom_el',
subjectIds: new Uint32Array(world.subjects.ids),
provenance: 'elements(.el)',
closed: true,
}],
]);
const formula = forall(
[{ vars: ['x'], domain: domainRef('elements', '.el') }],
predicate('atLeast', [varRef('x')], { min: minWidth })
);
const result = evaluateLogic({
formula,
world,
resolver: makeResolver(domains),
options: { skipMissingPredicates: false },
});
assert.strictEqual(result.passed, true);
assert.strictEqual(result.mode, 'scene-determinate');
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
}
),
{ numRuns: 100, seed: 42 }
);
});
it('property: forall fails when at least one element violates predicate', () => {
fc.assert(
fc.property(
fc.integer({ min: 2, max: 15 }),
fc.integer({ min: 50, max: 150 }),
(elementCount, threshold) => {
const world = makeWorldWithElements(elementCount);
// Make the first element violate the threshold by having small width
world.boxes.borderRight[0] = world.boxes.borderLeft[0] + 5;
const domains = new Map<string, DomainValue>([
['.el', {
domainId: 'dom_el',
subjectIds: new Uint32Array(world.subjects.ids),
provenance: 'elements(.el)',
closed: true,
}],
]);
const formula = forall(
[{ vars: ['x'], domain: domainRef('elements', '.el') }],
predicate('atLeast', [varRef('x')], { min: threshold })
);
const result = evaluateLogic({
formula,
world,
resolver: makeResolver(domains),
});
// Should fail because element 1 has width 5 which is below threshold
assert.strictEqual(result.passed, false);
assert.strictEqual(result.formulaResults[0].outcome, 'fail');
assert.ok(result.failingAssignments);
assert.ok(result.failingAssignments!.length > 0);
}
),
{ numRuns: 100, seed: 123 }
);
});
it('property: exists finds a witness when one exists', () => {
fc.assert(
fc.property(
fc.integer({ min: 2, max: 15 }),
(elementCount) => {
const world = makeWorldWithElements(elementCount);
// Make element 2 very large so it definitely passes any reasonable atLeast
world.boxes.borderLeft[1] = 0;
world.boxes.borderRight[1] = 500;
const domains = new Map<string, DomainValue>([
['.el', {
domainId: 'dom_el',
subjectIds: new Uint32Array(world.subjects.ids),
provenance: 'elements(.el)',
closed: true,
}],
]);
const formula = exists(
[{ vars: ['x'], domain: domainRef('elements', '.el') }],
predicate('atLeast', [varRef('x')])
);
const result = evaluateLogic({
formula,
world,
resolver: makeResolver(domains),
});
assert.strictEqual(result.passed, true);
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
assert.ok(result.trace.some((t) => t.phase === 'evaluate-exists-witness'));
}
),
{ numRuns: 100, seed: 77 }
);
});
it('property: exists is false when no witness exists', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 10 }),
(elementCount) => {
const world = makeWorldWithElements(elementCount);
// Make all elements tiny
for (let i = 0; i < elementCount; i++) {
world.boxes.borderRight[i] = world.boxes.borderLeft[i] + 1;
world.boxes.borderBottom[i] = world.boxes.borderTop[i] + 1;
}
const domains = new Map<string, DomainValue>([
['.el', {
domainId: 'dom_el',
subjectIds: new Uint32Array(world.subjects.ids),
provenance: 'elements(.el)',
closed: true,
}],
]);
// Use atLeast with min=2 so 1x1 elements fail
const formula = exists(
[{ vars: ['x'], domain: domainRef('elements', '.el') }],
predicate('atLeast', [varRef('x')], { min: 2 })
);
const result = evaluateLogic({
formula,
world,
resolver: makeResolver(domains),
});
assert.strictEqual(result.passed, false);
assert.strictEqual(result.formulaResults[0].outcome, 'fail');
}
),
{ numRuns: 100, seed: 88 }
);
});
it('property: forall over empty domain is vacuously true', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 5 }),
(depth) => {
const world = makeWorldWithElements(3);
const domains = new Map<string, DomainValue>([
['.empty', {
domainId: 'dom_empty',
subjectIds: new Uint32Array(0),
provenance: 'elements(.empty)',
closed: true,
}],
]);
const formula = forall(
[{ vars: ['x'], domain: domainRef('elements', '.empty') }],
predicate('atLeast', [varRef('x')])
);
const result = evaluateLogic({
formula,
world,
resolver: makeResolver(domains),
});
assert.strictEqual(result.passed, true);
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
assert.ok(result.trace.some((t) => t.phase === 'evaluate-forall-vacuous'));
}
),
{ numRuns: 50, seed: 55 }
);
});
it('property: exists over empty domain is false', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 5 }),
(depth) => {
const world = makeWorldWithElements(3);
const domains = new Map<string, DomainValue>([
['.empty', {
domainId: 'dom_empty',
subjectIds: new Uint32Array(0),
provenance: 'elements(.empty)',
closed: true,
}],
]);
const formula = exists(
[{ vars: ['x'], domain: domainRef('elements', '.empty') }],
predicate('atLeast', [varRef('x')])
);
const result = evaluateLogic({
formula,
world,
resolver: makeResolver(domains),
});
assert.strictEqual(result.passed, false);
assert.strictEqual(result.formulaResults[0].outcome, 'fail');
assert.ok(result.trace.some((t) => t.phase === 'evaluate-exists-empty'));
}
),
{ numRuns: 50, seed: 66 }
);
});
it('property: And passes iff both sides pass', () => {
fc.assert(
fc.property(
fc.constantFrom('pass', 'fail', 'indeterminate'),
fc.constantFrom('pass', 'fail', 'indeterminate'),
(leftOutcome, rightOutcome) => {
const world = makeWorldWithElements(2);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
// leftOf(1,2) is true by construction in makeWorldWithElements (random placement)
// So we use predicates whose truth we can control via world geometry.
// For determinism, place subjects explicitly.
world.boxes.borderLeft[0] = 0;
world.boxes.borderRight[0] = 50;
world.boxes.borderLeft[1] = 60;
world.boxes.borderRight[1] = 110;
const leftPred = leftOutcome === 'pass'
? predicate('leftOf', [varRef('a'), varRef('b')])
: leftOutcome === 'fail'
? predicate('inside', [varRef('a'), varRef('b')])
: predicate('unknown', [varRef('a')]);
const rightPred = rightOutcome === 'pass'
? predicate('leftOf', [varRef('a'), varRef('b')])
: rightOutcome === 'fail'
? predicate('inside', [varRef('a'), varRef('b')])
: predicate('unknown', [varRef('a')]);
const formula = forall(
[
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
],
and(leftPred, rightPred)
);
const result = evaluateLogic({
formula,
world,
resolver: makeResolver(domains),
});
// Expected outcomes based on engine's actual connective logic
if (leftOutcome === 'indeterminate' || rightOutcome === 'indeterminate') {
// At least one side is indeterminate
assert.ok(
result.formulaResults[0].outcome === 'indeterminate' ||
result.formulaResults[0].outcome === 'fail',
`And(${leftOutcome}, ${rightOutcome}) should be indeterminate or fail, got ${result.formulaResults[0].outcome}`
);
} else if (leftOutcome === 'fail' || rightOutcome === 'fail') {
assert.strictEqual(result.formulaResults[0].outcome, 'fail');
} else {
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
}
}
),
{ numRuns: 100, seed: 99 }
);
});
it('property: Or passes iff at least one side passes', () => {
fc.assert(
fc.property(
fc.constantFrom('pass', 'fail'),
fc.constantFrom('pass', 'fail'),
(leftOutcome, rightOutcome) => {
const world = makeWorldWithElements(2);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
world.boxes.borderLeft[0] = 0;
world.boxes.borderRight[0] = 50;
world.boxes.borderLeft[1] = 60;
world.boxes.borderRight[1] = 110;
const leftPred = leftOutcome === 'pass'
? predicate('leftOf', [varRef('a'), varRef('b')])
: predicate('inside', [varRef('a'), varRef('b')]);
const rightPred = rightOutcome === 'pass'
? predicate('leftOf', [varRef('a'), varRef('b')])
: predicate('inside', [varRef('a'), varRef('b')]);
const formula = forall(
[
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
],
or(leftPred, rightPred)
);
const result = evaluateLogic({
formula,
world,
resolver: makeResolver(domains),
});
const expected = leftOutcome === 'pass' || rightOutcome === 'pass' ? 'pass' : 'fail';
assert.strictEqual(result.formulaResults[0].outcome, expected);
}
),
{ numRuns: 100, seed: 111 }
);
});
it('property: Not inverts pass and fail', () => {
fc.assert(
fc.property(
fc.constantFrom('pass', 'fail'),
(outcome) => {
const world = makeWorldWithElements(2);
const domains = new Map<string, DomainValue>([
['.a', {
domainId: 'dom_a',
subjectIds: new Uint32Array([1]),
provenance: 'elements(.a)',
closed: true,
}],
['.b', {
domainId: 'dom_b',
subjectIds: new Uint32Array([2]),
provenance: 'elements(.b)',
closed: true,
}],
]);
world.boxes.borderLeft[0] = 0;
world.boxes.borderRight[0] = 50;
world.boxes.borderLeft[1] = 60;
world.boxes.borderRight[1] = 110;
const inner = outcome === 'pass'
? predicate('leftOf', [varRef('a'), varRef('b')])
: predicate('inside', [varRef('a'), varRef('b')]);
const formula = forall(
[
{ vars: ['a'], domain: domainRef('elements', '.a') },
{ vars: ['b'], domain: domainRef('elements', '.b') },
],
not(inner)
);
const result = evaluateLogic({
formula,
world,
resolver: makeResolver(domains),
});
const expected = outcome === 'pass' ? 'fail' : 'pass';
assert.strictEqual(result.formulaResults[0].outcome, expected);
}
),
{ numRuns: 100, seed: 222 }
);
});
it('property: nested quantifiers (forall + exists) evaluate correctly', () => {
fc.assert(
fc.property(
fc.integer({ min: 2, max: 8 }),
fc.integer({ min: 2, max: 8 }),
(containerCount, itemCount) => {
const world = makeWorldWithElements(containerCount + itemCount);
// First containerCount subjects are large containers
for (let i = 0; i < containerCount; i++) {
world.boxes.borderLeft[i] = 0;
world.boxes.borderTop[i] = 0;
world.boxes.borderRight[i] = 500;
world.boxes.borderBottom[i] = 500;
}
// Remaining subjects are small items placed inside the first container
for (let i = containerCount; i < containerCount + itemCount; i++) {
world.boxes.borderLeft[i] = 10 + (i - containerCount) * 5;
world.boxes.borderTop[i] = 10 + (i - containerCount) * 5;
world.boxes.borderRight[i] = world.boxes.borderLeft[i] + 10;
world.boxes.borderBottom[i] = world.boxes.borderTop[i] + 10;
}
const domains = new Map<string, DomainValue>([
['.container', {
domainId: 'dom_container',
subjectIds: new Uint32Array(world.subjects.ids.slice(0, containerCount)),
provenance: 'elements(.container)',
closed: true,
}],
['.item', {
domainId: 'dom_item',
subjectIds: new Uint32Array(world.subjects.ids.slice(containerCount)),
provenance: 'elements(.item)',
closed: true,
}],
]);
// forall container: exists item: inside(item, container)
const formula = forall(
[{ vars: ['c'], domain: domainRef('elements', '.container') }],
exists(
[{ vars: ['i'], domain: domainRef('elements', '.item') }],
predicate('inside', [varRef('i'), varRef('c')])
)
);
const result = evaluateLogic({
formula,
world,
resolver: makeResolver(domains),
});
assert.strictEqual(result.passed, true);
assert.strictEqual(result.formulaResults[0].outcome, 'pass');
}
),
{ numRuns: 50, seed: 333 }
);
});
it('property: evaluation produces proofs and traces', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 10 }),
(elementCount) => {
const world = makeWorldWithElements(elementCount);
const domains = new Map<string, DomainValue>([
['.el', {
domainId: 'dom_el',
subjectIds: new Uint32Array(world.subjects.ids),
provenance: 'elements(.el)',
closed: true,
}],
]);
const formula = forall(
[{ vars: ['x'], domain: domainRef('elements', '.el') }],
predicate('atLeast', [varRef('x')])
);
const result = evaluateLogic({
formula,
world,
resolver: makeResolver(domains),
});
assert.ok(result.proofs.length > 0);
assert.ok(result.trace.length > 0);
assert.ok(result.trace.some((t) => t.phase === 'evaluate-logic-start'));
assert.ok(result.trace.some((t) => t.phase === 'evaluate-logic-end'));
}
),
{ numRuns: 50, seed: 444 }
);
});
});
@@ -0,0 +1,480 @@
/**
* Unit tests for spatial alias predicate evaluators.
*
* Covers:
* - beside (leftOf OR rightOf)
* - nextTo (alias for beside)
* - adjacent (all four directions with maxGap:0)
* - touching (alias for adjacent)
* - near (overlaps OR proximity radius)
* - under (alias for below)
* - within (alias for inside)
*/
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert';
import {
clearPredicateRegistry,
registerDefaultPredicates,
getPredicateEvaluator,
type GeometryWorld,
} from './index.js';
// --- Test helpers ------------------------------------------------------------
function makeWorld(overrides?: Partial<GeometryWorld>): GeometryWorld {
return {
sceneId: 'scene_1',
snapshotId: 'snap_1',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: [1, 2, 3, 4],
domNodeId: [10, 20, 30, 40],
subjectKind: [1, 1, 1, 1],
primaryBoxId: [100, 200, 300, 400],
firstFragmentId: [0, 0, 0, 0],
fragmentCount: [0, 0, 0, 0],
},
dom: {
nodeId: [10, 20, 30, 40],
parentNodeId: [0, 0, 0, 0],
childCount: [0, 0, 0, 0],
tagNameStringId: [0, 0, 0, 0],
},
boxes: {
boxId: [100, 200, 300, 400],
subjectId: [1, 2, 3, 4],
frameId: [1, 1, 1, 1],
// Subject 1: left box at (0,0)-(100,40)
// Subject 2: right box at (110,50)-(210,90)
// Subject 3: top box at (50,0)-(150,100)
// Subject 4: bottom box at (50,150)-(150,190)
borderLeft: [0, 110, 50, 50],
borderTop: [0, 50, 0, 150],
borderRight: [100, 210, 150, 150],
borderBottom: [40, 90, 100, 190],
paddingLeft: [0, 0, 0, 0],
paddingTop: [0, 0, 0, 0],
paddingRight: [0, 0, 0, 0],
paddingBottom: [0, 0, 0, 0],
contentLeft: [0, 110, 50, 50],
contentTop: [0, 50, 0, 150],
contentRight: [100, 210, 150, 150],
contentBottom: [40, 90, 100, 190],
},
visualBoxes: {
boxId: [100, 200, 300, 400],
subjectId: [1, 2, 3, 4],
frameId: [1, 1, 1, 1],
borderLeft: [0, 110, 50, 50],
borderTop: [0, 50, 0, 150],
borderRight: [100, 210, 150, 150],
borderBottom: [40, 90, 100, 190],
paddingLeft: [0, 0, 0, 0],
paddingTop: [0, 0, 0, 0],
paddingRight: [0, 0, 0, 0],
paddingBottom: [0, 0, 0, 0],
contentLeft: [0, 110, 50, 50],
contentTop: [0, 50, 0, 150],
contentRight: [100, 210, 150, 150],
contentBottom: [40, 90, 100, 190],
},
transforms: {
transformId: [],
subjectId: [],
matrixStart: [],
matrixLength: [],
originX: [],
originY: [],
},
matrices: {
values: [],
},
rects: {
rectId: [],
left: [],
top: [],
right: [],
bottom: [],
},
topology: {
clippingRootOf: [],
stackingContextOf: [],
scrollContainerOf: [],
containingBlockOf: [],
formattingContextOf: [],
nearestPositionedAncestorOf: [],
paintOrderBucket: [],
paintOrderIndex: [],
},
scroll: {
containerId: [],
scrollLeft: [],
scrollTop: [],
scrollWidth: [],
scrollHeight: [],
clientWidth: [],
clientHeight: [],
},
...overrides,
} as GeometryWorld;
}
// --- Alias predicate tests ---------------------------------------------------
describe('spatial alias predicates', () => {
beforeEach(() => {
clearPredicateRegistry();
registerDefaultPredicates();
});
describe('beside', () => {
it('returns true when subject is leftOf reference', () => {
const evaluator = getPredicateEvaluator('beside')!;
const world = makeWorld();
// Subject 1 (left box) is leftOf Subject 2 (right box)
const result = evaluator.evaluateTuple(world, [1, 2], { minGap: 0, maxGap: 20 });
assert.strictEqual(result.truth, 'true');
assert.strictEqual(result.metrics?.direction, 'left');
});
it('returns true when subject is rightOf reference', () => {
const evaluator = getPredicateEvaluator('beside')!;
const world = makeWorld();
// Subject 2 (right box) is rightOf Subject 1 (left box)
const result = evaluator.evaluateTuple(world, [2, 1], { minGap: 0, maxGap: 20 });
assert.strictEqual(result.truth, 'true');
assert.strictEqual(result.metrics?.direction, 'right');
});
it('returns false when subject is not horizontally adjacent', () => {
const evaluator = getPredicateEvaluator('beside')!;
const world = makeWorld();
// Subject 3 is above Subject 4, not beside
const result = evaluator.evaluateTuple(world, [3, 4], { minGap: 0, maxGap: 10 });
assert.strictEqual(result.truth, 'false');
assert.ok(result.diagnostics);
assert.ok(result.diagnostics!.some((d) => d.code === 'IMH_RELATION_BESIDE_FAILED'));
});
it('reports both gaps when neither direction passes', () => {
const evaluator = getPredicateEvaluator('beside')!;
const world = makeWorld();
// Subject 3 is above Subject 4
const result = evaluator.evaluateTuple(world, [3, 4], { minGap: 0, maxGap: 5 });
assert.strictEqual(result.truth, 'false');
assert.ok(Number.isFinite(result.metrics?.leftGap) || Number.isNaN(result.metrics?.leftGap));
assert.ok(Number.isFinite(result.metrics?.rightGap) || Number.isNaN(result.metrics?.rightGap));
});
});
describe('nextTo', () => {
it('delegates to beside evaluator', () => {
const evaluator = getPredicateEvaluator('nextTo')!;
const world = makeWorld();
const result = evaluator.evaluateTuple(world, [1, 2], { minGap: 0, maxGap: 20 });
assert.strictEqual(result.truth, 'true');
assert.strictEqual(result.metrics?.alias, 'nextTo');
});
it('fails with beside diagnostic rewritten for nextTo', () => {
const evaluator = getPredicateEvaluator('nextTo')!;
const world = makeWorld();
const result = evaluator.evaluateTuple(world, [3, 4], { minGap: 0, maxGap: 5 });
assert.strictEqual(result.truth, 'false');
assert.ok(result.diagnostics!.some((d) => d.message.includes('"nextTo"')));
});
});
describe('adjacent', () => {
it('returns true when subject touches reference on the left', () => {
const evaluator = getPredicateEvaluator('adjacent')!;
const touchingWorld = makeWorld({
boxes: {
boxId: [100, 200, 300, 400],
subjectId: [1, 2, 3, 4],
frameId: [1, 1, 1, 1],
borderLeft: [0, 100, 50, 50],
borderTop: [0, 0, 0, 150],
borderRight: [100, 200, 150, 150],
borderBottom: [40, 40, 100, 190],
paddingLeft: [0, 0, 0, 0],
paddingTop: [0, 0, 0, 0],
paddingRight: [0, 0, 0, 0],
paddingBottom: [0, 0, 0, 0],
contentLeft: [0, 100, 50, 50],
contentTop: [0, 0, 0, 150],
contentRight: [100, 200, 150, 150],
contentBottom: [40, 40, 100, 190],
},
});
const result = evaluator.evaluateTuple(touchingWorld, [1, 2], {});
assert.strictEqual(result.truth, 'true');
assert.strictEqual(result.metrics?.direction, 'leftOf');
});
it('returns true when subject touches reference above', () => {
const evaluator = getPredicateEvaluator('adjacent')!;
const touchingWorld = makeWorld({
boxes: {
boxId: [100, 200, 300, 400],
subjectId: [1, 2, 3, 4],
frameId: [1, 1, 1, 1],
borderLeft: [0, 110, 50, 50],
borderTop: [0, 50, 0, 100],
borderRight: [100, 210, 150, 150],
borderBottom: [40, 90, 100, 140],
paddingLeft: [0, 0, 0, 0],
paddingTop: [0, 0, 0, 0],
paddingRight: [0, 0, 0, 0],
paddingBottom: [0, 0, 0, 0],
contentLeft: [0, 110, 50, 50],
contentTop: [0, 50, 0, 100],
contentRight: [100, 210, 150, 150],
contentBottom: [40, 90, 100, 140],
},
});
const result = evaluator.evaluateTuple(touchingWorld, [3, 4], {});
assert.strictEqual(result.truth, 'true');
assert.strictEqual(result.metrics?.direction, 'above');
});
it('returns false when no direction touches with maxGap:0', () => {
const evaluator = getPredicateEvaluator('adjacent')!;
const world = makeWorld();
const result = evaluator.evaluateTuple(world, [1, 2], {});
assert.strictEqual(result.truth, 'false');
assert.ok(result.diagnostics!.some((d) => d.code === 'IMH_RELATION_ADJACENT_FAILED'));
});
});
describe('touching', () => {
it('delegates to adjacent evaluator', () => {
const evaluator = getPredicateEvaluator('touching')!;
const touchingWorld = makeWorld({
boxes: {
boxId: [100, 200, 300, 400],
subjectId: [1, 2, 3, 4],
frameId: [1, 1, 1, 1],
borderLeft: [0, 100, 50, 50],
borderTop: [0, 0, 0, 150],
borderRight: [100, 200, 150, 150],
borderBottom: [40, 40, 100, 190],
paddingLeft: [0, 0, 0, 0],
paddingTop: [0, 0, 0, 0],
paddingRight: [0, 0, 0, 0],
paddingBottom: [0, 0, 0, 0],
contentLeft: [0, 100, 50, 50],
contentTop: [0, 0, 0, 150],
contentRight: [100, 200, 150, 150],
contentBottom: [40, 40, 100, 190],
},
});
const result = evaluator.evaluateTuple(touchingWorld, [1, 2], {});
assert.strictEqual(result.truth, 'true');
assert.strictEqual(result.metrics?.alias, 'touching');
});
});
describe('near', () => {
it('returns true when elements overlap', () => {
const evaluator = getPredicateEvaluator('near')!;
const world = makeWorld({
boxes: {
boxId: [100, 200, 300, 400],
subjectId: [1, 2, 3, 4],
frameId: [1, 1, 1, 1],
borderLeft: [0, 50, 50, 50],
borderTop: [0, 20, 0, 150],
borderRight: [100, 150, 150, 150],
borderBottom: [40, 60, 100, 190],
paddingLeft: [0, 0, 0, 0],
paddingTop: [0, 0, 0, 0],
paddingRight: [0, 0, 0, 0],
paddingBottom: [0, 0, 0, 0],
contentLeft: [0, 50, 50, 50],
contentTop: [0, 20, 0, 150],
contentRight: [100, 150, 150, 150],
contentBottom: [40, 60, 100, 190],
},
});
const result = evaluator.evaluateTuple(world, [1, 2], {});
assert.strictEqual(result.truth, 'true');
assert.strictEqual(result.metrics?.mode, 'overlaps');
});
it('returns true when elements are within proximity radius', () => {
const evaluator = getPredicateEvaluator('near')!;
const world = makeWorld();
// Subject 1 is at (0,0)-(100,40), Subject 2 is at (110,50)-(210,90)
// gap horizontally = 110 - 100 = 10, which is within default radius 100
const result = evaluator.evaluateTuple(world, [1, 2], {});
assert.strictEqual(result.truth, 'true');
assert.strictEqual(result.metrics?.mode, 'proximity');
assert.strictEqual(result.metrics?.direction, 'leftOf');
assert.strictEqual(result.metrics?.radius, 100);
});
it('returns false when elements are far apart', () => {
const evaluator = getPredicateEvaluator('near')!;
const world = makeWorld();
// Subject 3 is at (50,0)-(150,100), Subject 4 is at (50,150)-(150,190)
// Vertical gap = 150 - 100 = 50, within default radius 100
// Actually that's within radius. Let's use a small radius.
const result = evaluator.evaluateTuple(world, [3, 4], { maxGap: 10 });
assert.strictEqual(result.truth, 'false');
assert.ok(result.diagnostics!.some((d) => d.code === 'IMH_RELATION_NEAR_FAILED'));
});
it('uses custom radius from options.maxGap', () => {
const evaluator = getPredicateEvaluator('near')!;
const world = makeWorld();
const result = evaluator.evaluateTuple(world, [1, 2], { maxGap: 5 });
// gap = 10, radius = 5, should fail
assert.strictEqual(result.truth, 'false');
assert.strictEqual(result.metrics?.radius, 5);
});
});
describe('under', () => {
it('delegates to below evaluator', () => {
const evaluator = getPredicateEvaluator('under')!;
const world = makeWorld();
// Subject 4 is below Subject 3
const result = evaluator.evaluateTuple(world, [4, 3], { minGap: 0, maxGap: 60 });
assert.strictEqual(result.truth, 'true');
});
it('fails when subject is above reference', () => {
const evaluator = getPredicateEvaluator('under')!;
const world = makeWorld();
// Subject 3 is above Subject 4, so under should fail
const result = evaluator.evaluateTuple(world, [3, 4], { minGap: 0, maxGap: 60 });
assert.strictEqual(result.truth, 'false');
});
});
describe('within', () => {
it('delegates to inside evaluator', () => {
const evaluator = getPredicateEvaluator('within')!;
const world = makeWorld({
boxes: {
boxId: [100, 200, 300, 400],
subjectId: [1, 2, 3, 4],
frameId: [1, 1, 1, 1],
borderLeft: [10, 0, 50, 50],
borderTop: [10, 0, 0, 150],
borderRight: [90, 200, 150, 150],
borderBottom: [30, 90, 100, 190],
paddingLeft: [0, 0, 0, 0],
paddingTop: [0, 0, 0, 0],
paddingRight: [0, 0, 0, 0],
paddingBottom: [0, 0, 0, 0],
contentLeft: [10, 0, 50, 50],
contentTop: [10, 0, 0, 150],
contentRight: [90, 200, 150, 150],
contentBottom: [30, 90, 100, 190],
},
});
// Subject 1 is inside Subject 2
const result = evaluator.evaluateTuple(world, [1, 2], {});
assert.strictEqual(result.truth, 'true');
});
it('fails when subject overflows reference', () => {
const evaluator = getPredicateEvaluator('within')!;
const world = makeWorld();
// Subject 2 is not inside Subject 1
const result = evaluator.evaluateTuple(world, [2, 1], {});
assert.strictEqual(result.truth, 'false');
});
it('reports zero overflow metrics when subject is fully inside', () => {
const evaluator = getPredicateEvaluator('within')!;
const world = makeWorld({
boxes: {
boxId: [100, 200, 300, 400],
subjectId: [1, 2, 3, 4],
frameId: [1, 1, 1, 1],
borderLeft: [10, 0, 50, 50],
borderTop: [10, 0, 0, 150],
borderRight: [90, 200, 150, 150],
borderBottom: [30, 90, 100, 190],
paddingLeft: [0, 0, 0, 0],
paddingTop: [0, 0, 0, 0],
paddingRight: [0, 0, 0, 0],
paddingBottom: [0, 0, 0, 0],
contentLeft: [10, 0, 50, 50],
contentTop: [10, 0, 0, 150],
contentRight: [90, 200, 150, 150],
contentBottom: [30, 90, 100, 190],
},
});
const result = evaluator.evaluateTuple(world, [1, 2], {});
assert.strictEqual(result.truth, 'true');
assert.strictEqual(result.metrics?.overflowTop, 0);
assert.strictEqual(result.metrics?.overflowRight, 0);
assert.strictEqual(result.metrics?.overflowBottom, 0);
assert.strictEqual(result.metrics?.overflowLeft, 0);
});
it('passes for subpixel overflow within default epsilon', () => {
const evaluator = getPredicateEvaluator('within')!;
const world = makeWorld({
boxes: {
boxId: [100, 200, 300, 400],
subjectId: [1, 2, 3, 4],
frameId: [1, 1, 1, 1],
borderLeft: [47.5947, 47.6093, 50, 50],
borderTop: [537.2549, 482.6552, 0, 150],
borderRight: [401.2353, 666.0625, 150, 150],
borderBottom: [584.4893, 1000.3740, 100, 190],
paddingLeft: [0, 0, 0, 0],
paddingTop: [0, 0, 0, 0],
paddingRight: [0, 0, 0, 0],
paddingBottom: [0, 0, 0, 0],
contentLeft: [47.5947, 47.6093, 50, 50],
contentTop: [537.2549, 482.6552, 0, 150],
contentRight: [401.2353, 666.0625, 150, 150],
contentBottom: [584.4893, 1000.3740, 100, 190],
},
});
const result = evaluator.evaluateTuple(world, [1, 2], {});
assert.strictEqual(result.truth, 'true');
assert.ok((result.metrics?.overflowLeft ?? 0) > 0);
assert.ok((result.metrics?.overflowLeft ?? 0) < 0.1);
});
});
describe('size unit conversion', () => {
it('converts vw thresholds for atMost', () => {
const evaluator = getPredicateEvaluator('atMost')!;
const world = makeWorld();
const result = evaluator.evaluateTuple(world, [1], { dimension: 'width', max: { value: 85, unit: 'vw' } });
assert.strictEqual(result.truth, 'true');
assert.ok((result.metrics?.max ?? 0) > 1000);
});
it('converts ch thresholds for atMost using css-derived ch width', () => {
const evaluator = getPredicateEvaluator('atMost')!;
const world = makeWorld() as any;
world.styles = { subjectId: [1, 2, 3, 4], fontSize: [16, 16, 16, 16], chWidth: [9, 9, 9, 9] };
const result = evaluator.evaluateTuple(world, [1], { dimension: 'width', max: { value: 60, unit: 'ch' } });
assert.strictEqual(result.truth, 'true');
assert.strictEqual(result.metrics?.max, 540);
});
});
});
+981
View File
@@ -0,0 +1,981 @@
/**
* Predicate registry and evaluator contracts for the Imhotep logic engine.
*
* Predicates are the atomic propositions over which first-order logic
* formulas are built. Each predicate declares its name, arity, domain
* signature, and required geometry facts.
*
* Invariant: a predicate evaluator is a pure function from a geometry
* world and a tuple of bound subject ids to a PredicateTruth result.
*/
import type { DiagnosticCode } from 'imhotep-core'
import type {
GeometryWorld,
ClauseResult,
ClauseStatus,
TruthValue,
} from './registry.js';
// ---------------------------------------------------------------------------
// Predicate Truth
// ---------------------------------------------------------------------------
export type PredicateTruth = 'true' | 'false' | 'indeterminate';
export interface PredicateResult {
truth: PredicateTruth;
metrics?: Record<string, number>;
witness?: {
subjectIds: number[];
};
diagnostics?: Array<{
code: DiagnosticCode;
severity: 'error' | 'warning' | 'info';
message: string;
}>;
}
// ---------------------------------------------------------------------------
// Predicate Descriptor
// ---------------------------------------------------------------------------
export interface PredicateDescriptor {
name: string;
arity: number;
domains: string[];
requiredFacts: string[];
}
// ---------------------------------------------------------------------------
// Predicate Evaluator
// ---------------------------------------------------------------------------
export interface PredicateEvaluator {
descriptor: PredicateDescriptor;
evaluateTuple(
world: GeometryWorld,
tuple: number[],
options?: Record<string, unknown>,
): PredicateResult;
}
// ---------------------------------------------------------------------------
// Built-in Predicates
// ---------------------------------------------------------------------------
export const BUILTIN_PREDICATES: PredicateDescriptor[] = [
{ name: 'width', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
{ name: 'height', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
{ name: 'above', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'below', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'leftOf', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'rightOf', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'inside', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'contains', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'overlaps', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'alignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'centeredWithin', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'atLeast', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
{ name: 'atMost', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
{ name: 'between', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
{ name: 'clippedBy', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.clipChain', 'reference.clipChain'] },
{ name: 'inStackingContext', arity: 1, domains: ['element'], requiredFacts: ['topology.stackingContextOf'] },
{ name: 'separatedFrom', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
// Spatial alias predicates
{ name: 'beside', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'nextTo', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'adjacent', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'touching', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'near', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'under', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'within', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
];
// ---------------------------------------------------------------------------
// Predicate Registry
// ---------------------------------------------------------------------------
export class PredicateRegistry {
private registry = new Map<string, PredicateEvaluator>();
register(evaluator: PredicateEvaluator): void {
this.registry.set(evaluator.descriptor.name, evaluator);
}
get(name: string): PredicateEvaluator | undefined {
return this.registry.get(name);
}
list(): string[] {
return Array.from(this.registry.keys());
}
clear(): void {
this.registry.clear();
}
getDescriptor(name: string): PredicateDescriptor | undefined {
return this.registry.get(name)?.descriptor;
}
getRequiredFacts(name: string): string[] {
return this.getDescriptor(name)?.requiredFacts ?? [];
}
}
// Global default instance for backward compatibility.
export const globalPredicateRegistry = new PredicateRegistry();
export function registerPredicate(evaluator: PredicateEvaluator): void {
globalPredicateRegistry.register(evaluator);
}
export function getPredicateEvaluator(name: string): PredicateEvaluator | undefined {
return globalPredicateRegistry.get(name);
}
export function listRegisteredPredicates(): string[] {
return globalPredicateRegistry.list();
}
export function clearPredicateRegistry(): void {
globalPredicateRegistry.clear();
defaultPredicatesRegistered = false;
}
export function getPredicateDescriptor(name: string): PredicateDescriptor | undefined {
return globalPredicateRegistry.getDescriptor(name);
}
export function getRequiredFactsForPredicate(name: string): string[] {
return globalPredicateRegistry.getRequiredFacts(name);
}
// ---------------------------------------------------------------------------
// Built-in Predicate Evaluators
// ---------------------------------------------------------------------------
function getBorderRect(world: GeometryWorld, subjectId: number) {
const { boxes } = world;
// Build a subjectId -> boxIndex map on first use for O(1) lookups.
// This avoids O(n) linear scans on every predicate evaluation.
let index = (world as any).__boxIndex as Map<number, number> | undefined;
if (!index) {
index = new Map<number, number>();
for (let i = 0; i < boxes.subjectId.length; i++) {
index.set(boxes.subjectId[i], i);
}
(world as any).__boxIndex = index;
}
const i = index.get(subjectId);
if (i === undefined) {
return null;
}
return {
left: boxes.borderLeft[i],
top: boxes.borderTop[i],
right: boxes.borderRight[i],
bottom: boxes.borderBottom[i],
};
}
function makePredicateResult(
truth: PredicateTruth,
metrics?: Record<string, number>,
witness?: number[],
diagnostics?: PredicateResult['diagnostics'],
): PredicateResult {
return {
truth,
metrics,
witness: witness ? { subjectIds: witness } : undefined,
diagnostics,
};
}
/**
* Build a diagnostic for a failing predicate that includes observed values.
*/
function makePredicateDiagnostic(
predicate: string,
metrics: Record<string, number>,
subjectIds: number[],
): PredicateResult['diagnostics'] {
const entries = Object.entries(metrics)
.map(([k, v]) => `${k}=${typeof v === 'number' ? v.toFixed(2) : String(v)}`)
.join(', ');
return [
{
code: 'IMH_PREDICATE_FAILED',
severity: 'error',
message: `Predicate "${predicate}" failed for subjects [${subjectIds.join(', ')}]. Observed: { ${entries} }`,
},
];
}
function parseLengthOption(raw: unknown): { value: number; unit: string } | null {
if (typeof raw === 'number') {
return { value: raw, unit: 'px' };
}
if (typeof raw === 'string') {
const trimmed = raw.trim();
const m = trimmed.match(/^(-?\d*\.?\d+)\s*([a-z%]+)?$/i);
if (!m) return null;
const value = Number.parseFloat(m[1] ?? '');
if (Number.isNaN(value)) return null;
return { value, unit: (m[2] ?? 'px').toLowerCase() };
}
if (raw && typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
if (typeof obj.value === 'number') {
return {
value: obj.value,
unit: typeof obj.unit === 'string' ? obj.unit.toLowerCase() : 'px',
};
}
}
return null;
}
function getSubjectFontSizePx(world: GeometryWorld, subjectId: number): number {
const styles = (world as any).styles as { fontSize?: ArrayLike<number> } | undefined;
const fontSize = styles?.fontSize;
if (!fontSize) return 16;
const ids = world.subjects?.ids;
if (!ids) return 16;
const idx = ids.indexOf(subjectId);
if (idx < 0) return 16;
const fs = Number(fontSize[idx] ?? 16);
return Number.isFinite(fs) && fs > 0 ? fs : 16;
}
function getRootFontSizePx(world: GeometryWorld): number {
const styles = (world as any).styles as { fontSize?: ArrayLike<number> } | undefined;
const fontSize = styles?.fontSize;
if (!fontSize || fontSize.length === 0) return 16;
const fs = Number(fontSize[0] ?? 16);
return Number.isFinite(fs) && fs > 0 ? fs : 16;
}
function getSubjectChWidthPx(world: GeometryWorld, subjectId: number): number {
const styles = (world as any).styles as {
subjectId?: ArrayLike<number>
chWidth?: ArrayLike<number>
} | undefined;
const chWidth = styles?.chWidth;
if (!chWidth || chWidth.length === 0) {
return getSubjectFontSizePx(world, subjectId) * 0.5;
}
const styleSubjectIds = styles?.subjectId;
if (styleSubjectIds && styleSubjectIds.length > 0) {
for (let i = 0; i < styleSubjectIds.length; i++) {
if (Number(styleSubjectIds[i]) !== subjectId) continue;
const w = Number(chWidth[i]);
if (Number.isFinite(w) && w > 0) return w;
break;
}
}
const ids = world.subjects?.ids;
if (ids) {
const idx = ids.indexOf(subjectId);
if (idx >= 0) {
const w = Number(chWidth[idx]);
if (Number.isFinite(w) && w > 0) return w;
}
}
return getSubjectFontSizePx(world, subjectId) * 0.5;
}
function resolveLengthToPx(
world: GeometryWorld,
subjectId: number,
raw: unknown,
dimension: 'width' | 'height',
): number | undefined {
const parsed = parseLengthOption(raw);
if (!parsed) return undefined;
const { value, unit } = parsed;
switch (unit) {
case 'px':
return value;
case 'vw':
return value * (world.env.viewportWidth / 100);
case 'vh':
return value * (world.env.viewportHeight / 100);
case '%':
return value * ((dimension === 'height' ? world.env.viewportHeight : world.env.viewportWidth) / 100);
case 'rem':
return value * getRootFontSizePx(world);
case 'em':
return value * getSubjectFontSizePx(world, subjectId);
case 'ch':
return value * getSubjectChWidthPx(world, subjectId);
default:
return value;
}
}
export const widthPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[0]!,
evaluateTuple(world, tuple) {
const subjectId = tuple[0];
if (subjectId === undefined) {
return makePredicateResult('indeterminate');
}
const rect = getBorderRect(world, subjectId);
if (!rect) {
return makePredicateResult('indeterminate');
}
const w = rect.right - rect.left;
return makePredicateResult('true', { width: w, left: rect.left, right: rect.right }, [subjectId]);
},
};
export const heightPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[1]!,
evaluateTuple(world, tuple) {
const subjectId = tuple[0];
if (subjectId === undefined) {
return makePredicateResult('indeterminate');
}
const rect = getBorderRect(world, subjectId);
if (!rect) {
return makePredicateResult('indeterminate');
}
const h = rect.bottom - rect.top;
return makePredicateResult('true', { height: h }, [subjectId]);
},
};
export const abovePredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[2]!,
evaluateTuple(world, tuple, options) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
const gap = rRect.top - sRect.bottom;
const minGap = (options?.minGap as number | undefined) ?? 0;
const maxGap = (options?.maxGap as number | undefined) ?? Infinity;
const pass = gap >= minGap && gap <= maxGap;
const metrics = { gap, observedGap: gap, minGap, maxGap, subjectBottom: sRect.bottom, refTop: rRect.top };
const diagnostics = pass ? undefined : makePredicateDiagnostic('above', metrics, [subjectId, referenceId]);
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
},
};
export const belowPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[3]!,
evaluateTuple(world, tuple, options) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
const gap = sRect.top - rRect.bottom;
const minGap = (options?.minGap as number | undefined) ?? 0;
const maxGap = (options?.maxGap as number | undefined) ?? Infinity;
const pass = gap >= minGap && gap <= maxGap;
const metrics = { gap, observedGap: gap, minGap, maxGap };
const diagnostics = pass ? undefined : makePredicateDiagnostic('below', metrics, [subjectId, referenceId]);
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
},
};
export const leftOfPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[4]!,
evaluateTuple(world, tuple, options) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
const gap = rRect.left - sRect.right;
const minGap = (options?.minGap as number | undefined) ?? 0;
const maxGap = (options?.maxGap as number | undefined) ?? Infinity;
const pass = gap >= minGap && gap <= maxGap;
const metrics = { gap, observedGap: gap, minGap, maxGap, subjectRight: sRect.right, refLeft: rRect.left };
const diagnostics = pass ? undefined : makePredicateDiagnostic('leftOf', metrics, [subjectId, referenceId]);
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
},
};
export const rightOfPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[5]!,
evaluateTuple(world, tuple, options) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
const gap = sRect.left - rRect.right;
const minGap = (options?.minGap as number | undefined) ?? 0;
const maxGap = (options?.maxGap as number | undefined) ?? Infinity;
const pass = gap >= minGap && gap <= maxGap;
const metrics = { gap, observedGap: gap, minGap, maxGap };
const diagnostics = pass ? undefined : makePredicateDiagnostic('rightOf', metrics, [subjectId, referenceId]);
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
},
};
export const insidePredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[6]!,
evaluateTuple(world, tuple, options) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
const tolerance = (options?.tolerance as number | undefined) ?? 0;
const epsilon = (options?.epsilon as number | undefined) ?? 0.1;
const effectiveTolerance = tolerance + epsilon;
const pass =
sRect.left >= rRect.left - effectiveTolerance &&
sRect.top >= rRect.top - effectiveTolerance &&
sRect.right <= rRect.right + effectiveTolerance &&
sRect.bottom <= rRect.bottom + effectiveTolerance;
const metrics = {
overflowLeft: Math.max(0, rRect.left - sRect.left),
overflowTop: Math.max(0, rRect.top - sRect.top),
overflowRight: Math.max(0, sRect.right - rRect.right),
overflowBottom: Math.max(0, sRect.bottom - rRect.bottom),
tolerance,
epsilon,
effectiveTolerance,
subjectLeft: sRect.left,
subjectTop: sRect.top,
subjectRight: sRect.right,
subjectBottom: sRect.bottom,
refLeft: rRect.left,
refTop: rRect.top,
refRight: rRect.right,
refBottom: rRect.bottom,
};
const diagnostics = pass ? undefined : makePredicateDiagnostic('inside', metrics, [subjectId, referenceId]);
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
},
};
export const containsPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[7]!,
evaluateTuple(world, tuple, options) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
const tolerance = (options?.tolerance as number | undefined) ?? 0;
const pass =
rRect.left >= sRect.left - tolerance &&
rRect.top >= sRect.top - tolerance &&
rRect.right <= sRect.right + tolerance &&
rRect.bottom <= sRect.bottom + tolerance;
const metrics = {
tolerance,
subjectLeft: sRect.left,
subjectTop: sRect.top,
subjectRight: sRect.right,
subjectBottom: sRect.bottom,
refLeft: rRect.left,
refTop: rRect.top,
refRight: rRect.right,
refBottom: rRect.bottom,
};
const diagnostics = pass ? undefined : makePredicateDiagnostic('contains', metrics, [subjectId, referenceId]);
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
},
};
export const overlapsPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[8]!,
evaluateTuple(world, tuple) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
const pass =
sRect.left < rRect.right &&
sRect.right > rRect.left &&
sRect.top < rRect.bottom &&
sRect.bottom > rRect.top;
return makePredicateResult(pass ? 'true' : 'false', {}, [subjectId, referenceId]);
},
};
export const alignedWithPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[9]!,
evaluateTuple(world, tuple, options) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
const axis = (options?.axis as string | undefined) ?? 'centerY';
const tolerance = (options?.tolerance as number | undefined) ?? 0;
let delta = Infinity;
switch (axis) {
case 'left':
delta = Math.abs(sRect.left - rRect.left);
break;
case 'right':
delta = Math.abs(sRect.right - rRect.right);
break;
case 'top':
delta = Math.abs(sRect.top - rRect.top);
break;
case 'bottom':
delta = Math.abs(sRect.bottom - rRect.bottom);
break;
case 'centerX':
delta = Math.abs((sRect.left + sRect.right) / 2 - (rRect.left + rRect.right) / 2);
break;
case 'centerY':
delta = Math.abs((sRect.top + sRect.bottom) / 2 - (rRect.top + rRect.bottom) / 2);
break;
}
const pass = delta <= tolerance;
const metrics: Record<string, number> = { delta, tolerance };
const diagnostics = pass ? undefined : makePredicateDiagnostic('alignedWith', metrics, [subjectId, referenceId]);
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
},
};
export const centeredWithinPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[10]!,
evaluateTuple(world, tuple, options) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
const tolerance = (options?.tolerance as number | undefined) ?? 0;
const sCx = (sRect.left + sRect.right) / 2;
const sCy = (sRect.top + sRect.bottom) / 2;
const rCx = (rRect.left + rRect.right) / 2;
const rCy = (rRect.top + rRect.bottom) / 2;
const deltaX = Math.abs(sCx - rCx);
const deltaY = Math.abs(sCy - rCy);
const pass = deltaX <= tolerance && deltaY <= tolerance;
const metrics = { deltaX, deltaY, tolerance };
const diagnostics = pass ? undefined : makePredicateDiagnostic('centeredWithin', metrics, [subjectId, referenceId]);
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
},
};
export const atLeastPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[11]!,
evaluateTuple(world, tuple, options) {
const subjectId = tuple[0];
if (subjectId === undefined) {
return makePredicateResult('indeterminate');
}
const rect = getBorderRect(world, subjectId);
if (!rect) {
return makePredicateResult('indeterminate');
}
const dimension = options?.dimension as string | undefined;
const min = resolveLengthToPx(world, subjectId, options?.min, dimension === 'height' ? 'height' : 'width');
const value = dimension === 'height' ? rect.bottom - rect.top : rect.right - rect.left;
if (min === undefined) {
return makePredicateResult('true', { value, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }, [subjectId]);
}
const pass = value >= min;
const metrics = { value, min, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom };
const diagnostics = pass ? undefined : makePredicateDiagnostic('atLeast', metrics, [subjectId]);
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId], diagnostics);
},
};
export const atMostPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[12]!,
evaluateTuple(world, tuple, options) {
const subjectId = tuple[0];
if (subjectId === undefined) {
return makePredicateResult('indeterminate');
}
const rect = getBorderRect(world, subjectId);
if (!rect) {
return makePredicateResult('indeterminate');
}
const dimension = options?.dimension as string | undefined;
const max = resolveLengthToPx(world, subjectId, options?.max, dimension === 'height' ? 'height' : 'width');
const value = dimension === 'height' ? rect.bottom - rect.top : rect.right - rect.left;
if (max === undefined) {
return makePredicateResult('true', { value, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }, [subjectId]);
}
const pass = value <= max;
const metrics = { value, max, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom };
const diagnostics = pass ? undefined : makePredicateDiagnostic('atMost', metrics, [subjectId]);
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId], diagnostics);
},
};
export const betweenPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[13]!,
evaluateTuple(world, tuple, options) {
const subjectId = tuple[0];
if (subjectId === undefined) {
return makePredicateResult('indeterminate');
}
const rect = getBorderRect(world, subjectId);
if (!rect) {
return makePredicateResult('indeterminate');
}
const dimension = options?.dimension as string | undefined;
const min = resolveLengthToPx(world, subjectId, options?.min, dimension === 'height' ? 'height' : 'width');
const max = resolveLengthToPx(world, subjectId, options?.max, dimension === 'height' ? 'height' : 'width');
const value = dimension === 'height' ? rect.bottom - rect.top : rect.right - rect.left;
if (min === undefined && max === undefined) {
return makePredicateResult('true', { value, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }, [subjectId]);
}
const pass = (min === undefined || value >= min) && (max === undefined || value <= max);
const metrics = { value, min: min ?? -Infinity, max: max ?? Infinity, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom };
const diagnostics = pass ? undefined : makePredicateDiagnostic('between', metrics, [subjectId]);
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId], diagnostics);
},
};
export const clippedByPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[14]!,
evaluateTuple(world, tuple) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
// Simplified: check if subject's clipping root is the reference
const clipRoot = world.topology.clippingRootOf[subjectId - 1] ?? 0;
const pass = clipRoot === referenceId;
return makePredicateResult(pass ? 'true' : 'false', {}, [subjectId, referenceId]);
},
};
export const inStackingContextPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[15]!,
evaluateTuple(world, tuple) {
const subjectId = tuple[0];
if (subjectId === undefined) {
return makePredicateResult('indeterminate');
}
const sc = world.topology.stackingContextOf[subjectId - 1] ?? 0;
const pass = sc > 0;
return makePredicateResult(pass ? 'true' : 'false', { stackingContext: sc }, [subjectId]);
},
};
// ---------------------------------------------------------------------------
// Spatial Alias Predicate Evaluators
// ---------------------------------------------------------------------------
/**
* beside(subject, opts) = leftOf(subject, opts) OR rightOf(subject, opts)
* Tries leftOf first, then rightOf. Returns whichever passes, or fails with both measured values.
*/
export const besidePredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[17]!,
evaluateTuple(world, tuple, options) {
const leftResult = leftOfPredicate.evaluateTuple(world, tuple, options);
if (leftResult.truth === 'true') {
return makePredicateResult('true', { ...leftResult.metrics, direction: 'left' } as any, tuple);
}
const rightResult = rightOfPredicate.evaluateTuple(world, tuple, options);
if (rightResult.truth === 'true') {
return makePredicateResult('true', { ...rightResult.metrics, direction: 'right' } as any, tuple);
}
const metrics = {
leftGap: leftResult.metrics?.gap ?? NaN,
rightGap: rightResult.metrics?.gap ?? NaN,
};
return makePredicateResult('false', metrics, tuple, [
{
code: 'IMH_RELATION_BESIDE_FAILED',
severity: 'error',
message: `Predicate "beside" failed: neither leftOf (gap=${(metrics.leftGap as number).toFixed(2)}) nor rightOf (gap=${(metrics.rightGap as number).toFixed(2)}) satisfied.`,
},
]);
},
};
/**
* nextTo(subject, opts) = beside(subject, opts)
* Synonym for beside — delegates to the beside evaluator.
*/
export const nextToPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[18]!,
evaluateTuple(world, tuple, options) {
const besideResult = besidePredicate.evaluateTuple(world, tuple, options);
if (besideResult.truth === 'true') {
return makePredicateResult('true', { ...besideResult.metrics, alias: 'nextTo' } as any, tuple);
}
// Rewrite diagnostic to mention nextTo
const diagnostics = besideResult.diagnostics?.map((d) => ({
...d,
code: 'IMH_RELATION_BESIDE_FAILED' as const,
message: d.message.replace('"beside"', '"nextTo"'),
}));
return makePredicateResult('false', besideResult.metrics ?? {}, tuple, diagnostics);
},
};
/**
* adjacent(subject, opts) = leftOr(subject, { maxGap: 0 }) OR rightOf(subject, { maxGap: 0 })
* OR above(subject, { maxGap: 0 }) OR below(subject, { maxGap: 0 })
* Checks all four cardinal directions with maxGap: 0. Returns whichever passes, or fails.
*/
export const adjacentPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[19]!,
evaluateTuple(world, tuple, options) {
const adjOptions = { ...options, maxGap: 0 };
const directions = [
{ name: 'leftOf', result: leftOfPredicate.evaluateTuple(world, tuple, adjOptions) },
{ name: 'rightOf', result: rightOfPredicate.evaluateTuple(world, tuple, adjOptions) },
{ name: 'above', result: abovePredicate.evaluateTuple(world, tuple, adjOptions) },
{ name: 'below', result: belowPredicate.evaluateTuple(world, tuple, adjOptions) },
];
for (const dir of directions) {
if (dir.result.truth === 'true') {
return makePredicateResult('true', { ...dir.result.metrics, direction: dir.name } as any, tuple);
}
}
const metrics = {
leftGap: directions[0].result.metrics?.gap ?? NaN,
rightGap: directions[1].result.metrics?.gap ?? NaN,
aboveGap: directions[2].result.metrics?.gap ?? NaN,
belowGap: directions[3].result.metrics?.gap ?? NaN,
};
return makePredicateResult('false', metrics, tuple, [
{
code: 'IMH_RELATION_ADJACENT_FAILED',
severity: 'error',
message: `Predicate "adjacent" failed: no direction satisfied with maxGap=0.`,
},
]);
},
};
/**
* touching(subject, opts) = adjacent(subject, opts)
* Synonym for adjacent — delegates to the adjacent evaluator.
*/
export const touchingPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[20]!,
evaluateTuple(world, tuple, options) {
const adjacentResult = adjacentPredicate.evaluateTuple(world, tuple, options);
if (adjacentResult.truth === 'true') {
return makePredicateResult('true', { ...adjacentResult.metrics, alias: 'touching' } as any, tuple);
}
const diagnostics = adjacentResult.diagnostics?.map((d) => ({
...d,
code: 'IMH_RELATION_ADJACENT_FAILED' as const,
message: d.message.replace('"adjacent"', '"touching"'),
}));
return makePredicateResult('false', adjacentResult.metrics ?? {}, tuple, diagnostics);
},
};
/**
* near(subject, opts) = overlaps(subject) OR inside a proximity radius
* First checks overlaps. If that fails, checks directional proximity using leftOf/rightOf/above/below
* with a large maxGap (default 100px, overridable via opts.maxGap).
*/
export const nearPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[21]!,
evaluateTuple(world, tuple, options) {
const overlapsResult = overlapsPredicate.evaluateTuple(world, tuple);
if (overlapsResult.truth === 'true') {
return makePredicateResult('true', { ...overlapsResult.metrics, mode: 'overlaps' } as any, tuple);
}
const radius = (options?.maxGap as number | undefined) ?? 100;
const proximityOptions = { ...options, maxGap: radius };
const directions = [
{ name: 'leftOf', result: leftOfPredicate.evaluateTuple(world, tuple, proximityOptions) },
{ name: 'rightOf', result: rightOfPredicate.evaluateTuple(world, tuple, proximityOptions) },
{ name: 'above', result: abovePredicate.evaluateTuple(world, tuple, proximityOptions) },
{ name: 'below', result: belowPredicate.evaluateTuple(world, tuple, proximityOptions) },
];
for (const dir of directions) {
if (dir.result.truth === 'true') {
return makePredicateResult('true', { ...dir.result.metrics, direction: dir.name, mode: 'proximity', radius } as any, tuple);
}
}
const metrics = {
radius,
leftGap: directions[0].result.metrics?.gap ?? NaN,
rightGap: directions[1].result.metrics?.gap ?? NaN,
aboveGap: directions[2].result.metrics?.gap ?? NaN,
belowGap: directions[3].result.metrics?.gap ?? NaN,
};
return makePredicateResult('false', metrics, tuple, [
{
code: 'IMH_RELATION_NEAR_FAILED',
severity: 'error',
message: `Predicate "near" failed: elements neither overlap nor are within proximity radius ${radius}.`,
},
]);
},
};
/**
* under(subject, opts) = below(subject, opts)
* Synonym for below — delegates to the below evaluator.
*/
export const underPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[22]!,
evaluateTuple(world, tuple, options) {
return belowPredicate.evaluateTuple(world, tuple, options);
},
};
/**
* within(subject, opts) = inside(subject, opts)
* Synonym for inside — delegates to the inside evaluator.
*/
export const withinPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[23]!,
evaluateTuple(world, tuple, options) {
return insidePredicate.evaluateTuple(world, tuple, options);
},
};
// ---------------------------------------------------------------------------
// Not-Yet-Implemented Predicates
// ---------------------------------------------------------------------------
function makeNotImplementedPredicate(name: string): PredicateEvaluator {
return {
descriptor: {
name,
arity: 2,
domains: ['element', 'element'],
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
},
evaluateTuple(_world, tuple) {
const subjectId = tuple[0];
const referenceId = tuple[1];
return makePredicateResult('false', {}, [subjectId, referenceId].filter((id): id is number => id !== undefined), [
{
code: 'IMH_FEATURE_NOT_YET_IMPLEMENTED',
severity: 'error',
message: `Predicate "${name}" is not yet implemented.`,
},
]);
},
};
}
export const separatedFromPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[16]!,
evaluateTuple(world, tuple, options) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
// separatedFrom = NOT overlaps
const overlaps =
sRect.left < rRect.right &&
sRect.right > rRect.left &&
sRect.top < rRect.bottom &&
sRect.bottom > rRect.top;
const pass = !overlaps;
const metrics: Record<string, number> = {};
if (!pass) {
// Calculate overlap area for diagnostics
const overlapLeft = Math.max(sRect.left, rRect.left);
const overlapTop = Math.max(sRect.top, rRect.top);
const overlapRight = Math.min(sRect.right, rRect.right);
const overlapBottom = Math.min(sRect.bottom, rRect.bottom);
metrics.overlapWidth = overlapRight - overlapLeft;
metrics.overlapHeight = overlapBottom - overlapTop;
}
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId]);
},
};
export const leftAlignedWithPredicate = makeNotImplementedPredicate('leftAlignedWith');
export const rightAlignedWithPredicate = makeNotImplementedPredicate('rightAlignedWith');
export const topAlignedWithPredicate = makeNotImplementedPredicate('topAlignedWith');
export const bottomAlignedWithPredicate = makeNotImplementedPredicate('bottomAlignedWith');
// ---------------------------------------------------------------------------
// Register Defaults
// ---------------------------------------------------------------------------
let defaultPredicatesRegistered = false;
export function registerDefaultPredicates(): void {
if (defaultPredicatesRegistered) return;
defaultPredicatesRegistered = true;
registerPredicate(widthPredicate);
registerPredicate(heightPredicate);
registerPredicate(abovePredicate);
registerPredicate(belowPredicate);
registerPredicate(leftOfPredicate);
registerPredicate(rightOfPredicate);
registerPredicate(insidePredicate);
registerPredicate(containsPredicate);
registerPredicate(overlapsPredicate);
registerPredicate(alignedWithPredicate);
registerPredicate(centeredWithinPredicate);
registerPredicate(atLeastPredicate);
registerPredicate(atMostPredicate);
registerPredicate(betweenPredicate);
registerPredicate(clippedByPredicate);
registerPredicate(inStackingContextPredicate);
registerPredicate(separatedFromPredicate);
registerPredicate(leftAlignedWithPredicate);
registerPredicate(rightAlignedWithPredicate);
registerPredicate(topAlignedWithPredicate);
registerPredicate(bottomAlignedWithPredicate);
registerPredicate(besidePredicate);
registerPredicate(nextToPredicate);
registerPredicate(adjacentPredicate);
registerPredicate(touchingPredicate);
registerPredicate(nearPredicate);
registerPredicate(underPredicate);
registerPredicate(withinPredicate);
}
+314
View File
@@ -0,0 +1,314 @@
/**
* Tests for relation-specific proof generation.
*
* Verifies that generateProof produces rich, relation-specific failedPredicate
* details instead of generic left/right metric comparisons.
*/
import { describe, it, beforeEach } from 'node:test'
import assert from 'node:assert'
import {
generateProof,
resetProofCounter,
} from './proofs.js'
import type {
ClauseResult,
ClauseDescriptor,
GeometryWorld,
} from './registry.js'
// ---------------------------------------------------------------------------
// Mock World
// ---------------------------------------------------------------------------
const world: GeometryWorld = {
sceneId: 'scene_1',
snapshotId: 'snap_1',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: [1, 2],
domNodeId: [10, 20],
subjectKind: [1, 1],
primaryBoxId: [100, 200],
firstFragmentId: [0, 0],
fragmentCount: [0, 0],
},
dom: {
nodeId: [10, 20],
parentNodeId: [0, 0],
childCount: [0, 0],
tagNameStringId: [0, 0],
},
boxes: {
boxId: [100, 200],
subjectId: [1, 2],
frameId: [1, 1],
borderLeft: [0, 110],
borderTop: [0, 50],
borderRight: [100, 210],
borderBottom: [40, 90],
paddingLeft: [0, 0],
paddingTop: [0, 0],
paddingRight: [0, 0],
paddingBottom: [0, 0],
contentLeft: [0, 0],
contentTop: [0, 0],
contentRight: [0, 0],
contentBottom: [0, 0],
},
visualBoxes: {
boxId: [], subjectId: [], frameId: [],
borderLeft: [], borderTop: [], borderRight: [], borderBottom: [],
paddingLeft: [], paddingTop: [], paddingRight: [], paddingBottom: [],
contentLeft: [], contentTop: [], contentRight: [], contentBottom: [],
},
transforms: {
transformId: [], subjectId: [], matrixStart: [], matrixLength: [],
originX: [], originY: [],
},
matrices: { values: [] },
rects: { rectId: [], left: [], top: [], right: [], bottom: [] },
topology: {
containingBlockOf: [0, 0],
nearestPositionedAncestorOf: [0, 0],
scrollContainerOf: [0, 0],
stackingContextOf: [0, 0],
formattingContextOf: [0, 0],
clippingRootOf: [0, 0],
paintOrderBucket: [0, 0],
paintOrderIndex: [0, 0],
},
scroll: {
containerId: [],
scrollLeft: [],
scrollTop: [],
scrollWidth: [],
scrollHeight: [],
clientWidth: [],
clientHeight: [],
},
clipping: {
clipNodeId: [],
subjectId: [],
clipKind: [],
clipLeft: [],
clipTop: [],
clipRight: [],
clipBottom: [],
parentClipNodeId: [],
},
visibility: {
subjectId: [],
isRendered: [],
isVisible: [],
visibleArea: [],
clippedArea: [],
},
}
function makeClause(kind: string): ClauseDescriptor {
return {
clauseId: 'clause_1',
clauseKind: kind,
version: 1,
subjectRef: 1,
referenceRef: 2,
}
}
function makeResult(status: 'pass' | 'fail' | 'error', metrics?: Record<string, number>): ClauseResult {
return {
clauseId: 'clause_1',
status,
truth: status === 'error' ? 'indeterminate' : 'determinate',
metrics,
witness: { subjectId: 1, referenceId: 2 },
}
}
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
beforeEach(() => {
resetProofCounter()
})
// ---------------------------------------------------------------------------
// Relation-Specific Proof Tests
// ---------------------------------------------------------------------------
describe('relation-specific proofs', () => {
it('leftOf proof includes measured gap and expected bounds', () => {
const clause = makeClause('relation.leftOf')
const result = makeResult('fail', {
observedGap: -5,
minGap: 0,
maxGap: Infinity,
subjectLeft: 0,
subjectTop: 0,
subjectRight: 100,
subjectBottom: 40,
refLeft: 90,
refTop: 50,
refRight: 190,
refBottom: 90,
})
const proof = generateProof(result, clause, world)
assert.strictEqual(proof.outcome, 'fail')
assert.ok(proof.failedPredicate)
assert.strictEqual(proof.failedPredicate!.relationKind, 'leftOf')
assert.strictEqual(proof.failedPredicate!.measuredGap, -5)
assert.strictEqual(proof.failedPredicate!.expectedMinGap, 0)
assert.ok(proof.failedPredicate!.subjectRect)
assert.strictEqual(proof.failedPredicate!.subjectRect!.right, 100)
assert.ok(proof.failedPredicate!.referenceRect)
assert.strictEqual(proof.failedPredicate!.referenceRect!.left, 90)
})
it('above proof includes vertical gap and positions', () => {
const clause = makeClause('relation.above')
const result = makeResult('fail', {
observedGap: -3,
minGap: 0,
maxGap: Infinity,
subjectLeft: 0,
subjectTop: 0,
subjectRight: 100,
subjectBottom: 40,
refLeft: 0,
refTop: 35,
refRight: 100,
refBottom: 75,
})
const proof = generateProof(result, clause, world)
assert.strictEqual(proof.failedPredicate!.relationKind, 'above')
assert.strictEqual(proof.failedPredicate!.measuredGap, -3)
assert.ok(proof.failedPredicate!.subjectRect)
assert.ok(proof.failedPredicate!.referenceRect)
})
it('inside proof includes overflow edges', () => {
const clause = makeClause('relation.inside')
const result = makeResult('fail', {
overflowLeft: 10,
overflowTop: 0,
overflowRight: -5,
overflowBottom: 0,
subjectLeft: 10,
subjectTop: 0,
subjectRight: 105,
subjectBottom: 40,
refLeft: 0,
refTop: 0,
refRight: 100,
refBottom: 40,
})
const proof = generateProof(result, clause, world)
assert.strictEqual(proof.failedPredicate!.relationKind, 'inside')
assert.ok(proof.failedPredicate!.overflowEdges)
assert.strictEqual(proof.failedPredicate!.overflowEdges!.left, 10)
assert.strictEqual(proof.failedPredicate!.overflowEdges!.right, -5)
assert.ok(proof.failedPredicate!.subjectRect)
assert.ok(proof.failedPredicate!.referenceRect)
})
it('atLeast proof includes measured vs expected dimensions', () => {
const clause = makeClause('size.atLeast')
const result = makeResult('fail', {
observed: 80,
min: 100,
})
const proof = generateProof(result, clause, world)
assert.strictEqual(proof.failedPredicate!.relationKind, 'atLeast')
assert.strictEqual(proof.failedPredicate!.measuredValue, 80)
assert.strictEqual(proof.failedPredicate!.expectedMin, 100)
})
it('atMost proof includes measured vs expected dimensions', () => {
const clause = makeClause('size.atMost')
const result = makeResult('fail', {
observed: 120,
max: 100,
})
const proof = generateProof(result, clause, world)
assert.strictEqual(proof.failedPredicate!.relationKind, 'atMost')
assert.strictEqual(proof.failedPredicate!.measuredValue, 120)
assert.strictEqual(proof.failedPredicate!.expectedMax, 100)
})
it('alignedWith proof includes delta and tolerance', () => {
const clause = makeClause('alignment.alignedWith')
const result = makeResult('fail', {
delta: 5,
tolerance: 1,
})
const proof = generateProof(result, clause, world)
assert.strictEqual(proof.failedPredicate!.relationKind, 'alignedWith')
assert.strictEqual(proof.failedPredicate!.measuredValue, 5)
assert.strictEqual(proof.failedPredicate!.expectedMax, 1)
})
it('centeredWithin proof includes deltaX, deltaY and tolerance', () => {
const clause = makeClause('alignment.centeredWithin')
const result = makeResult('fail', {
deltaX: 3,
deltaY: 4,
tolerance: 2,
})
const proof = generateProof(result, clause, world)
assert.strictEqual(proof.failedPredicate!.relationKind, 'centeredWithin')
assert.strictEqual(proof.failedPredicate!.measuredValue, 3)
assert.strictEqual(proof.failedPredicate!.expectedMax, 2)
})
it('omits failedPredicate on pass', () => {
const clause = makeClause('relation.leftOf')
const result = makeResult('pass', { observedGap: 10, minGap: 0 })
const proof = generateProof(result, clause, world)
assert.strictEqual(proof.outcome, 'pass')
assert.strictEqual(proof.failedPredicate, undefined)
})
it('falls back to generic synthesis for unknown relation kinds', () => {
const clause = makeClause('relation.unknownRelation')
const result = makeResult('fail', { foo: 10, bar: 20 })
const proof = generateProof(result, clause, world)
assert.ok(proof.failedPredicate)
assert.strictEqual(proof.failedPredicate!.relationKind, 'unknownRelation')
assert.strictEqual(proof.failedPredicate!.op, '<')
assert.strictEqual(proof.failedPredicate!.left, 10)
assert.strictEqual(proof.failedPredicate!.right, 20)
})
})
+278
View File
@@ -0,0 +1,278 @@
/**
* Proof object generation.
*
* Every evaluated clause produces a proof object carrying used facts,
* derived facts, failed predicates, and witness data.
*
* Relation-aware synthesis: generateProof inspects the clause kind and
* metrics to produce rich, relation-specific diagnostics instead of a
* generic left/right comparison.
*/
import {
type ClauseResult,
type ClauseDescriptor,
type Proof,
type GeometryWorld,
} from './registry.js';
let proofCounter = 0;
function nextProofId(): string {
return `proof_${++proofCounter}`;
}
/**
* Reset the proof counter (mainly for tests).
*/
export function resetProofCounter(): void {
proofCounter = 0;
}
// ---------------------------------------------------------------------------
// Relation-specific proof synthesis
// ---------------------------------------------------------------------------
function buildFailedPredicate(
clauseKind: string,
metrics: Record<string, number>,
): Proof['failedPredicate'] {
// Normalize kind to handle prefix forms like "relation.leftOf".
const kind = clauseKind.includes('.')
? clauseKind.slice(clauseKind.indexOf('.') + 1)
: clauseKind;
const base = {
op: '<',
left: 0,
right: 0,
relationKind: kind,
};
switch (kind) {
case 'leftOf':
case 'rightOf':
case 'above':
case 'below': {
const gap = metrics.observedGap ?? metrics.gap ?? 0;
const min = metrics.minGap ?? 0;
const max = metrics.maxGap ?? Infinity;
return {
...base,
op: gap < min ? '<' : '>',
left: gap,
right: gap < min ? min : max,
measuredGap: gap,
expectedMinGap: Number.isFinite(min) ? min : undefined,
expectedMaxGap: Number.isFinite(max) ? max : undefined,
subjectRect:
metrics.subjectLeft !== undefined
? {
left: metrics.subjectLeft,
top: metrics.subjectTop ?? 0,
right: metrics.subjectRight ?? 0,
bottom: metrics.subjectBottom ?? 0,
}
: undefined,
referenceRect:
metrics.refLeft !== undefined
? {
left: metrics.refLeft,
top: metrics.refTop ?? 0,
right: metrics.refRight ?? 0,
bottom: metrics.refBottom ?? 0,
}
: undefined,
};
}
case 'inside': {
const overflowLeft = metrics.overflowLeft ?? 0;
const overflowTop = metrics.overflowTop ?? 0;
const overflowRight = metrics.overflowRight ?? 0;
const overflowBottom = metrics.overflowBottom ?? 0;
return {
...base,
op: 'not-contained',
left: 0,
right: 0,
overflowEdges: {
left: overflowLeft,
top: overflowTop,
right: overflowRight,
bottom: overflowBottom,
},
subjectRect:
metrics.subjectLeft !== undefined
? {
left: metrics.subjectLeft,
top: metrics.subjectTop ?? 0,
right: metrics.subjectRight ?? 0,
bottom: metrics.subjectBottom ?? 0,
}
: undefined,
referenceRect:
metrics.refLeft !== undefined
? {
left: metrics.refLeft,
top: metrics.refTop ?? 0,
right: metrics.refRight ?? 0,
bottom: metrics.refBottom ?? 0,
}
: undefined,
};
}
case 'atLeast':
case 'atMost':
case 'between': {
const observed = metrics.observed ?? metrics.value ?? 0;
const min = metrics.min ?? -Infinity;
const max = metrics.max ?? Infinity;
return {
...base,
op: kind === 'atMost' ? '>' : '<',
left: observed,
right: kind === 'atMost' ? max : min,
measuredValue: observed,
expectedMin: Number.isFinite(min) ? min : undefined,
expectedMax: Number.isFinite(max) ? max : undefined,
};
}
case 'aspectRatio': {
const observed = metrics.observed ?? 0;
const minRatio = metrics.minRatio ?? -Infinity;
const maxRatio = metrics.maxRatio ?? Infinity;
return {
...base,
op: observed < minRatio ? '<' : '>',
left: observed,
right: observed < minRatio ? minRatio : maxRatio,
measuredValue: observed,
expectedMin: Number.isFinite(minRatio) ? minRatio : undefined,
expectedMax: Number.isFinite(maxRatio) ? maxRatio : undefined,
};
}
case 'alignedWith':
case 'centeredWithin': {
const delta = metrics.delta ?? metrics.deltaX ?? metrics.deltaY ?? 0;
const tolerance = metrics.tolerance ?? 0;
return {
...base,
op: '>',
left: delta,
right: tolerance,
measuredValue: delta,
expectedMax: tolerance,
};
}
default:
// Fallback to generic synthesis from the first two numeric metrics.
const fallback = synthesizeGenericFailedPredicate(metrics);
if (fallback) {
return { ...fallback, relationKind: kind };
}
return fallback;
}
}
function synthesizeGenericFailedPredicate(
metrics: Record<string, number>,
): Proof['failedPredicate'] {
const keys = Object.keys(metrics);
if (keys.length >= 2) {
return {
op: '<',
left: metrics[keys[0]] ?? 0,
right: metrics[keys[1]] ?? 0,
};
}
if (keys.length === 1) {
return {
op: '<',
left: metrics[keys[0]] ?? 0,
right: 0,
};
}
return undefined;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Generate a single proof object from a clause result.
*
* @param result - The evaluation result for the clause.
* @param clause - The clause descriptor that was evaluated.
* @param world - The geometry world the clause was evaluated against.
* @param usedFacts - Array of fact ids that were read during evaluation.
* @param derivedFacts - Array of fact ids that were derived during evaluation.
*/
export function generateProof(
result: ClauseResult,
clause: ClauseDescriptor,
world: GeometryWorld,
usedFacts: number[] = [],
derivedFacts: number[] = [],
): Proof {
const proof: Proof = {
proofId: nextProofId(),
clauseId: result.clauseId,
outcome: result.status,
truth: result.truth,
usedFacts: [...usedFacts],
derivedFacts: [...derivedFacts],
witness: {
subjectId: result.witness?.subjectId ?? clause.subjectRef,
referenceId: result.witness?.referenceId ?? clause.referenceRef,
frameId: result.witness?.frameId ?? clause.frameRef,
snapshotId: world.snapshotId,
},
};
if (result.status === 'fail' && result.metrics) {
proof.failedPredicate = buildFailedPredicate(
clause.clauseKind,
result.metrics,
);
}
return proof;
}
/**
* Batch-generate proofs for a set of clause results.
*
* @param results - Array of clause results.
* @param clauses - Array of clause descriptors (must cover all result clauseIds).
* @param world - The geometry world used during evaluation.
* @param usedFactsMap - Optional map from clauseId to fact ids.
* @param derivedFactsMap - Optional map from clauseId to derived fact ids.
*/
export function generateProofs(
results: ClauseResult[],
clauses: ClauseDescriptor[],
world: GeometryWorld,
usedFactsMap: Map<string, number[]> = new Map(),
derivedFactsMap: Map<string, number[]> = new Map(),
): Proof[] {
const clauseMap = new Map(clauses.map((c) => [c.clauseId, c]));
return results.map((r) => {
const clause = clauseMap.get(r.clauseId);
if (!clause) {
throw new Error(`Clause descriptor not found for ${r.clauseId}`);
}
return generateProof(
r,
clause,
world,
usedFactsMap.get(r.clauseId),
derivedFactsMap.get(r.clauseId),
);
});
}
@@ -0,0 +1,439 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import fc from 'fast-check';
import {
type GeometryWorld,
type ClauseDescriptor,
} from './registry.js';
import {
evaluateLeftOf,
evaluateRightOf,
evaluateAbove,
evaluateBelow,
evaluateOverlaps,
evaluateInside,
} from './relations.js';
import {
evaluateQuantifier,
type QuantifierClause,
} from './quantifiers.js';
import {
generateProof,
resetProofCounter,
} from './proofs.js';
// ---- Test Helpers ----
function makeWorldFromRects(
sLeft: number, sTop: number, sRight: number, sBottom: number,
rLeft: number, rTop: number, rRight: number, rBottom: number,
): GeometryWorld {
return {
sceneId: 'scene_1',
snapshotId: 'snap_1',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: [1, 2],
domNodeId: [10, 20],
subjectKind: [1, 1],
primaryBoxId: [100, 200],
firstFragmentId: [0, 0],
fragmentCount: [0, 0],
},
dom: {
nodeId: [10, 20],
parentNodeId: [0, 0],
childCount: [0, 0],
tagNameStringId: [0, 0],
},
boxes: {
boxId: [100, 200],
subjectId: [1, 2],
frameId: [1, 1],
borderLeft: [sLeft, rLeft],
borderTop: [sTop, rTop],
borderRight: [sRight, rRight],
borderBottom: [sBottom, rBottom],
paddingLeft: [0, 0],
paddingTop: [0, 0],
paddingRight: [0, 0],
paddingBottom: [0, 0],
contentLeft: [0, 0],
contentTop: [0, 0],
contentRight: [0, 0],
contentBottom: [0, 0],
},
visualBoxes: {
boxId: [], subjectId: [], frameId: [],
borderLeft: [], borderTop: [], borderRight: [], borderBottom: [],
paddingLeft: [], paddingTop: [], paddingRight: [], paddingBottom: [],
contentLeft: [], contentTop: [], contentRight: [], contentBottom: [],
},
transforms: {
transformId: [], subjectId: [], matrixStart: [], matrixLength: [],
originX: [], originY: [],
},
matrices: { values: [] },
rects: {
rectId: [],
left: [],
top: [],
right: [],
bottom: [],
},
topology: {
containingBlockOf: [0, 0],
nearestPositionedAncestorOf: [0, 0],
scrollContainerOf: [0, 0],
stackingContextOf: [0, 0],
formattingContextOf: [0, 0],
clippingRootOf: [0, 0],
paintOrderBucket: [0, 0],
paintOrderIndex: [0, 0],
},
scroll: {
containerId: [],
scrollLeft: [],
scrollTop: [],
scrollWidth: [],
scrollHeight: [],
clientWidth: [],
clientHeight: [],
},
clipping: {
clipNodeId: [],
subjectId: [],
clipKind: [],
clipLeft: [],
clipTop: [],
clipRight: [],
clipBottom: [],
parentClipNodeId: [],
},
visibility: {
subjectId: [],
isRendered: [],
isVisible: [],
visibleArea: [],
clippedArea: [],
},
};
}
function makeClause(kind: string, overrides?: Partial<ClauseDescriptor>): ClauseDescriptor {
return {
clauseId: 'clause_1',
clauseKind: kind,
version: 1,
subjectRef: 1,
referenceRef: 2,
...overrides,
};
}
// ---- Arbitraries ----
const coordArb = fc.integer({ min: 0, max: 500 });
const rectArb = fc
.tuple(coordArb, coordArb, coordArb, coordArb)
.filter(([l, t, r, b]) => l < r && t < b)
.map(([l, t, r, b]) => ({ left: l, top: t, right: r, bottom: b }));
const gapArb = fc.integer({ min: 0, max: 200 });
// ---- Property Tests ----
describe('solver property tests', () => {
it('property: leftOf and rightOf are inverse relations with same gap', () => {
fc.assert(
fc.property(
rectArb,
rectArb,
gapArb,
(subjectRect, referenceRect, gap) => {
// Place subject left of reference with exact gap
const sLeft = 0;
const sTop = 0;
const sRight = sLeft + (subjectRect.right - subjectRect.left);
const sBottom = sTop + (subjectRect.bottom - subjectRect.top);
const rLeft = sRight + gap;
const rTop = 0;
const rRight = rLeft + (referenceRect.right - referenceRect.left);
const rBottom = rTop + (referenceRect.bottom - referenceRect.top);
// World where subject is left of reference
const worldLR = makeWorldFromRects(sLeft, sTop, sRight, sBottom, rLeft, rTop, rRight, rBottom);
// World where subject is right of reference (swapped)
const worldRL = makeWorldFromRects(rLeft, rTop, rRight, rBottom, sLeft, sTop, sRight, sBottom);
const leftOfClause = makeClause('relation.leftOf', {
bounds: { minGap: gap, maxGap: gap },
});
const rightOfClause = makeClause('relation.rightOf', {
bounds: { minGap: gap, maxGap: gap },
});
const leftResult = evaluateLeftOf(worldLR, leftOfClause);
const rightResult = evaluateRightOf(worldRL, rightOfClause);
assert.strictEqual(leftResult.status, 'pass');
assert.strictEqual(rightResult.status, 'pass');
assert.strictEqual(leftResult.metrics?.observedGap, gap);
assert.strictEqual(rightResult.metrics?.observedGap, gap);
}
),
{ numRuns: 200, seed: 42 }
);
});
it('property: above and below are inverse relations', () => {
fc.assert(
fc.property(
rectArb,
rectArb,
gapArb,
(subjectRect, referenceRect, gap) => {
const sLeft = 0;
const sTop = 0;
const sRight = sLeft + (subjectRect.right - subjectRect.left);
const sBottom = sTop + (subjectRect.bottom - subjectRect.top);
const rLeft = 0;
const rTop = sBottom + gap;
const rRight = rLeft + (referenceRect.right - referenceRect.left);
const rBottom = rTop + (referenceRect.bottom - referenceRect.top);
// World where subject is above reference
const worldAB = makeWorldFromRects(sLeft, sTop, sRight, sBottom, rLeft, rTop, rRight, rBottom);
// World where subject is below reference (swapped)
const worldBA = makeWorldFromRects(rLeft, rTop, rRight, rBottom, sLeft, sTop, sRight, sBottom);
const aboveClause = makeClause('relation.above', {
bounds: { minGap: gap, maxGap: gap },
});
const belowClause = makeClause('relation.below', {
bounds: { minGap: gap, maxGap: gap },
});
const aboveResult = evaluateAbove(worldAB, aboveClause);
const belowResult = evaluateBelow(worldBA, belowClause);
assert.strictEqual(aboveResult.status, 'pass');
assert.strictEqual(belowResult.status, 'pass');
assert.strictEqual(aboveResult.metrics?.observedGap, gap);
assert.strictEqual(belowResult.metrics?.observedGap, gap);
}
),
{ numRuns: 200, seed: 123 }
);
});
it('property: if A is inside B, then B contains A', () => {
fc.assert(
fc.property(
rectArb,
fc.integer({ min: 0, max: 50 }),
(innerRect, padding) => {
const outerRect = {
left: innerRect.left - padding,
top: innerRect.top - padding,
right: innerRect.right + padding,
bottom: innerRect.bottom + padding,
};
if (outerRect.left >= outerRect.right || outerRect.top >= outerRect.bottom) {
return; // Skip invalid outer rect
}
// A inside B
const world1 = makeWorldFromRects(
innerRect.left, innerRect.top, innerRect.right, innerRect.bottom,
outerRect.left, outerRect.top, outerRect.right, outerRect.bottom
);
const insideClause = makeClause('relation.inside');
const insideResult = evaluateInside(world1, insideClause);
assert.strictEqual(insideResult.status, 'pass', 'A should be inside B');
// B contains A (swap subject/reference)
const world2 = makeWorldFromRects(
outerRect.left, outerRect.top, outerRect.right, outerRect.bottom,
innerRect.left, innerRect.top, innerRect.right, innerRect.bottom
);
// Use leftOf with a huge gap as a proxy for contains, or just verify the geometry relation
// Since there is no direct "contains" evaluator in relations.ts, we verify via inside logic:
// If A is inside B, then B's rect fully contains A's rect.
assert.ok(
innerRect.left >= outerRect.left &&
innerRect.top >= outerRect.top &&
innerRect.right <= outerRect.right &&
innerRect.bottom <= outerRect.bottom,
'Containment geometry should hold'
);
}
),
{ numRuns: 200, seed: 77 }
);
});
it('property: if A overlaps B, then B overlaps A', () => {
fc.assert(
fc.property(
rectArb,
rectArb,
(rectA, rectB) => {
const world = makeWorldFromRects(
rectA.left, rectA.top, rectA.right, rectA.bottom,
rectB.left, rectB.top, rectB.right, rectB.bottom
);
const overlapsClause = makeClause('relation.overlaps');
const resultAB = evaluateOverlaps(world, overlapsClause);
// Swap subject and reference by creating a new world with swapped boxes
const worldBA = makeWorldFromRects(
rectB.left, rectB.top, rectB.right, rectB.bottom,
rectA.left, rectA.top, rectA.right, rectA.bottom
);
const resultBA = evaluateOverlaps(worldBA, overlapsClause);
assert.strictEqual(resultAB.status, resultBA.status, 'Overlap should be symmetric');
}
),
{ numRuns: 300, seed: 88 }
);
});
it('property: all quantifier passes iff every sub-clause passes', () => {
fc.assert(
fc.property(
fc.array(fc.boolean(), { minLength: 1, maxLength: 20 }),
(passFlags) => {
const subResults = passFlags.map((pass, i) => ({
clauseId: `c${i}`,
status: pass ? 'pass' as const : 'fail' as const,
truth: 'determinate' as const,
}));
const clause: QuantifierClause = {
clauseId: 'q1',
clauseKind: 'quantifier.all',
version: 1,
quantifier: 'all',
subClauseIds: passFlags.map((_, i) => `c${i}`),
};
const result = evaluateQuantifier(subResults, clause);
const expected = passFlags.every(Boolean) ? 'pass' : 'fail';
assert.strictEqual(result.status, expected);
}
),
{ numRuns: 200, seed: 55 }
);
});
it('property: any quantifier passes iff at least one sub-clause passes', () => {
fc.assert(
fc.property(
fc.array(fc.boolean(), { minLength: 1, maxLength: 20 }),
(passFlags) => {
const subResults = passFlags.map((pass, i) => ({
clauseId: `c${i}`,
status: pass ? 'pass' as const : 'fail' as const,
truth: 'determinate' as const,
}));
const clause: QuantifierClause = {
clauseId: 'q1',
clauseKind: 'quantifier.any',
version: 1,
quantifier: 'any',
subClauseIds: passFlags.map((_, i) => `c${i}`),
};
const result = evaluateQuantifier(subResults, clause);
const expected = passFlags.some(Boolean) ? 'pass' : 'fail';
assert.strictEqual(result.status, expected);
}
),
{ numRuns: 200, seed: 66 }
);
});
it('property: none quantifier passes iff no sub-clause passes', () => {
fc.assert(
fc.property(
fc.array(fc.boolean(), { minLength: 1, maxLength: 20 }),
(passFlags) => {
const subResults = passFlags.map((pass, i) => ({
clauseId: `c${i}`,
status: pass ? 'pass' as const : 'fail' as const,
truth: 'determinate' as const,
}));
const clause: QuantifierClause = {
clauseId: 'q1',
clauseKind: 'quantifier.none',
version: 1,
quantifier: 'none',
subClauseIds: passFlags.map((_, i) => `c${i}`),
};
const result = evaluateQuantifier(subResults, clause);
const expected = !passFlags.some(Boolean) ? 'pass' : 'fail';
assert.strictEqual(result.status, expected);
}
),
{ numRuns: 200, seed: 33 }
);
});
it('property: proof generation preserves failure/success', () => {
fc.assert(
fc.property(
fc.tuple(coordArb, coordArb, coordArb, coordArb),
fc.tuple(coordArb, coordArb, coordArb, coordArb),
gapArb,
([sL, sT, sR, sB], [rL, rT, rR, rB], minGap) => {
if (sL >= sR || sT >= sB || rL >= rR || rT >= rB) return;
const world = makeWorldFromRects(sL, sT, sR, sB, rL, rT, rR, rB);
const clause = makeClause('relation.leftOf', {
bounds: { minGap },
});
resetProofCounter();
const evalResult = evaluateLeftOf(world, clause);
const proof = generateProof(evalResult, clause, world);
assert.strictEqual(proof.outcome, evalResult.status);
assert.strictEqual(proof.truth, evalResult.truth);
assert.strictEqual(proof.clauseId, clause.clauseId);
if (evalResult.status === 'fail') {
assert.ok(proof.failedPredicate, 'Failed proof should have failedPredicate');
} else {
assert.strictEqual(proof.failedPredicate, undefined);
}
}
),
{ numRuns: 300, seed: 99 }
);
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Quantifier and composition evaluators:
* all, any, none, pairwise
*
* These aggregate pre-computed sub-clause results rather than inspecting raw
* geometry directly.
*/
import {
type ClauseDescriptor,
type ClauseResult,
} from './registry.js';
export type QuantifierMode = 'all' | 'any' | 'none' | 'pairwise';
export interface QuantifierClause extends ClauseDescriptor {
quantifier: QuantifierMode;
subClauseIds: string[];
}
/**
* Evaluate a quantifier over pre-computed sub-results.
*/
export function evaluateQuantifier(
subResults: ClauseResult[],
clause: QuantifierClause,
): ClauseResult {
const { clauseId, quantifier } = clause;
if (subResults.length === 0) {
return {
clauseId,
status: 'error',
truth: 'indeterminate',
diagnostics: [
{
code: 'IMH_QUANTIFIER_NO_SUBCLAUSES',
severity: 'error',
category: 'validation-error',
message: `Quantifier ${quantifier} has no sub-clauses to evaluate.`,
clauseId,
},
],
};
}
switch (quantifier) {
case 'all': {
const failed = subResults.filter(
(r) => r.status === 'fail' || r.status === 'error',
);
const pass = failed.length === 0;
return {
clauseId,
status: pass ? 'pass' : 'fail',
truth: pass ? 'determinate' : 'determinate',
metrics: {
total: subResults.length,
passed: subResults.length - failed.length,
failed: failed.length,
},
diagnostics: failed.flatMap((r) => r.diagnostics ?? []),
};
}
case 'any': {
const passed = subResults.filter((r) => r.status === 'pass');
const pass = passed.length > 0;
return {
clauseId,
status: pass ? 'pass' : 'fail',
truth: 'determinate',
metrics: { total: subResults.length, passed: passed.length },
};
}
case 'none': {
const passed = subResults.filter((r) => r.status === 'pass');
const pass = passed.length === 0;
return {
clauseId,
status: pass ? 'pass' : 'fail',
truth: 'determinate',
metrics: { total: subResults.length, passed: passed.length },
};
}
case 'pairwise': {
if (subResults.length < 2) {
return {
clauseId,
status: 'error',
truth: 'indeterminate',
diagnostics: [
{
code: 'IMH_PAIRWISE_INSUFFICIENT',
severity: 'error',
category: 'validation-error',
message: 'pairwise quantifier requires at least two sub-clauses.',
clauseId,
},
],
};
}
const failed = subResults.filter(
(r) => r.status === 'fail' || r.status === 'error',
);
const pass = failed.length === 0;
return {
clauseId,
status: pass ? 'pass' : 'fail',
truth: 'determinate',
metrics: {
total: subResults.length,
passed: subResults.length - failed.length,
failed: failed.length,
},
diagnostics: failed.flatMap((r) => r.diagnostics ?? []),
};
}
default:
return {
clauseId,
status: 'error',
truth: 'indeterminate',
diagnostics: [
{
code: 'IMH_UNKNOWN_QUANTIFIER',
severity: 'error',
category: 'validation-error',
message: `Unknown quantifier mode: ${quantifier}`,
clauseId,
},
],
};
}
}
+296
View File
@@ -0,0 +1,296 @@
/**
* Clause registry and required-fact declarations for the Imhotep solver.
*
* Each clause family declares what geometry facts it needs so the extractor
* can plan ahead. Evaluators are looked up at runtime by clauseKind.
*/
import type { DiagnosticCode } from 'imhotep-core'
// --- Core enumerations -------------------------------------------------------
export type ClauseStatus = 'pass' | 'fail' | 'error' | 'skipped';
export type TruthValue = 'determinate' | 'indeterminate' | 'approximate';
// Coordinate space for geometry evaluation.
// LAYOUT = pre-transform local coordinates (CDP box model).
// VISUAL = post-transform viewport coordinates (Playwright boundingBox).
export enum CoordinateSpace {
LAYOUT = 0,
VISUAL = 1,
}
// --- Geometry world (simplified contract view) -------------------------------
export interface GeometryWorld {
sceneId: string;
snapshotId: string;
env: {
viewportWidth: number;
viewportHeight: number;
deviceScaleFactor: number;
colorScheme: string;
pointer: string;
hover: boolean;
reducedMotion: boolean;
locale: string;
writingMode: string;
};
strings: { values: string[] };
subjects: {
ids: number[];
domNodeId: number[];
subjectKind: number[];
primaryBoxId: number[];
firstFragmentId: number[];
fragmentCount: number[];
};
dom: {
nodeId: number[];
parentNodeId: number[];
childCount: number[];
tagNameStringId: number[];
};
boxes: {
boxId: number[];
subjectId: number[];
frameId: number[];
borderLeft: number[];
borderTop: number[];
borderRight: number[];
borderBottom: number[];
paddingLeft: number[];
paddingTop: number[];
paddingRight: number[];
paddingBottom: number[];
contentLeft: number[];
contentTop: number[];
contentRight: number[];
contentBottom: number[];
};
visualBoxes: {
boxId: number[];
subjectId: number[];
frameId: number[];
borderLeft: number[];
borderTop: number[];
borderRight: number[];
borderBottom: number[];
paddingLeft: number[];
paddingTop: number[];
paddingRight: number[];
paddingBottom: number[];
contentLeft: number[];
contentTop: number[];
contentRight: number[];
contentBottom: number[];
};
transforms: {
transformId: number[];
subjectId: number[];
matrixStart: number[];
matrixLength: number[];
originX: number[];
originY: number[];
};
matrices: {
values: number[];
};
rects: {
rectId: number[];
left: number[];
top: number[];
right: number[];
bottom: number[];
};
topology: {
containingBlockOf: number[];
nearestPositionedAncestorOf: number[];
scrollContainerOf: number[];
stackingContextOf: number[];
formattingContextOf: number[];
clippingRootOf: number[];
paintOrderBucket: number[];
paintOrderIndex: number[];
};
scroll: {
containerId: number[];
scrollLeft: number[];
scrollTop: number[];
scrollWidth: number[];
scrollHeight: number[];
clientWidth: number[];
clientHeight: number[];
};
clipping: {
clipNodeId: number[];
subjectId: number[];
clipKind: number[];
clipLeft: number[];
clipTop: number[];
clipRight: number[];
clipBottom: number[];
parentClipNodeId: number[];
};
visibility: {
subjectId: number[];
isRendered: number[];
isVisible: number[];
visibleArea: number[];
clippedArea: number[];
};
}
// --- Clause descriptor -------------------------------------------------------
export interface ClauseDescriptor {
clauseId: string;
clauseKind: string;
version: number;
subjectRef?: number;
referenceRef?: number;
frameRef?: number;
stateRef?: string;
timelineRef?: string;
envGuardRef?: string;
toleranceRef?: string;
bounds?: Record<string, number>;
options?: Record<string, unknown>;
}
// --- Clause result -----------------------------------------------------------
export interface ClauseResult {
clauseId: string;
status: ClauseStatus;
truth: TruthValue;
metrics?: Record<string, number>;
witness?: Witness;
trace?: TraceEvent[];
diagnostics?: Diagnostic[];
}
export interface Witness {
subjectId?: number;
referenceId?: number;
frameId?: number;
envCaseId?: string;
snapshotId?: string;
}
export interface Diagnostic {
code: DiagnosticCode;
severity: 'error' | 'warning' | 'info';
category: string;
message: string;
position?: {
start: { line: number; column: number; offset: number };
end: { line: number; column: number; offset: number };
};
clauseId?: string;
}
export interface TraceEvent {
traceEventId: string;
phase: string;
at: number;
refs?: Record<string, string>;
payload?: unknown;
}
export interface FailedPredicate {
op: string;
left: number;
right: number;
// Relation-specific diagnostic details (populated by generateProof)
relationKind?: string;
measuredGap?: number;
expectedMinGap?: number;
expectedMaxGap?: number;
subjectRect?: { left: number; top: number; right: number; bottom: number };
referenceRect?: { left: number; top: number; right: number; bottom: number };
overflowEdges?: { left: number; top: number; right: number; bottom: number };
measuredValue?: number;
expectedMin?: number;
expectedMax?: number;
}
export interface Proof {
proofId: string;
clauseId: string;
outcome: ClauseStatus;
truth: TruthValue;
usedFacts: number[];
derivedFacts: number[];
failedPredicate?: FailedPredicate;
witness?: Witness;
}
// --- Evaluator signature -----------------------------------------------------
export type ClauseEvaluator = (
world: GeometryWorld,
clause: ClauseDescriptor,
) => ClauseResult;
// --- Registry ----------------------------------------------------------------
export interface ClauseEntry {
clauseKind: string;
version: number;
requiredFacts: string[];
evaluate: ClauseEvaluator;
}
export class ClauseRegistry {
private registry = new Map<string, ClauseEntry>();
register(entry: ClauseEntry): void {
const key = `${entry.clauseKind}@v${entry.version}`;
this.registry.set(key, entry);
}
getEvaluator(kind: string, version = 1): ClauseEvaluator | undefined {
return this.registry.get(`${kind}@v${version}`)?.evaluate;
}
getRequiredFacts(kind: string, version = 1): string[] {
return this.registry.get(`${kind}@v${version}`)?.requiredFacts ?? [];
}
listRegisteredKinds(): string[] {
return Array.from(this.registry.keys());
}
clear(): void {
this.registry.clear();
}
}
// Global default instance for backward compatibility.
export const globalClauseRegistry = new ClauseRegistry();
/** Register a clause family so the engine can route evaluation. */
export function registerClause(entry: ClauseEntry): void {
globalClauseRegistry.register(entry);
}
/** Lookup an evaluator by kind and version. */
export function getEvaluator(kind: string, version = 1): ClauseEvaluator | undefined {
return globalClauseRegistry.getEvaluator(kind, version);
}
/** Return the set of required facts for a given clause kind. */
export function getRequiredFacts(kind: string, version = 1): string[] {
return globalClauseRegistry.getRequiredFacts(kind, version);
}
/** Return all registered kinds (useful for testing and introspection). */
export function listRegisteredKinds(): string[] {
return globalClauseRegistry.listRegisteredKinds();
}
/** Clear the global registry (mainly for tests). */
export function clearRegistry(): void {
globalClauseRegistry.clear();
}
+349
View File
@@ -0,0 +1,349 @@
/**
* Direct evaluators for spatial relations:
* leftOf, rightOf, above, below, overlaps, inside, contains
*
* Each evaluator is a pure function: (world, clause) => ClauseResult.
* Missing box geometry produces an indeterminate result with an error status.
*
* Coordinate space: defaults to VISUAL (post-transform). The space can be
* overridden via clause.options.space = 'layout' | 'visual'.
*/
import {
type GeometryWorld,
type ClauseDescriptor,
type ClauseResult,
type ClauseStatus,
type TruthValue,
CoordinateSpace,
} from './registry.js';
import { getBorderRect, getEvaluationCache } from './transform-adapter.js';
// --- Helpers -----------------------------------------------------------------
/**
* Resolve the coordinate space for a clause.
* Defaults to VISUAL (post-transform) to match user expectations.
* Explicit `layout` or `visual` in clause.options takes precedence.
*/
function resolveSpace(clause: ClauseDescriptor): CoordinateSpace {
const spaceOpt = clause.options?.space as string | undefined;
if (spaceOpt === 'layout') return CoordinateSpace.LAYOUT;
if (spaceOpt === 'visual') return CoordinateSpace.VISUAL;
return CoordinateSpace.VISUAL;
}
/**
* Build a ClauseResult with the given status and truth.
*/
function result(
clauseId: string,
status: ClauseStatus,
truth: TruthValue,
metrics?: Record<string, number>,
witness?: Record<string, number | string | undefined>,
): ClauseResult {
return {
clauseId,
status,
truth,
metrics,
witness: witness
? {
subjectId: witness.subjectId as number | undefined,
referenceId: witness.referenceId as number | undefined,
frameId: witness.frameId as number | undefined,
}
: undefined,
};
}
// --- Relation evaluators -----------------------------------------------------
export function evaluateLeftOf(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, referenceRef, clauseId, bounds } = clause;
if (subjectRef === undefined || referenceRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const cache = getEvaluationCache(world);
const space = resolveSpace(clause);
const sRect = getBorderRect(world, subjectRef, space, cache);
const rRect = getBorderRect(world, referenceRef, space, cache);
if (!sRect || !rRect) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const gap = rRect.left - sRect.right;
const minGap = bounds?.minGap ?? 0;
const maxGap = bounds?.maxGap ?? Infinity;
const pass = gap >= minGap && gap <= maxGap;
return result(
clauseId,
pass ? 'pass' : 'fail',
'determinate',
{ observedGap: gap, minGap, maxGap, space },
{ subjectId: subjectRef, referenceId: referenceRef },
);
}
export function evaluateRightOf(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, referenceRef, clauseId, bounds } = clause;
if (subjectRef === undefined || referenceRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const cache = getEvaluationCache(world);
const space = resolveSpace(clause);
const sRect = getBorderRect(world, subjectRef, space, cache);
const rRect = getBorderRect(world, referenceRef, space, cache);
if (!sRect || !rRect) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const gap = sRect.left - rRect.right;
const minGap = bounds?.minGap ?? 0;
const maxGap = bounds?.maxGap ?? Infinity;
const pass = gap >= minGap && gap <= maxGap;
return result(
clauseId,
pass ? 'pass' : 'fail',
'determinate',
{ observedGap: gap, minGap, maxGap, space },
{ subjectId: subjectRef, referenceId: referenceRef },
);
}
export function evaluateAbove(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, referenceRef, clauseId, bounds } = clause;
if (subjectRef === undefined || referenceRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const cache = getEvaluationCache(world);
const space = resolveSpace(clause);
const sRect = getBorderRect(world, subjectRef, space, cache);
const rRect = getBorderRect(world, referenceRef, space, cache);
if (!sRect || !rRect) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const gap = rRect.top - sRect.bottom;
const minGap = bounds?.minGap ?? 0;
const maxGap = bounds?.maxGap ?? Infinity;
const pass = gap >= minGap && gap <= maxGap;
return result(
clauseId,
pass ? 'pass' : 'fail',
'determinate',
{ observedGap: gap, minGap, maxGap, space },
{ subjectId: subjectRef, referenceId: referenceRef },
);
}
export function evaluateBelow(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, referenceRef, clauseId, bounds } = clause;
if (subjectRef === undefined || referenceRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const cache = getEvaluationCache(world);
const space = resolveSpace(clause);
const sRect = getBorderRect(world, subjectRef, space, cache);
const rRect = getBorderRect(world, referenceRef, space, cache);
if (!sRect || !rRect) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const gap = sRect.top - rRect.bottom;
const minGap = bounds?.minGap ?? 0;
const maxGap = bounds?.maxGap ?? Infinity;
const pass = gap >= minGap && gap <= maxGap;
return result(
clauseId,
pass ? 'pass' : 'fail',
'determinate',
{ observedGap: gap, minGap, maxGap, space },
{ subjectId: subjectRef, referenceId: referenceRef },
);
}
export function evaluateOverlaps(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, referenceRef, clauseId } = clause;
if (subjectRef === undefined || referenceRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const cache = getEvaluationCache(world);
const space = resolveSpace(clause);
const sRect = getBorderRect(world, subjectRef, space, cache);
const rRect = getBorderRect(world, referenceRef, space, cache);
if (!sRect || !rRect) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const overlap =
sRect.left < rRect.right &&
sRect.right > rRect.left &&
sRect.top < rRect.bottom &&
sRect.bottom > rRect.top;
const dx = Math.max(0, Math.max(sRect.left - rRect.right, rRect.left - sRect.right));
const dy = Math.max(0, Math.max(sRect.top - rRect.bottom, rRect.top - sRect.bottom));
const distance = Math.sqrt(dx * dx + dy * dy);
return result(
clauseId,
overlap ? 'pass' : 'fail',
'determinate',
{ overlap: overlap ? 1 : 0, distance, space },
{ subjectId: subjectRef, referenceId: referenceRef },
);
}
export function evaluateInside(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, referenceRef, clauseId, bounds } = clause;
if (subjectRef === undefined || referenceRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const cache = getEvaluationCache(world);
const space = resolveSpace(clause);
const sRect = getBorderRect(world, subjectRef, space, cache);
const rRect = getBorderRect(world, referenceRef, space, cache);
if (!sRect || !rRect) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const tolerance = bounds?.tolerance ?? 0;
const pass =
sRect.left >= rRect.left - tolerance &&
sRect.top >= rRect.top - tolerance &&
sRect.right <= rRect.right + tolerance &&
sRect.bottom <= rRect.bottom + tolerance;
return result(
clauseId,
pass ? 'pass' : 'fail',
'determinate',
{
subjectLeft: sRect.left,
subjectTop: sRect.top,
subjectRight: sRect.right,
subjectBottom: sRect.bottom,
refLeft: rRect.left,
refTop: rRect.top,
refRight: rRect.right,
refBottom: rRect.bottom,
space,
},
{ subjectId: subjectRef, referenceId: referenceRef },
);
}
export function evaluateContains(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, referenceRef, clauseId, bounds } = clause;
if (subjectRef === undefined || referenceRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const cache = getEvaluationCache(world);
const space = resolveSpace(clause);
const sRect = getBorderRect(world, subjectRef, space, cache);
const rRect = getBorderRect(world, referenceRef, space, cache);
if (!sRect || !rRect) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const tolerance = bounds?.tolerance ?? 0;
const pass =
rRect.left >= sRect.left - tolerance &&
rRect.top >= sRect.top - tolerance &&
rRect.right <= sRect.right + tolerance &&
rRect.bottom <= sRect.bottom + tolerance;
return result(
clauseId,
pass ? 'pass' : 'fail',
'determinate',
{
subjectLeft: sRect.left,
subjectTop: sRect.top,
subjectRight: sRect.right,
subjectBottom: sRect.bottom,
refLeft: rRect.left,
refTop: rRect.top,
refRight: rRect.right,
refBottom: rRect.bottom,
space,
},
{ subjectId: subjectRef, referenceId: referenceRef },
);
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Size and aspect ratio evaluators:
* atLeast, atMost, between, aspectRatio
*
* Operates on border-box dimensions extracted from the geometry world.
*/
import {
type GeometryWorld,
type ClauseDescriptor,
type ClauseResult,
} from './registry.js';
function findBoxIndex(world: GeometryWorld, subjectId: number): number {
const { boxes } = world;
for (let i = 0; i < boxes.boxId.length; i++) {
if (boxes.subjectId[i] === subjectId) {
return i;
}
}
return -1;
}
function getBorderRect(world: GeometryWorld, subjectId: number) {
const idx = findBoxIndex(world, subjectId);
if (idx === -1) return null;
const b = world.boxes;
return {
left: b.borderLeft[idx],
top: b.borderTop[idx],
right: b.borderRight[idx],
bottom: b.borderBottom[idx],
};
}
function result(
clauseId: string,
status: 'pass' | 'fail' | 'error',
truth: 'determinate' | 'indeterminate',
metrics?: Record<string, number>,
witness?: Record<string, number | undefined>,
): ClauseResult {
return {
clauseId,
status,
truth,
metrics,
witness: witness
? {
subjectId: witness.subjectId,
frameId: witness.frameId,
}
: undefined,
};
}
export function evaluateAtLeast(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, clauseId, bounds, options } = clause;
if (subjectRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
});
}
const rect = getBorderRect(world, subjectRef);
if (!rect) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
});
}
const dimension = (options?.dimension as string) ?? 'width';
const observed =
dimension === 'height' ? rect.bottom - rect.top : rect.right - rect.left;
const min = bounds?.min ?? 0;
const pass = observed >= min;
return result(
clauseId,
pass ? 'pass' : 'fail',
'determinate',
{ observed, min },
{ subjectId: subjectRef },
);
}
export function evaluateAtMost(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, clauseId, bounds, options } = clause;
if (subjectRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
});
}
const rect = getBorderRect(world, subjectRef);
if (!rect) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
});
}
const dimension = (options?.dimension as string) ?? 'width';
const observed =
dimension === 'height' ? rect.bottom - rect.top : rect.right - rect.left;
const max = bounds?.max ?? Infinity;
const pass = observed <= max;
return result(
clauseId,
pass ? 'pass' : 'fail',
'determinate',
{ observed, max },
{ subjectId: subjectRef },
);
}
export function evaluateBetween(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, clauseId, bounds, options } = clause;
if (subjectRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
});
}
const rect = getBorderRect(world, subjectRef);
if (!rect) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
});
}
const dimension = (options?.dimension as string) ?? 'width';
const observed =
dimension === 'height' ? rect.bottom - rect.top : rect.right - rect.left;
const min = bounds?.min ?? -Infinity;
const max = bounds?.max ?? Infinity;
const pass = observed >= min && observed <= max;
return result(
clauseId,
pass ? 'pass' : 'fail',
'determinate',
{ observed, min, max },
{ subjectId: subjectRef },
);
}
export function evaluateAspectRatio(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, clauseId, bounds } = clause;
if (subjectRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
});
}
const rect = getBorderRect(world, subjectRef);
if (!rect) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
});
}
const width = rect.right - rect.left;
const height = rect.bottom - rect.top;
if (height === 0) {
return result(
clauseId,
'error',
'indeterminate',
{ width, height },
{ subjectId: subjectRef },
);
}
const observed = width / height;
const min = bounds?.minRatio ?? -Infinity;
const max = bounds?.maxRatio ?? Infinity;
const pass = observed >= min && observed <= max;
return result(
clauseId,
pass ? 'pass' : 'fail',
'determinate',
{ observed, minRatio: min, maxRatio: max, width, height },
{ subjectId: subjectRef },
);
}
+595
View File
@@ -0,0 +1,595 @@
/**
* Unit tests for the Imhotep geometry constraint solver.
*
* Covers:
* - Relation evaluation (leftOf, rightOf, above, below, overlaps, inside)
* - Size evaluation (atLeast, atMost, between, aspectRatio)
* - Quantifier behavior (all, any, none, pairwise)
* - Proof generation
*/
import { describe, it } from 'node:test';
import assert from 'node:assert';
import {
clearRegistry,
type GeometryWorld,
type ClauseDescriptor,
} from './registry.js';
import {
evaluateLeftOf,
evaluateRightOf,
evaluateAbove,
evaluateBelow,
evaluateOverlaps,
evaluateInside,
} from './relations.js';
import {
evaluateAtLeast,
evaluateAtMost,
evaluateBetween,
evaluateAspectRatio,
} from './size.js';
import { evaluateQuantifier, type QuantifierClause } from './quantifiers.js';
import { generateProof, resetProofCounter } from './proofs.js';
import {
evaluate,
registerDefaultClauses,
collectRequiredFacts,
} from './engine.js';
// --- Test helpers ------------------------------------------------------------
function makeWorld(overrides?: Partial<GeometryWorld>): GeometryWorld {
return {
sceneId: 'scene_1',
snapshotId: 'snap_1',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: [1, 2],
domNodeId: [10, 20],
subjectKind: [1, 1],
primaryBoxId: [100, 200],
firstFragmentId: [0, 0],
fragmentCount: [0, 0],
},
dom: {
nodeId: [10, 20],
parentNodeId: [0, 0],
childCount: [0, 0],
tagNameStringId: [0, 0],
},
boxes: {
boxId: [100, 200],
subjectId: [1, 2],
frameId: [1, 1],
borderLeft: [0, 110],
borderTop: [0, 50],
borderRight: [100, 210],
borderBottom: [40, 90],
paddingLeft: [0, 0],
paddingTop: [0, 0],
paddingRight: [0, 0],
paddingBottom: [0, 0],
contentLeft: [0, 0],
contentTop: [0, 0],
contentRight: [0, 0],
contentBottom: [0, 0],
},
visualBoxes: {
boxId: [],
subjectId: [],
frameId: [],
borderLeft: [],
borderTop: [],
borderRight: [],
borderBottom: [],
paddingLeft: [],
paddingTop: [],
paddingRight: [],
paddingBottom: [],
contentLeft: [],
contentTop: [],
contentRight: [],
contentBottom: [],
},
transforms: {
transformId: [],
subjectId: [],
matrixStart: [],
matrixLength: [],
originX: [],
originY: [],
},
matrices: { values: [] },
rects: {
rectId: [],
left: [],
top: [],
right: [],
bottom: [],
},
topology: {
containingBlockOf: [0, 0],
nearestPositionedAncestorOf: [0, 0],
scrollContainerOf: [0, 0],
stackingContextOf: [0, 0],
formattingContextOf: [0, 0],
clippingRootOf: [0, 0],
paintOrderBucket: [0, 0],
paintOrderIndex: [0, 0],
},
scroll: {
containerId: [],
scrollLeft: [],
scrollTop: [],
scrollWidth: [],
scrollHeight: [],
clientWidth: [],
clientHeight: [],
},
clipping: {
clipNodeId: [],
subjectId: [],
clipKind: [],
clipLeft: [],
clipTop: [],
clipRight: [],
clipBottom: [],
parentClipNodeId: [],
},
visibility: {
subjectId: [],
isRendered: [],
isVisible: [],
visibleArea: [],
clippedArea: [],
},
...overrides,
};
}
function makeClause(kind: string, overrides?: Partial<ClauseDescriptor>): ClauseDescriptor {
return {
clauseId: 'clause_1',
clauseKind: kind,
version: 1,
subjectRef: 1,
referenceRef: 2,
...overrides,
};
}
// --- Relation tests ----------------------------------------------------------
describe('relation evaluators', () => {
it('leftOf passes when gap is inside bounds', () => {
const world = makeWorld();
const clause = makeClause('relation.leftOf', { bounds: { minGap: 5, maxGap: 15 } });
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.truth, 'determinate');
assert.strictEqual(result.metrics?.observedGap, 10);
});
it('leftOf fails when gap is too small', () => {
const world = makeWorld();
const clause = makeClause('relation.leftOf', { bounds: { minGap: 15, maxGap: 20 } });
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'fail');
assert.strictEqual(result.metrics?.observedGap, 10);
});
it('rightOf passes when gap is inside bounds', () => {
// Subject 1 to the right of reference 2 with a 5px gap.
const world = makeWorld({
boxes: {
...makeWorld().boxes,
borderLeft: [105, 0],
borderTop: [0, 0],
borderRight: [205, 100],
borderBottom: [40, 40],
},
});
const clause = makeClause('relation.rightOf', { bounds: { minGap: 5, maxGap: 15 } });
const result = evaluateRightOf(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.observedGap, 5);
});
it('above passes when vertical gap is inside bounds', () => {
const world = makeWorld();
const clause = makeClause('relation.above', { bounds: { minGap: 5, maxGap: 15 } });
const result = evaluateAbove(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.observedGap, 10);
});
it('below passes when vertical gap is inside bounds', () => {
// Subject 1 below reference 2 with a 5px gap.
const world = makeWorld({
boxes: {
...makeWorld().boxes,
borderLeft: [0, 0],
borderTop: [45, 0],
borderRight: [100, 100],
borderBottom: [85, 40],
},
});
const clause = makeClause('relation.below', { bounds: { minGap: 5, maxGap: 15 } });
const result = evaluateBelow(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.observedGap, 5);
});
it('leftOf without gap bounds fails when subject is to the right', () => {
// Subject 1 at [150,250], reference 2 at [0,100] — subject is to the right
const world = makeWorld({
boxes: {
...makeWorld().boxes,
borderLeft: [150, 0],
borderRight: [250, 100],
},
});
const clause = makeClause('relation.leftOf'); // no bounds
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'fail');
});
it('rightOf without gap bounds fails when subject is to the left', () => {
const world = makeWorld(); // subject [0,100], reference [110,210]
const clause = makeClause('relation.rightOf'); // no bounds
const result = evaluateRightOf(world, clause);
assert.strictEqual(result.status, 'fail');
});
it('above without gap bounds fails when subject is below', () => {
// Subject 1 at [50,90], reference 2 at [0,40] — subject is below
const world = makeWorld({
boxes: {
...makeWorld().boxes,
borderTop: [50, 0],
borderBottom: [90, 40],
},
});
const clause = makeClause('relation.above'); // no bounds
const result = evaluateAbove(world, clause);
assert.strictEqual(result.status, 'fail');
});
it('below without gap bounds fails when subject is above', () => {
const world = makeWorld(); // subject [0,40], reference [50,90]
const clause = makeClause('relation.below'); // no bounds
const result = evaluateBelow(world, clause);
assert.strictEqual(result.status, 'fail');
});
it('overlaps detects intersection', () => {
const world = makeWorld();
// Overlap: subject 1 is [0,0,100,40], reference 2 is [110,50,210,90] — no overlap
let result = evaluateOverlaps(world, makeClause('relation.overlaps'));
assert.strictEqual(result.status, 'fail');
// Move reference to overlap
const overlappingWorld = makeWorld({
boxes: {
...makeWorld().boxes,
borderLeft: [0, 50],
borderTop: [0, 20],
borderRight: [100, 150],
borderBottom: [40, 60],
},
});
result = evaluateOverlaps(overlappingWorld, makeClause('relation.overlaps'));
assert.strictEqual(result.status, 'pass');
});
it('inside detects containment', () => {
const world = makeWorld();
// Subject [0,0,100,40] is inside reference [110,50,210,90]? No.
let result = evaluateInside(world, makeClause('relation.inside'));
assert.strictEqual(result.status, 'fail');
const containedWorld = makeWorld({
boxes: {
...makeWorld().boxes,
borderLeft: [120, 100],
borderTop: [60, 50],
borderRight: [180, 210],
borderBottom: [80, 90],
},
});
result = evaluateInside(containedWorld, makeClause('relation.inside'));
assert.strictEqual(result.status, 'pass');
});
it('relation evaluators return error for missing subject', () => {
const world = makeWorld();
const clause = makeClause('relation.leftOf', { subjectRef: 999 });
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'error');
assert.strictEqual(result.truth, 'indeterminate');
});
});
// --- Size tests --------------------------------------------------------------
describe('size evaluators', () => {
it('atLeast passes when dimension is large enough', () => {
const world = makeWorld();
const clause = makeClause('size.atLeast', {
bounds: { min: 80 },
options: { dimension: 'width' },
});
const result = evaluateAtLeast(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.observed, 100);
});
it('atLeast fails when dimension is too small', () => {
const world = makeWorld();
const clause = makeClause('size.atLeast', {
bounds: { min: 120 },
options: { dimension: 'width' },
});
const result = evaluateAtLeast(world, clause);
assert.strictEqual(result.status, 'fail');
});
it('atMost passes when dimension is small enough', () => {
const world = makeWorld();
const clause = makeClause('size.atMost', {
bounds: { max: 120 },
options: { dimension: 'width' },
});
const result = evaluateAtMost(world, clause);
assert.strictEqual(result.status, 'pass');
});
it('between passes when dimension is inside range', () => {
const world = makeWorld();
const clause = makeClause('size.between', {
bounds: { min: 80, max: 120 },
options: { dimension: 'width' },
});
const result = evaluateBetween(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.observed, 100);
});
it('aspectRatio passes when ratio is inside range', () => {
const world = makeWorld();
// Subject 1: width 100, height 40 => ratio 2.5
const clause = makeClause('size.aspectRatio', {
bounds: { minRatio: 2, maxRatio: 3 },
});
const result = evaluateAspectRatio(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.observed, 2.5);
});
it('aspectRatio returns error when height is zero', () => {
const world = makeWorld({
boxes: {
...makeWorld().boxes,
borderBottom: [40, 90],
borderTop: [40, 90],
},
});
const clause = makeClause('size.aspectRatio');
const result = evaluateAspectRatio(world, clause);
assert.strictEqual(result.status, 'error');
assert.strictEqual(result.truth, 'indeterminate');
});
});
// --- Quantifier tests --------------------------------------------------------
describe('quantifier evaluators', () => {
it('all passes when every sub-result passes', () => {
const subResults = [
{ clauseId: 'c1', status: 'pass' as const, truth: 'determinate' as const },
{ clauseId: 'c2', status: 'pass' as const, truth: 'determinate' as const },
];
const clause: QuantifierClause = {
clauseId: 'q1',
clauseKind: 'quantifier.all',
version: 1,
quantifier: 'all',
subClauseIds: ['c1', 'c2'],
};
const result = evaluateQuantifier(subResults, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.total, 2);
});
it('all fails when any sub-result fails', () => {
const subResults = [
{ clauseId: 'c1', status: 'pass' as const, truth: 'determinate' as const },
{ clauseId: 'c2', status: 'fail' as const, truth: 'determinate' as const },
];
const clause: QuantifierClause = {
clauseId: 'q1',
clauseKind: 'quantifier.all',
version: 1,
quantifier: 'all',
subClauseIds: ['c1', 'c2'],
};
const result = evaluateQuantifier(subResults, clause);
assert.strictEqual(result.status, 'fail');
assert.strictEqual(result.metrics?.failed, 1);
});
it('any passes when at least one sub-result passes', () => {
const subResults = [
{ clauseId: 'c1', status: 'fail' as const, truth: 'determinate' as const },
{ clauseId: 'c2', status: 'pass' as const, truth: 'determinate' as const },
];
const clause: QuantifierClause = {
clauseId: 'q1',
clauseKind: 'quantifier.any',
version: 1,
quantifier: 'any',
subClauseIds: ['c1', 'c2'],
};
const result = evaluateQuantifier(subResults, clause);
assert.strictEqual(result.status, 'pass');
});
it('none passes when no sub-result passes', () => {
const subResults = [
{ clauseId: 'c1', status: 'fail' as const, truth: 'determinate' as const },
{ clauseId: 'c2', status: 'fail' as const, truth: 'determinate' as const },
];
const clause: QuantifierClause = {
clauseId: 'q1',
clauseKind: 'quantifier.none',
version: 1,
quantifier: 'none',
subClauseIds: ['c1', 'c2'],
};
const result = evaluateQuantifier(subResults, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.passed, 0);
});
it('pairwise requires at least two sub-clauses', () => {
const clause: QuantifierClause = {
clauseId: 'q1',
clauseKind: 'quantifier.pairwise',
version: 1,
quantifier: 'pairwise',
subClauseIds: ['c1'],
};
const result = evaluateQuantifier(
[{ clauseId: 'c1', status: 'pass' as const, truth: 'determinate' as const }],
clause,
);
assert.strictEqual(result.status, 'error');
assert.strictEqual(result.truth, 'indeterminate');
});
it('quantifier returns error for empty sub-results', () => {
const clause: QuantifierClause = {
clauseId: 'q1',
clauseKind: 'quantifier.all',
version: 1,
quantifier: 'all',
subClauseIds: [],
};
const result = evaluateQuantifier([], clause);
assert.strictEqual(result.status, 'error');
assert.strictEqual(result.truth, 'indeterminate');
});
});
// --- Proof generation tests --------------------------------------------------
describe('proof generation', () => {
it('generateProof creates a proof with outcome and witness', () => {
resetProofCounter();
const world = makeWorld();
const clause = makeClause('relation.leftOf');
const result = evaluateLeftOf(world, clause);
const proof = generateProof(result, clause, world, [101, 102]);
assert.strictEqual(proof.proofId, 'proof_1');
assert.strictEqual(proof.clauseId, 'clause_1');
assert.strictEqual(proof.outcome, result.status);
assert.strictEqual(proof.truth, result.truth);
assert.deepStrictEqual(proof.usedFacts, [101, 102]);
assert.strictEqual(proof.witness?.subjectId, 1);
assert.strictEqual(proof.witness?.referenceId, 2);
assert.strictEqual(proof.witness?.snapshotId, 'snap_1');
});
it('generateProof synthesizes failedPredicate on failure', () => {
resetProofCounter();
const world = makeWorld();
const clause = makeClause('relation.leftOf', { bounds: { minGap: 50 } });
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'fail');
const proof = generateProof(result, clause, world);
assert.ok(proof.failedPredicate);
assert.strictEqual(typeof proof.failedPredicate?.left, 'number');
assert.strictEqual(typeof proof.failedPredicate?.right, 'number');
});
it('generateProof omits failedPredicate on pass', () => {
resetProofCounter();
const world = makeWorld();
const clause = makeClause('relation.leftOf');
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'pass');
const proof = generateProof(result, clause, world);
assert.strictEqual(proof.failedPredicate, undefined);
});
});
// --- Engine integration tests ------------------------------------------------
describe('solver engine', () => {
it('evaluate runs registered evaluators and produces proofs', () => {
clearRegistry();
registerDefaultClauses();
const world = makeWorld();
const clauses: ClauseDescriptor[] = [
makeClause('relation.leftOf', { clauseId: 'c1', bounds: { minGap: 5 } }),
makeClause('size.atLeast', {
clauseId: 'c2',
subjectRef: 1,
referenceRef: undefined,
bounds: { min: 50 },
options: { dimension: 'width' },
}),
];
const output = evaluate(world, clauses);
assert.strictEqual(output.clauseResults.length, 2);
assert.strictEqual(output.proofs.length, 2);
assert.strictEqual(output.clauseResults[0].status, 'pass');
assert.strictEqual(output.clauseResults[1].status, 'pass');
});
it('collectRequiredFacts gathers facts across clauses', () => {
clearRegistry();
registerDefaultClauses();
const clauses: ClauseDescriptor[] = [
makeClause('relation.leftOf'),
makeClause('size.atLeast', {
subjectRef: 1,
referenceRef: undefined,
}),
];
const facts = collectRequiredFacts(clauses);
assert.ok(facts.includes('subject.primaryBox'));
assert.ok(facts.includes('reference.primaryBox'));
});
it('evaluate emits error for unregistered clause kinds', () => {
clearRegistry();
const world = makeWorld();
const clauses: ClauseDescriptor[] = [
makeClause('relation.unknown', { clauseId: 'c1' }),
];
const output = evaluate(world, clauses);
assert.strictEqual(output.clauseResults[0].status, 'error');
assert.ok(output.diagnostics.some((d) => d.code === 'IMH_EVALUATOR_MISSING'));
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* Topology clause evaluators:
* clippedBy, attachedToScrollContainer, inStackingContext
*
* These evaluators consult the topology graph rather than raw box geometry.
*/
import {
type GeometryWorld,
type ClauseDescriptor,
type ClauseResult,
} from './registry.js';
function result(
clauseId: string,
status: 'pass' | 'fail' | 'error',
truth: 'determinate' | 'indeterminate',
metrics?: Record<string, number>,
witness?: Record<string, number | undefined>,
): ClauseResult {
return {
clauseId,
status,
truth,
metrics,
witness: witness
? {
subjectId: witness.subjectId,
referenceId: witness.referenceId,
}
: undefined,
};
}
/**
* Safely read a topology value indexed by subject id.
* Returns 0 when the subject is out of bounds (treated as "no relation").
*/
function getTopologyValue(arr: number[], subjectId: number): number {
return arr[subjectId] ?? 0;
}
/**
* Check whether the subject is clipped by the reference.
* Walks the clipping chain for the subject looking for the reference.
*/
export function evaluateClippedBy(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, referenceRef, clauseId } = clause;
if (subjectRef === undefined || referenceRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const { clipping } = world;
// Build lookup maps from clipNodeId to subjectId and parentClipNodeId.
const clipNodeToSubject = new Map<number, number>();
const clipNodeToParent = new Map<number, number>();
for (let i = 0; i < clipping.clipNodeId.length; i++) {
clipNodeToSubject.set(clipping.clipNodeId[i], clipping.subjectId[i]);
clipNodeToParent.set(clipping.clipNodeId[i], clipping.parentClipNodeId[i]);
}
let clipped = false;
for (let i = 0; i < clipping.clipNodeId.length; i++) {
if (clipping.subjectId[i] === subjectRef) {
let current = clipping.clipNodeId[i];
while (current !== 0) {
if (clipNodeToSubject.get(current) === referenceRef) {
clipped = true;
break;
}
const parent = clipNodeToParent.get(current) ?? 0;
if (parent === current || parent === 0) break;
current = parent;
}
if (clipped) break;
}
}
return result(
clauseId,
clipped ? 'pass' : 'fail',
'determinate',
{ clipped: clipped ? 1 : 0 },
{ subjectId: subjectRef, referenceId: referenceRef },
);
}
/**
* Check whether the subject is attached to the scroll container identified by
* the reference subject id. Uses the topology.scrollContainerOf array.
*/
export function evaluateAttachedToScrollContainer(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, referenceRef, clauseId } = clause;
if (subjectRef === undefined || referenceRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const scrollContainerId = getTopologyValue(
world.topology.scrollContainerOf,
subjectRef,
);
const attached = scrollContainerId === referenceRef;
return result(
clauseId,
attached ? 'pass' : 'fail',
'determinate',
{ attached: attached ? 1 : 0, scrollContainerId },
{ subjectId: subjectRef, referenceId: referenceRef },
);
}
/**
* Check whether subject and reference share a stacking context.
* Uses topology.stackingContextOf indexed by subject id.
*/
export function evaluateInStackingContext(
world: GeometryWorld,
clause: ClauseDescriptor,
): ClauseResult {
const { subjectRef, referenceRef, clauseId } = clause;
if (subjectRef === undefined || referenceRef === undefined) {
return result(clauseId, 'error', 'indeterminate', undefined, {
subjectRef,
referenceRef,
});
}
const sCtx = getTopologyValue(world.topology.stackingContextOf, subjectRef);
const rCtx = getTopologyValue(world.topology.stackingContextOf, referenceRef);
const pass = sCtx !== 0 && sCtx === rCtx;
return result(
clauseId,
pass ? 'pass' : 'fail',
'determinate',
{ subjectStackingContext: sCtx, referenceStackingContext: rCtx },
{ subjectId: subjectRef, referenceId: referenceRef },
);
}
@@ -0,0 +1,329 @@
/**
* Unit tests for transform adapter and space-aware relation evaluation.
*
* Covers:
* - Transform chain application to layout rects
* - Visual vs layout coordinate space in relation evaluators
* - Per-evaluation caching of visual rects
*/
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert';
import {
type GeometryWorld,
type ClauseDescriptor,
CoordinateSpace,
} from './registry.js';
import {
getLayoutRect,
getVisualRect,
getBorderRect,
getTransformChain,
applyTransformToRect,
getEvaluationCache,
clearEvaluationCache,
} from './transform-adapter.js';
import {
evaluateLeftOf,
} from './relations.js';
// --- Helpers -----------------------------------------------------------------
function makeWorldWithTransform(
layoutLeft: number,
layoutTop: number,
layoutRight: number,
layoutBottom: number,
translateX = 0,
translateY = 0,
): GeometryWorld {
return {
sceneId: 'scene_1',
snapshotId: 'snap_1',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: [1, 2],
domNodeId: [10, 20],
subjectKind: [1, 1],
primaryBoxId: [100, 200],
firstFragmentId: [0, 0],
fragmentCount: [0, 0],
},
dom: {
nodeId: [10, 20],
parentNodeId: [0, 0],
childCount: [0, 0],
tagNameStringId: [0, 0],
},
boxes: {
boxId: [100, 200],
subjectId: [1, 2],
frameId: [1, 1],
borderLeft: [layoutLeft, layoutLeft + 200],
borderTop: [layoutTop, layoutTop],
borderRight: [layoutRight, layoutRight + 200],
borderBottom: [layoutBottom, layoutBottom + 50],
paddingLeft: [0, 0],
paddingTop: [0, 0],
paddingRight: [0, 0],
paddingBottom: [0, 0],
contentLeft: [0, 0],
contentTop: [0, 0],
contentRight: [0, 0],
contentBottom: [0, 0],
},
visualBoxes: {
boxId: [],
subjectId: [],
frameId: [],
borderLeft: [],
borderTop: [],
borderRight: [],
borderBottom: [],
paddingLeft: [],
paddingTop: [],
paddingRight: [],
paddingBottom: [],
contentLeft: [],
contentTop: [],
contentRight: [],
contentBottom: [],
},
transforms: {
transformId: [1],
subjectId: [1],
matrixStart: [0],
matrixLength: [6],
originX: [0],
originY: [0],
},
matrices: {
values: [
// CSS 2x3 matrix: translate(translateX, translateY)
1, 0, 0, 1, translateX, translateY,
],
},
rects: {
rectId: [],
left: [],
top: [],
right: [],
bottom: [],
},
topology: {
containingBlockOf: [0, 0],
nearestPositionedAncestorOf: [0, 0],
scrollContainerOf: [0, 0],
stackingContextOf: [0, 0],
formattingContextOf: [0, 0],
clippingRootOf: [0, 0],
paintOrderBucket: [0, 0],
paintOrderIndex: [0, 0],
},
scroll: {
containerId: [],
scrollLeft: [],
scrollTop: [],
scrollWidth: [],
scrollHeight: [],
clientWidth: [],
clientHeight: [],
},
clipping: {
clipNodeId: [],
subjectId: [],
clipKind: [],
clipLeft: [],
clipTop: [],
clipRight: [],
clipBottom: [],
parentClipNodeId: [],
},
visibility: {
subjectId: [],
isRendered: [],
isVisible: [],
visibleArea: [],
clippedArea: [],
},
};
}
function makeClause(kind: string, overrides?: Partial<ClauseDescriptor>): ClauseDescriptor {
return {
clauseId: 'clause_1',
clauseKind: kind,
version: 1,
subjectRef: 1,
referenceRef: 2,
...overrides,
};
}
// --- Transform adapter tests -------------------------------------------------
describe('transform adapter', () => {
beforeEach(() => {
// Ensure clean caches for each test.
});
it('getLayoutRect returns pre-transform coordinates', () => {
const world = makeWorldWithTransform(0, 0, 100, 50, 50, 0);
const rect = getLayoutRect(world, 1);
assert.ok(rect);
assert.strictEqual(rect!.left, 0);
assert.strictEqual(rect!.top, 0);
assert.strictEqual(rect!.right, 100);
assert.strictEqual(rect!.bottom, 50);
});
it('getTransformChain returns composite matrix for subject with transform', () => {
const world = makeWorldWithTransform(0, 0, 100, 50, 50, 0);
const chain = getTransformChain(world, 1);
assert.ok(chain);
// For a translate(50, 0) matrix, the 4x4 form has e=50 at index 12.
assert.strictEqual(chain![12], 50);
assert.strictEqual(chain![13], 0);
});
it('getTransformChain returns null for subject without transform', () => {
const world = makeWorldWithTransform(0, 0, 100, 50, 50, 0);
const chain = getTransformChain(world, 2);
assert.strictEqual(chain, null);
});
it('applyTransformToRect translates rect by matrix', () => {
const rect = { left: 0, top: 0, right: 100, bottom: 50 };
const mat = new Float64Array([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
50, 0, 0, 1, // translateX(50)
]);
const transformed = applyTransformToRect(rect, mat);
assert.strictEqual(transformed.left, 50);
assert.strictEqual(transformed.right, 150);
assert.strictEqual(transformed.top, 0);
assert.strictEqual(transformed.bottom, 50);
});
it('getVisualRect returns transformed coordinates', () => {
const world = makeWorldWithTransform(0, 0, 100, 50, 50, 0);
const rect = getVisualRect(world, 1);
assert.ok(rect);
assert.strictEqual(rect!.left, 50);
assert.strictEqual(rect!.right, 150);
});
it('getVisualRect falls back to layout rect when no transforms', () => {
const world = makeWorldWithTransform(0, 0, 100, 50, 50, 0);
const rect = getVisualRect(world, 2);
assert.ok(rect);
assert.strictEqual(rect!.left, 200);
assert.strictEqual(rect!.right, 300);
});
it('getBorderRect defaults to VISUAL space', () => {
const world = makeWorldWithTransform(0, 0, 100, 50, 50, 0);
const rect = getBorderRect(world, 1);
assert.ok(rect);
assert.strictEqual(rect!.left, 50);
});
it('getBorderRect returns layout rect when space is LAYOUT', () => {
const world = makeWorldWithTransform(0, 0, 100, 50, 50, 0);
const rect = getBorderRect(world, 1, CoordinateSpace.LAYOUT);
assert.ok(rect);
assert.strictEqual(rect!.left, 0);
});
it('caches visual rects per-evaluation', () => {
const world = makeWorldWithTransform(0, 0, 100, 50, 50, 0);
clearEvaluationCache(world);
const cache = getEvaluationCache(world);
const r1 = getVisualRect(world, 1, cache);
const r2 = getVisualRect(world, 1, cache);
assert.strictEqual(r1, r2); // Same object reference from cache.
});
});
// --- Space-aware relation tests ----------------------------------------------
describe('space-aware relations', () => {
it('leftOf in visual space sees transformed position', () => {
// Subject 1: layout [0,0,100,50], translated by +50px => visual [50,0,150,50]
// Reference 2: layout [200,0,300,50], no transform => visual [200,0,300,50]
// Visual gap = 200 - 150 = 50px
const world = makeWorldWithTransform(0, 0, 100, 50, 50, 0);
clearEvaluationCache(world);
const clause = makeClause('relation.leftOf', {
bounds: { minGap: 45, maxGap: 55 },
});
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.observedGap, 50);
assert.strictEqual(result.metrics?.space, CoordinateSpace.VISUAL);
});
it('leftOf in layout space sees original position', () => {
// Subject 1: layout [0,0,100,50]
// Reference 2: layout [200,0,300,50]
// Layout gap = 200 - 100 = 100px
const world = makeWorldWithTransform(0, 0, 100, 50, 50, 0);
clearEvaluationCache(world);
const clause = makeClause('relation.leftOf', {
bounds: { minGap: 95, maxGap: 105 },
options: { space: 'layout' },
});
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.observedGap, 100);
assert.strictEqual(result.metrics?.space, CoordinateSpace.LAYOUT);
});
it('leftOf fails in visual space when transform is ignored', () => {
// Subject 1: layout [0,0,100,50], translated by +50px => visual [50,0,150,50]
// Reference 2: layout [200,0,300,50]
// If we evaluated in layout space with visual bounds, gap would be 100px.
// But in visual space gap is 50px, so minGap=80 fails.
const world = makeWorldWithTransform(0, 0, 100, 50, 50, 0);
clearEvaluationCache(world);
const clause = makeClause('relation.leftOf', {
bounds: { minGap: 80, maxGap: 120 },
});
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'fail');
assert.strictEqual(result.metrics?.observedGap, 50);
});
it('leftOf passes in layout space when transform would break visual', () => {
// Same setup: subject translated +50px.
// Layout gap = 100px, which satisfies minGap=80.
const world = makeWorldWithTransform(0, 0, 100, 50, 50, 0);
clearEvaluationCache(world);
const clause = makeClause('relation.leftOf', {
bounds: { minGap: 80, maxGap: 120 },
options: { space: 'layout' },
});
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.observedGap, 100);
});
});
@@ -0,0 +1,273 @@
/**
* Transform adapter: applies CSS transform chains to layout rects to
* produce visual rects. Provides the bridge between raw extraction
* (layout boxes + transforms) and space-aware relation evaluation.
*/
import {
buildTransformChain,
transformPoint,
readMatrix4x4,
MATRIX_4X4_STRIDE,
} from 'imhotep-geometry'
import { type GeometryWorld, CoordinateSpace } from './registry.js'
// --- Types -------------------------------------------------------------------
export interface Rect {
left: number
top: number
right: number
bottom: number
}
// --- Caches ------------------------------------------------------------------
// Per-evaluation caches to avoid recomputing transform chains and visual rects.
// A WeakMap keyed by world reference ensures caches are garbage-collected
// automatically and are scoped per-evaluation when cleared by the engine.
export interface TransformCache {
visualRectBySubjectId: Map<number, Rect>
layoutRectBySubjectId: Map<number, Rect>
transformChainBySubjectId: Map<number, Float64Array | null>
}
export function createTransformCache(): TransformCache {
return {
visualRectBySubjectId: new Map(),
layoutRectBySubjectId: new Map(),
transformChainBySubjectId: new Map(),
}
}
const _evaluationCache = new WeakMap<GeometryWorld, TransformCache>()
/** Get or create the evaluation cache for a given world. */
export function getEvaluationCache(world: GeometryWorld): TransformCache {
let cache = _evaluationCache.get(world)
if (!cache) {
cache = createTransformCache()
_evaluationCache.set(world, cache)
}
return cache
}
/** Clear the evaluation cache for a given world. Call this at the start of evaluate(). */
export function clearEvaluationCache(world: GeometryWorld): void {
_evaluationCache.delete(world)
}
// --- Layout rect --------------------------------------------------------------
/**
* Get the layout (pre-transform) border-box rect for a subject.
* Reads from world.boxes (layout space).
*/
export function getLayoutRect(
world: GeometryWorld,
subjectId: number,
cache?: TransformCache
): Rect | null {
if (cache) {
const cached = cache.layoutRectBySubjectId.get(subjectId)
if (cached) return cached
}
const idx = findBoxIndex(world.boxes, subjectId)
if (idx === -1) return null
const b = world.boxes
const rect: Rect = {
left: b.borderLeft[idx],
top: b.borderTop[idx],
right: b.borderRight[idx],
bottom: b.borderBottom[idx],
}
if (cache) {
cache.layoutRectBySubjectId.set(subjectId, rect)
}
return rect
}
/**
* Get the visual (post-transform) border-box rect for a subject.
*
* First checks world.visualBoxes for pre-computed visual geometry
* (populated by extractors like Playwright boundingBox or CDP
* with transform-applied boxes). If visualBoxes is empty or
* missing the subject, falls back to computing from layout rect
* + transform chain.
*/
export function getVisualRect(
world: GeometryWorld,
subjectId: number,
cache?: TransformCache
): Rect | null {
if (cache) {
const cached = cache.visualRectBySubjectId.get(subjectId)
if (cached) return cached
}
// Prefer pre-computed visual boxes if available.
const visualIdx = findBoxIndex(world.visualBoxes, subjectId)
if (visualIdx !== -1) {
const vb = world.visualBoxes
const rect: Rect = {
left: vb.borderLeft[visualIdx],
top: vb.borderTop[visualIdx],
right: vb.borderRight[visualIdx],
bottom: vb.borderBottom[visualIdx],
}
if (cache) {
cache.visualRectBySubjectId.set(subjectId, rect)
}
return rect
}
// Fallback: compute from layout + transform chain.
const layout = getLayoutRect(world, subjectId, cache)
if (!layout) return null
const transform = getTransformChain(world, subjectId, cache)
if (!transform) {
if (cache) {
cache.visualRectBySubjectId.set(subjectId, layout)
}
return layout
}
const visual = applyTransformToRect(layout, transform)
if (cache) {
cache.visualRectBySubjectId.set(subjectId, visual)
}
return visual
}
/**
* Get a border-box rect for a subject in the requested coordinate space.
* Defaults to VISUAL (post-transform) to match user expectations.
*/
export function getBorderRect(
world: GeometryWorld,
subjectId: number,
space: CoordinateSpace = CoordinateSpace.VISUAL,
cache?: TransformCache
): Rect | null {
if (space === CoordinateSpace.VISUAL) {
return getVisualRect(world, subjectId, cache)
}
return getLayoutRect(world, subjectId, cache)
}
// --- Transform chain ----------------------------------------------------------
/**
* Build the composite transform matrix for a subject by walking
* the transforms table and multiplying all matrices in order.
* Returns null if the subject has no transforms.
*/
export function getTransformChain(
world: GeometryWorld,
subjectId: number,
cache?: TransformCache
): Float64Array | null {
if (cache) {
const cached = cache.transformChainBySubjectId.get(subjectId)
if (cached !== undefined) return cached
}
const { transforms, matrices } = world
const matricesList: Float64Array[] = []
for (let i = 0; i < transforms.transformId.length; i++) {
if (transforms.subjectId[i] === subjectId) {
const start = transforms.matrixStart[i]
const len = transforms.matrixLength[i]
if (len === MATRIX_4X4_STRIDE) {
const mat = readMatrix4x4(
new Float64Array(matrices.values),
start
)
matricesList.push(mat)
} else if (len === 6) {
// Convert CSS 2x3 matrix (a,b,c,d,e,f) to 4x4 column-major.
const flat = matrices.values
const a = flat[start]
const b = flat[start + 1]
const c = flat[start + 2]
const d = flat[start + 3]
const e = flat[start + 4]
const f = flat[start + 5]
const mat = new Float64Array([
a, b, 0, 0,
c, d, 0, 0,
0, 0, 1, 0,
e, f, 0, 1,
])
matricesList.push(mat)
}
}
}
if (matricesList.length === 0) {
if (cache) {
cache.transformChainBySubjectId.set(subjectId, null)
}
return null
}
const chain = buildTransformChain(matricesList)
if (cache) {
cache.transformChainBySubjectId.set(subjectId, chain)
}
return chain
}
/**
* Apply a 4x4 transform matrix to each corner of a rect and return
* the axis-aligned bounding box of the transformed corners.
*/
export function applyTransformToRect(rect: Rect, mat: Float64Array): Rect {
const corners = [
{ x: rect.left, y: rect.top },
{ x: rect.right, y: rect.top },
{ x: rect.right, y: rect.bottom },
{ x: rect.left, y: rect.bottom },
]
const out = new Float64Array(2)
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const corner of corners) {
transformPoint(mat, corner.x, corner.y, out)
minX = Math.min(minX, out[0])
minY = Math.min(minY, out[1])
maxX = Math.max(maxX, out[0])
maxY = Math.max(maxY, out[1])
}
return {
left: minX,
top: minY,
right: maxX,
bottom: maxY,
}
}
// --- Helpers ------------------------------------------------------------------
function findBoxIndex(
boxes: GeometryWorld['boxes'],
subjectId: number
): number {
for (let i = 0; i < boxes.boxId.length; i++) {
if (boxes.subjectId[i] === subjectId) {
return i
}
}
return -1
}
@@ -0,0 +1,16 @@
{
"$schema":"../../node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"packageManager":"npm",
"reporters":["html","clear-text","progress"],
"testRunner":"command",
"commandRunner":{
"command":"npm run build && npm test"
},
"coverageAnalysis":"off",
"mutate":["src/**/*.ts","!src/**/*.test.ts"],
"thresholds":{
"high":80,
"low":60,
"break":50
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"paths": {},
"composite": false,
"noEmitOnError": false
},
"include": [
"src/**/*"
]
}