/** * 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; 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, ): 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: 'attachedToScrollContainer', arity: 2, domains: ['element', 'element'], requiredFacts: ['topology.scrollContainerOf'] }, { name: 'escapeClippingChainOf', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.clipChain', 'topology.clippingRootOf'] }, { name: 'inStackingContext', arity: 2, domains: ['element', 'element'], requiredFacts: ['topology.stackingContextOf'] }, { name: 'separatedFrom', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] }, { name: 'aspectRatio', arity: 1, domains: ['element'], requiredFacts: ['subject.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'] }, { name: 'leftAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] }, { name: 'rightAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] }, { name: 'topAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] }, { name: 'bottomAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] }, { name: 'intersects', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] }, { name: 'touches', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] }, { name: 'hasGap', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] }, ]; // --------------------------------------------------------------------------- // Predicate Registry // --------------------------------------------------------------------------- export class PredicateRegistry { private registry = new Map(); 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 ?? []; } } /** @deprecated Use explicit PredicateRegistry injection via LogicEngineOptions.predicateRegistry. */ 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(); } 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 // --------------------------------------------------------------------------- const boxIndexCache = new WeakMap>() function getBorderRect(world: GeometryWorld, subjectId: number) { const { boxes } = world; let index = boxIndexCache.get(world); if (!index) { index = new Map(); for (let i = 0; i < boxes.subjectId.length; i++) { index.set(boxes.subjectId[i], i); } boxIndexCache.set(world, 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 getSubjectIndex(world: GeometryWorld, subjectId: number): number { return world.subjects.ids.indexOf(subjectId); } function getTopologyValueBySubject(world: GeometryWorld, subjectId: number, values: number[]): number { const idx = getSubjectIndex(world, subjectId); if (idx < 0 || idx >= values.length) return -1; const v = values[idx]; // NaN or negative values indicate unset topology; treat as missing. return (v === undefined || Number.isNaN(v) || v < 0) ? -1 : v; } function getSubjectDomNodeId(world: GeometryWorld, subjectId: number): number { const idx = getSubjectIndex(world, subjectId); return idx >= 0 ? world.subjects.domNodeId[idx] ?? 0 : 0; } function isDomAncestor(world: GeometryWorld, ancestorSubjectId: number, descendantSubjectId: number): boolean | undefined { const ancestorNodeId = getSubjectDomNodeId(world, ancestorSubjectId); let current = getSubjectDomNodeId(world, descendantSubjectId); if (ancestorNodeId <= 0 || current <= 0 || world.dom.nodeId.length === 0) return undefined; while (current > 0) { const pos = world.dom.nodeId.indexOf(current); if (pos < 0) return undefined; const parent = world.dom.parentNodeId[pos] ?? 0; if (parent === ancestorNodeId) return true; current = parent; } return false; } function getClipRectForSubject(world: GeometryWorld, subjectId: number) { const clipping = world.clipping; for (let i = 0; i < clipping.subjectId.length; i++) { if (clipping.subjectId[i] !== subjectId) continue; return { left: clipping.clipLeft[i], top: clipping.clipTop[i], right: clipping.clipRight[i], bottom: clipping.clipBottom[i], kind: clipping.clipKind[i] ?? 0, }; } return null; } function rectOverlapMetrics(sRect: ReturnType & {}, rRect: ReturnType & {}) { 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); const overlapWidth = Math.max(0, overlapRight - overlapLeft); const overlapHeight = Math.max(0, overlapBottom - overlapTop); const gapX = Math.max(rRect.left - sRect.right, sRect.left - rRect.right, 0); const gapY = Math.max(rRect.top - sRect.bottom, sRect.top - rRect.bottom, 0); return { overlapWidth, overlapHeight, overlapArea: overlapWidth * overlapHeight, gapX, gapY, gap: Math.hypot(gapX, gapY), }; } function makePredicateResult( truth: PredicateTruth, metrics?: Record, 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, 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; 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 fontSize = world.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 fontSize = world.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 subjectIdArr = world.styles?.subjectId; const chWidthArr = world.styles?.chWidth; if (!chWidthArr || chWidthArr.length === 0) { return getSubjectFontSizePx(world, subjectId) * 0.5; } if (subjectIdArr && subjectIdArr.length > 0) { for (let i = 0; i < subjectIdArr.length; i++) { if (Number(subjectIdArr[i]) !== subjectId) continue; const w = Number(chWidthArr[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(chWidthArr[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: Record = { 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 hasOverflow = metrics.overflowLeft > 0 || metrics.overflowTop > 0 || metrics.overflowRight > 0 || metrics.overflowBottom > 0; if (hasOverflow && world.clipping?.subjectId) { for (let i = 0; i < world.clipping.subjectId.length; i++) { if (world.clipping.subjectId[i] === referenceId) { metrics.hasClippedOverflow = 1; break; } } } 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: Record = { 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 overflowLeft = Math.max(0, sRect.left - rRect.left); const overflowTop = Math.max(0, sRect.top - rRect.top); const overflowRight = Math.max(0, rRect.right - sRect.right); const overflowBottom = Math.max(0, rRect.bottom - sRect.bottom); if (overflowLeft > 0 || overflowTop > 0 || overflowRight > 0 || overflowBottom > 0) { if (world.clipping?.subjectId) { for (let i = 0; i < world.clipping.subjectId.length; i++) { if (world.clipping.subjectId[i] === subjectId) { metrics.hasClippedOverflow = 1; break; } } } } 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 = { 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 = getTopologyValueBySubject(world, subjectId, world.topology.clippingRootOf); if (clipRoot < 0) return makePredicateResult('indeterminate'); const pass = clipRoot === referenceId; // Determine clip kind from the clipping table entry for the reference. // Encoding: 1=contain:paint, 2=overflow:hidden/scroll/auto const metrics: Record = {}; if (referenceId !== undefined) { const { clipping } = world; for (let i = 0; i < clipping.subjectId.length; i++) { if (clipping.subjectId[i] === referenceId) { metrics.clipKind = clipping.clipKind[i] === 5 /* Contain */ ? 1 : 2; break; } } } return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId]); }, }; export const inStackingContextPredicate: PredicateEvaluator = { descriptor: BUILTIN_PREDICATES[17]!, evaluateTuple(world, tuple) { const subjectId = tuple[0]; if (subjectId === undefined) { return makePredicateResult('indeterminate'); } const sc = getTopologyValueBySubject(world, subjectId, world.topology.stackingContextOf); if (sc < 0) return makePredicateResult('indeterminate'); const subjectHasSC = sc > 0; if (tuple.length >= 2 && tuple[1] !== undefined && tuple[1] !== 0) { const referenceId = tuple[1]; const refSC = getTopologyValueBySubject(world, referenceId, world.topology.stackingContextOf); if (refSC < 0) return makePredicateResult('indeterminate'); const pass = subjectHasSC && refSC > 0 && sc === refSC; return makePredicateResult(pass ? 'true' : 'false', { stackingContext: sc, referenceStackingContext: refSC }, [subjectId, referenceId]); } const pass = subjectHasSC; return makePredicateResult(pass ? 'true' : 'false', { stackingContext: sc }, [subjectId]); }, }; export const attachedToScrollContainerPredicate: PredicateEvaluator = { descriptor: BUILTIN_PREDICATES[15]!, evaluateTuple(world, tuple) { const [subjectId, referenceId] = tuple; if (subjectId === undefined || referenceId === undefined) { return makePredicateResult('indeterminate'); } const scrollContainer = getTopologyValueBySubject(world, subjectId, world.topology.scrollContainerOf); if (scrollContainer < 0) return makePredicateResult('indeterminate'); const pass = scrollContainer > 0 && scrollContainer === referenceId; return makePredicateResult(pass ? 'true' : 'false', { scrollContainer, referenceId }, [subjectId]); }, }; export const escapeClippingChainOfPredicate: PredicateEvaluator = { descriptor: BUILTIN_PREDICATES[16]!, evaluateTuple(world, tuple) { const [subjectId, referenceId] = tuple; if (subjectId === undefined || referenceId === undefined) { return makePredicateResult('indeterminate'); } const sRect = getBorderRect(world, subjectId); const clipRect = getClipRectForSubject(world, referenceId); if (!sRect || !clipRect) { return makePredicateResult('indeterminate', { hasClipRect: clipRect ? 1 : 0 }, [subjectId, referenceId]); } const ancestry = isDomAncestor(world, referenceId, subjectId); const overflowLeft = Math.max(0, clipRect.left - sRect.left); const overflowTop = Math.max(0, clipRect.top - sRect.top); const overflowRight = Math.max(0, sRect.right - clipRect.right); const overflowBottom = Math.max(0, sRect.bottom - clipRect.bottom); const overflow = overflowLeft + overflowTop + overflowRight + overflowBottom; const clippingRoot = getTopologyValueBySubject(world, subjectId, world.topology.clippingRootOf); if (clippingRoot < 0) return makePredicateResult('indeterminate', { hasClippingRoot: 0 }, [subjectId, referenceId]); const clippedByReference = clippingRoot === referenceId; const pass = overflow > 0 && !clippedByReference && ancestry !== false; const metrics: Record = { overflowLeft, overflowTop, overflowRight, overflowBottom, overflow, clipKind: clipRect.kind, clippingRoot, clippedByReference: clippedByReference ? 1 : 0, referenceIsAncestor: ancestry === undefined ? -1 : ancestry ? 1 : 0, }; const diagnostics = pass ? undefined : makePredicateDiagnostic('escapeClippingChainOf', metrics, [subjectId, referenceId]); return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics); }, }; export const aspectRatioPredicate: PredicateEvaluator = { descriptor: BUILTIN_PREDICATES[19]!, evaluateTuple(world, tuple, options) { const subjectId = tuple[0]; if (subjectId === undefined) { return makePredicateResult('indeterminate'); } const boxId = world.subjects.primaryBoxId[subjectId - 1]; if (boxId === undefined || boxId < 0) { return makePredicateResult('indeterminate'); } const w = world.boxes.contentRight[boxId] - world.boxes.contentLeft[boxId]; const h = world.boxes.contentBottom[boxId] - world.boxes.contentTop[boxId]; if (h <= 0) { return makePredicateResult('indeterminate'); } const ratio = w / h; const min = (options as any)?.min as number | undefined; const max = (options as any)?.max as number | undefined; if (min !== undefined && ratio < min) { return makePredicateResult('false', { aspectRatio: ratio, min }, [subjectId]); } if (max !== undefined && ratio > max) { return makePredicateResult('false', { aspectRatio: ratio, max }, [subjectId]); } if (min !== undefined || max !== undefined) { return makePredicateResult('true', { aspectRatio: ratio, ...(min !== undefined ? { min } : {}), ...(max !== undefined ? { max } : {}) }, [subjectId]); } return makePredicateResult('indeterminate'); }, }; // --------------------------------------------------------------------------- // 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[20]!, 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[21]!, 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[22]!, 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[23]!, 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[24]!, 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[25]!, 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[26]!, evaluateTuple(world, tuple, options) { return insidePredicate.evaluateTuple(world, tuple, options); }, }; export const separatedFromPredicate: PredicateEvaluator = { descriptor: BUILTIN_PREDICATES[18]!, 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 = {}; 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: PredicateEvaluator = { descriptor: BUILTIN_PREDICATES[27]!, evaluateTuple(world, tuple, options) { return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record, axis: 'left' }); }, }; export const rightAlignedWithPredicate: PredicateEvaluator = { descriptor: BUILTIN_PREDICATES[28]!, evaluateTuple(world, tuple, options) { return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record, axis: 'right' }); }, }; export const topAlignedWithPredicate: PredicateEvaluator = { descriptor: BUILTIN_PREDICATES[29]!, evaluateTuple(world, tuple, options) { return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record, axis: 'top' }); }, }; export const bottomAlignedWithPredicate: PredicateEvaluator = { descriptor: BUILTIN_PREDICATES[30]!, evaluateTuple(world, tuple, options) { return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record, axis: 'bottom' }); }, }; export const intersectsPredicate: PredicateEvaluator = { descriptor: BUILTIN_PREDICATES[31]!, 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 metrics = rectOverlapMetrics(sRect, rRect); const pass = sRect.left <= rRect.right && sRect.right >= rRect.left && sRect.top <= rRect.bottom && sRect.bottom >= rRect.top; const diagnostics = pass ? undefined : makePredicateDiagnostic('intersects', metrics, [subjectId, referenceId]); return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics); }, }; export const touchesPredicate: PredicateEvaluator = { descriptor: BUILTIN_PREDICATES[32]!, 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 metrics = rectOverlapMetrics(sRect, rRect); const intersects = sRect.left <= rRect.right && sRect.right >= rRect.left && sRect.top <= rRect.bottom && sRect.bottom >= rRect.top; const overlapsArea = metrics.overlapArea > 0; const pass = intersects && !overlapsArea; const diagnostics = pass ? undefined : makePredicateDiagnostic('touches', metrics, [subjectId, referenceId]); return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics); }, }; export const hasGapPredicate: PredicateEvaluator = { descriptor: BUILTIN_PREDICATES[33]!, 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 metrics = rectOverlapMetrics(sRect, rRect); const minGap = (options?.minGap as number | undefined) ?? 0; const maxGap = (options?.maxGap as number | undefined) ?? Infinity; const separated = metrics.gap > 0; const pass = separated && metrics.gap >= minGap && metrics.gap <= maxGap; const resultMetrics = { ...metrics, minGap, maxGap }; const diagnostics = pass ? undefined : makePredicateDiagnostic('hasGap', resultMetrics, [subjectId, referenceId]); return makePredicateResult(pass ? 'true' : 'false', resultMetrics, [subjectId, referenceId], diagnostics); }, }; // --------------------------------------------------------------------------- // Register Defaults // --------------------------------------------------------------------------- /** Sentinel registered to detect if defaults were already installed. */ const DEFAULT_SENTINEL = '__imhotep_defaults_registered__' /** Populate a PredicateRegistry with all 33 built-in predicates (idempotent). */ export function populateDefaultPredicates(registry: PredicateRegistry): void { if (registry.get(DEFAULT_SENTINEL)) return registry.register({ descriptor: { name: DEFAULT_SENTINEL, arity: 0, domains: [], requiredFacts: [] }, evaluateTuple: () => ({ truth: 'indeterminate' }), }) registry.register(widthPredicate) registry.register(heightPredicate) registry.register(abovePredicate) registry.register(belowPredicate) registry.register(leftOfPredicate) registry.register(rightOfPredicate) registry.register(insidePredicate) registry.register(containsPredicate) registry.register(overlapsPredicate) registry.register(alignedWithPredicate) registry.register(centeredWithinPredicate) registry.register(atLeastPredicate) registry.register(atMostPredicate) registry.register(betweenPredicate) registry.register(clippedByPredicate) registry.register(attachedToScrollContainerPredicate) registry.register(escapeClippingChainOfPredicate) registry.register(aspectRatioPredicate) registry.register(inStackingContextPredicate) registry.register(separatedFromPredicate) registry.register(leftAlignedWithPredicate) registry.register(rightAlignedWithPredicate) registry.register(topAlignedWithPredicate) registry.register(bottomAlignedWithPredicate) registry.register(intersectsPredicate) registry.register(touchesPredicate) registry.register(hasGapPredicate) registry.register(besidePredicate) registry.register(nextToPredicate) registry.register(adjacentPredicate) registry.register(touchingPredicate) registry.register(nearPredicate) registry.register(underPredicate) registry.register(withinPredicate) } /** Create a fresh PredicateRegistry with all 33 built-in predicates pre-registered. */ export function createDefaultPredicateRegistry(): PredicateRegistry { const registry = new PredicateRegistry() populateDefaultPredicates(registry) return registry } export function registerDefaultPredicates(): void { populateDefaultPredicates(globalPredicateRegistry) }