Files
Imhotep/packages/imhotep-solver/src/predicates.ts
T
John Dvorak 066ef9f677 refactor: remove global registry fallbacks — factory pattern for test isolation
predicates.ts: Add populateDefaultPredicates(registry) accepting any
  PredicateRegistry. Add createDefaultPredicateRegistry() factory.
  registerDefaultPredicates() now delegates to populateDefaultPredicates
  on the global (backward compatible).

logic-engine.ts: Replace globalPredicateRegistry fallback with
  createDefaultPredicateRegistry() factory. Each evaluateLogic() call
  creates a fresh self-populated registry unless one is explicitly
  injected. No shared mutable state between evaluations.

engine.ts: Same pattern for clauses — add populateDefaultClauses(registry),
  createDefaultClauseRegistry() factory. registerDefaultClauses() now
  delegates to populateDefaultClauses on the global. evaluate() replaces
  globalClauseRegistry fallback with createDefaultClauseRegistry().

registry.ts: @deprecated tag on registerClause with migration note.

Both global registries remain for backward compatibility via the
  deprecated registerDefault*() functions, but the evaluation engines
  no longer depend on them. Every evaluation gets its own registry by
  default, so custom predicates registered by one test cannot leak
  into another. Tests using explicit registry injection are unaffected.

662 tests pass (315 DSL + 141 core + 149 solver + 57 E2E).
2026-05-22 15:44:44 -07:00

1256 lines
52 KiB
TypeScript

/**
* 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: '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<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 ?? [];
}
}
/** @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<GeometryWorld, Map<number, number>>()
function getBorderRect(world: GeometryWorld, subjectId: number) {
const { boxes } = world;
let index = boxIndexCache.get(world);
if (!index) {
index = new Map<number, number>();
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<typeof getBorderRect> & {}, rRect: ReturnType<typeof getBorderRect> & {}) {
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<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 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<string, number> = {
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<string, number> = {
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<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 = 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<string, number> = {};
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<string, number> = {
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<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: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[27]!,
evaluateTuple(world, tuple, options) {
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'left' });
},
};
export const rightAlignedWithPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[28]!,
evaluateTuple(world, tuple, options) {
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'right' });
},
};
export const topAlignedWithPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[29]!,
evaluateTuple(world, tuple, options) {
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'top' });
},
};
export const bottomAlignedWithPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[30]!,
evaluateTuple(world, tuple, options) {
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, 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)
}