Files
Imhotep/packages/imhotep-solver/src/predicates.ts
T

1041 lines
41 KiB
TypeScript
Raw Normal View History

/**
* Predicate registry and evaluator contracts for the Imhotep logic engine.
*
* Predicates are the atomic propositions over which first-order logic
* formulas are built. Each predicate declares its name, arity, domain
* signature, and required geometry facts.
*
* Invariant: a predicate evaluator is a pure function from a geometry
* world and a tuple of bound subject ids to a PredicateTruth result.
*/
import type { DiagnosticCode } from 'imhotep-core'
import type {
GeometryWorld,
ClauseResult,
ClauseStatus,
TruthValue,
} from './registry.js';
// ---------------------------------------------------------------------------
// Predicate Truth
// ---------------------------------------------------------------------------
export type PredicateTruth = 'true' | 'false' | 'indeterminate';
export interface PredicateResult {
truth: PredicateTruth;
metrics?: Record<string, number>;
witness?: {
subjectIds: number[];
};
diagnostics?: Array<{
code: DiagnosticCode;
severity: 'error' | 'warning' | 'info';
message: string;
}>;
}
// ---------------------------------------------------------------------------
// Predicate Descriptor
// ---------------------------------------------------------------------------
export interface PredicateDescriptor {
name: string;
arity: number;
domains: string[];
requiredFacts: string[];
}
// ---------------------------------------------------------------------------
// Predicate Evaluator
// ---------------------------------------------------------------------------
export interface PredicateEvaluator {
descriptor: PredicateDescriptor;
evaluateTuple(
world: GeometryWorld,
tuple: number[],
options?: Record<string, unknown>,
): PredicateResult;
}
// ---------------------------------------------------------------------------
// Built-in Predicates
// ---------------------------------------------------------------------------
export const BUILTIN_PREDICATES: PredicateDescriptor[] = [
{ name: 'width', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
{ name: 'height', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
{ name: 'above', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'below', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'leftOf', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'rightOf', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'inside', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'contains', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'overlaps', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'alignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'centeredWithin', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'atLeast', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
{ name: 'atMost', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
{ name: 'between', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
{ name: 'clippedBy', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.clipChain', 'reference.clipChain'] },
{ name: 'inStackingContext', arity: 1, domains: ['element'], requiredFacts: ['topology.stackingContextOf'] },
{ name: 'separatedFrom', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
// Spatial alias predicates
{ name: 'beside', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'nextTo', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'adjacent', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'touching', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'near', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'under', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
{ name: 'within', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
];
// ---------------------------------------------------------------------------
// Predicate Registry
// ---------------------------------------------------------------------------
export class PredicateRegistry {
private registry = new Map<string, PredicateEvaluator>();
register(evaluator: PredicateEvaluator): void {
this.registry.set(evaluator.descriptor.name, evaluator);
}
get(name: string): PredicateEvaluator | undefined {
return this.registry.get(name);
}
list(): string[] {
return Array.from(this.registry.keys());
}
clear(): void {
this.registry.clear();
}
getDescriptor(name: string): PredicateDescriptor | undefined {
return this.registry.get(name)?.descriptor;
}
getRequiredFacts(name: string): string[] {
return this.getDescriptor(name)?.requiredFacts ?? [];
}
}
// Global default instance for backward compatibility.
export const globalPredicateRegistry = new PredicateRegistry();
export function registerPredicate(evaluator: PredicateEvaluator): void {
globalPredicateRegistry.register(evaluator);
}
export function getPredicateEvaluator(name: string): PredicateEvaluator | undefined {
return globalPredicateRegistry.get(name);
}
export function listRegisteredPredicates(): string[] {
return globalPredicateRegistry.list();
}
export function clearPredicateRegistry(): void {
globalPredicateRegistry.clear();
defaultPredicatesRegistered = false;
}
export function getPredicateDescriptor(name: string): PredicateDescriptor | undefined {
return globalPredicateRegistry.getDescriptor(name);
}
export function getRequiredFactsForPredicate(name: string): string[] {
return globalPredicateRegistry.getRequiredFacts(name);
}
// ---------------------------------------------------------------------------
// Built-in Predicate Evaluators
// ---------------------------------------------------------------------------
function getBorderRect(world: GeometryWorld, subjectId: number) {
const { boxes } = world;
// Build a subjectId -> boxIndex map on first use for O(1) lookups.
// This avoids O(n) linear scans on every predicate evaluation.
let index = (world as any).__boxIndex as Map<number, number> | undefined;
if (!index) {
index = new Map<number, number>();
for (let i = 0; i < boxes.subjectId.length; i++) {
index.set(boxes.subjectId[i], i);
}
(world as any).__boxIndex = index;
}
const i = index.get(subjectId);
if (i === undefined) {
return null;
}
return {
left: boxes.borderLeft[i],
top: boxes.borderTop[i],
right: boxes.borderRight[i],
bottom: boxes.borderBottom[i],
};
}
function makePredicateResult(
truth: PredicateTruth,
metrics?: Record<string, number>,
witness?: number[],
diagnostics?: PredicateResult['diagnostics'],
): PredicateResult {
return {
truth,
metrics,
witness: witness ? { subjectIds: witness } : undefined,
diagnostics,
};
}
/**
* Build a diagnostic for a failing predicate that includes observed values.
*/
function makePredicateDiagnostic(
predicate: string,
metrics: Record<string, number>,
subjectIds: number[],
): PredicateResult['diagnostics'] {
const entries = Object.entries(metrics)
.map(([k, v]) => `${k}=${typeof v === 'number' ? v.toFixed(2) : String(v)}`)
.join(', ');
return [
{
code: 'IMH_PREDICATE_FAILED',
severity: 'error',
message: `Predicate "${predicate}" failed for subjects [${subjectIds.join(', ')}]. Observed: { ${entries} }`,
},
];
}
function parseLengthOption(raw: unknown): { value: number; unit: string } | null {
if (typeof raw === 'number') {
return { value: raw, unit: 'px' };
}
if (typeof raw === 'string') {
const trimmed = raw.trim();
const m = trimmed.match(/^(-?\d*\.?\d+)\s*([a-z%]+)?$/i);
if (!m) return null;
const value = Number.parseFloat(m[1] ?? '');
if (Number.isNaN(value)) return null;
return { value, unit: (m[2] ?? 'px').toLowerCase() };
}
if (raw && typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
if (typeof obj.value === 'number') {
return {
value: obj.value,
unit: typeof obj.unit === 'string' ? obj.unit.toLowerCase() : 'px',
};
}
}
return null;
}
function getSubjectFontSizePx(world: GeometryWorld, subjectId: number): number {
const styles = (world as any).styles as { fontSize?: ArrayLike<number> } | undefined;
const fontSize = styles?.fontSize;
if (!fontSize) return 16;
const ids = world.subjects?.ids;
if (!ids) return 16;
const idx = ids.indexOf(subjectId);
if (idx < 0) return 16;
const fs = Number(fontSize[idx] ?? 16);
return Number.isFinite(fs) && fs > 0 ? fs : 16;
}
function getRootFontSizePx(world: GeometryWorld): number {
const styles = (world as any).styles as { fontSize?: ArrayLike<number> } | undefined;
const fontSize = styles?.fontSize;
if (!fontSize || fontSize.length === 0) return 16;
const fs = Number(fontSize[0] ?? 16);
return Number.isFinite(fs) && fs > 0 ? fs : 16;
}
function getSubjectChWidthPx(world: GeometryWorld, subjectId: number): number {
const styles = (world as any).styles as {
subjectId?: ArrayLike<number>
chWidth?: ArrayLike<number>
} | undefined;
const chWidth = styles?.chWidth;
if (!chWidth || chWidth.length === 0) {
return getSubjectFontSizePx(world, subjectId) * 0.5;
}
const styleSubjectIds = styles?.subjectId;
if (styleSubjectIds && styleSubjectIds.length > 0) {
for (let i = 0; i < styleSubjectIds.length; i++) {
if (Number(styleSubjectIds[i]) !== subjectId) continue;
const w = Number(chWidth[i]);
if (Number.isFinite(w) && w > 0) return w;
break;
}
}
const ids = world.subjects?.ids;
if (ids) {
const idx = ids.indexOf(subjectId);
if (idx >= 0) {
const w = Number(chWidth[idx]);
if (Number.isFinite(w) && w > 0) return w;
}
}
return getSubjectFontSizePx(world, subjectId) * 0.5;
}
function resolveLengthToPx(
world: GeometryWorld,
subjectId: number,
raw: unknown,
dimension: 'width' | 'height',
): number | undefined {
const parsed = parseLengthOption(raw);
if (!parsed) return undefined;
const { value, unit } = parsed;
switch (unit) {
case 'px':
return value;
case 'vw':
return value * (world.env.viewportWidth / 100);
case 'vh':
return value * (world.env.viewportHeight / 100);
case '%':
return value * ((dimension === 'height' ? world.env.viewportHeight : world.env.viewportWidth) / 100);
case 'rem':
return value * getRootFontSizePx(world);
case 'em':
return value * getSubjectFontSizePx(world, subjectId);
case 'ch':
return value * getSubjectChWidthPx(world, subjectId);
default:
return value;
}
}
export const widthPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[0]!,
evaluateTuple(world, tuple) {
const subjectId = tuple[0];
if (subjectId === undefined) {
return makePredicateResult('indeterminate');
}
const rect = getBorderRect(world, subjectId);
if (!rect) {
return makePredicateResult('indeterminate');
}
const w = rect.right - rect.left;
return makePredicateResult('true', { width: w, left: rect.left, right: rect.right }, [subjectId]);
},
};
export const heightPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[1]!,
evaluateTuple(world, tuple) {
const subjectId = tuple[0];
if (subjectId === undefined) {
return makePredicateResult('indeterminate');
}
const rect = getBorderRect(world, subjectId);
if (!rect) {
return makePredicateResult('indeterminate');
}
const h = rect.bottom - rect.top;
return makePredicateResult('true', { height: h }, [subjectId]);
},
};
export const abovePredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[2]!,
evaluateTuple(world, tuple, options) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
const gap = rRect.top - sRect.bottom;
const minGap = (options?.minGap as number | undefined) ?? 0;
const maxGap = (options?.maxGap as number | undefined) ?? Infinity;
const pass = gap >= minGap && gap <= maxGap;
const metrics = { gap, observedGap: gap, minGap, maxGap, subjectBottom: sRect.bottom, refTop: rRect.top };
const diagnostics = pass ? undefined : makePredicateDiagnostic('above', metrics, [subjectId, referenceId]);
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
},
};
export const belowPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[3]!,
evaluateTuple(world, tuple, options) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
const gap = sRect.top - rRect.bottom;
const minGap = (options?.minGap as number | undefined) ?? 0;
const maxGap = (options?.maxGap as number | undefined) ?? Infinity;
const pass = gap >= minGap && gap <= maxGap;
const metrics = { gap, observedGap: gap, minGap, maxGap };
const diagnostics = pass ? undefined : makePredicateDiagnostic('below', metrics, [subjectId, referenceId]);
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
},
};
export const leftOfPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[4]!,
evaluateTuple(world, tuple, options) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
const gap = rRect.left - sRect.right;
const minGap = (options?.minGap as number | undefined) ?? 0;
const maxGap = (options?.maxGap as number | undefined) ?? Infinity;
const pass = gap >= minGap && gap <= maxGap;
const metrics = { gap, observedGap: gap, minGap, maxGap, subjectRight: sRect.right, refLeft: rRect.left };
const diagnostics = pass ? undefined : makePredicateDiagnostic('leftOf', metrics, [subjectId, referenceId]);
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
},
};
export const rightOfPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[5]!,
evaluateTuple(world, tuple, options) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
const gap = sRect.left - rRect.right;
const minGap = (options?.minGap as number | undefined) ?? 0;
const maxGap = (options?.maxGap as number | undefined) ?? Infinity;
const pass = gap >= minGap && gap <= maxGap;
const metrics = { gap, observedGap: gap, minGap, maxGap };
const diagnostics = pass ? undefined : makePredicateDiagnostic('rightOf', metrics, [subjectId, referenceId]);
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
},
};
export const insidePredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[6]!,
evaluateTuple(world, tuple, options) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
const tolerance = (options?.tolerance as number | undefined) ?? 0;
const epsilon = (options?.epsilon as number | undefined) ?? 0.1;
const effectiveTolerance = tolerance + epsilon;
const pass =
sRect.left >= rRect.left - effectiveTolerance &&
sRect.top >= rRect.top - effectiveTolerance &&
sRect.right <= rRect.right + effectiveTolerance &&
sRect.bottom <= rRect.bottom + effectiveTolerance;
const metrics: 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 = world.topology.clippingRootOf[subjectId - 1] ?? 0;
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[15]!,
evaluateTuple(world, tuple) {
const subjectId = tuple[0];
if (subjectId === undefined) {
return makePredicateResult('indeterminate');
}
const sc = world.topology.stackingContextOf[subjectId - 1] ?? 0;
const pass = sc > 0;
return makePredicateResult(pass ? 'true' : 'false', { stackingContext: sc }, [subjectId]);
},
};
// ---------------------------------------------------------------------------
// Spatial Alias Predicate Evaluators
// ---------------------------------------------------------------------------
/**
* beside(subject, opts) = leftOf(subject, opts) OR rightOf(subject, opts)
* Tries leftOf first, then rightOf. Returns whichever passes, or fails with both measured values.
*/
export const besidePredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[17]!,
evaluateTuple(world, tuple, options) {
const leftResult = leftOfPredicate.evaluateTuple(world, tuple, options);
if (leftResult.truth === 'true') {
return makePredicateResult('true', { ...leftResult.metrics, direction: 'left' } as any, tuple);
}
const rightResult = rightOfPredicate.evaluateTuple(world, tuple, options);
if (rightResult.truth === 'true') {
return makePredicateResult('true', { ...rightResult.metrics, direction: 'right' } as any, tuple);
}
const metrics = {
leftGap: leftResult.metrics?.gap ?? NaN,
rightGap: rightResult.metrics?.gap ?? NaN,
};
return makePredicateResult('false', metrics, tuple, [
{
code: 'IMH_RELATION_BESIDE_FAILED',
severity: 'error',
message: `Predicate "beside" failed: neither leftOf (gap=${(metrics.leftGap as number).toFixed(2)}) nor rightOf (gap=${(metrics.rightGap as number).toFixed(2)}) satisfied.`,
},
]);
},
};
/**
* nextTo(subject, opts) = beside(subject, opts)
* Synonym for beside — delegates to the beside evaluator.
*/
export const nextToPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[18]!,
evaluateTuple(world, tuple, options) {
const besideResult = besidePredicate.evaluateTuple(world, tuple, options);
if (besideResult.truth === 'true') {
return makePredicateResult('true', { ...besideResult.metrics, alias: 'nextTo' } as any, tuple);
}
// Rewrite diagnostic to mention nextTo
const diagnostics = besideResult.diagnostics?.map((d) => ({
...d,
code: 'IMH_RELATION_BESIDE_FAILED' as const,
message: d.message.replace('"beside"', '"nextTo"'),
}));
return makePredicateResult('false', besideResult.metrics ?? {}, tuple, diagnostics);
},
};
/**
* adjacent(subject, opts) = leftOr(subject, { maxGap: 0 }) OR rightOf(subject, { maxGap: 0 })
* OR above(subject, { maxGap: 0 }) OR below(subject, { maxGap: 0 })
* Checks all four cardinal directions with maxGap: 0. Returns whichever passes, or fails.
*/
export const adjacentPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[19]!,
evaluateTuple(world, tuple, options) {
const adjOptions = { ...options, maxGap: 0 };
const directions = [
{ name: 'leftOf', result: leftOfPredicate.evaluateTuple(world, tuple, adjOptions) },
{ name: 'rightOf', result: rightOfPredicate.evaluateTuple(world, tuple, adjOptions) },
{ name: 'above', result: abovePredicate.evaluateTuple(world, tuple, adjOptions) },
{ name: 'below', result: belowPredicate.evaluateTuple(world, tuple, adjOptions) },
];
for (const dir of directions) {
if (dir.result.truth === 'true') {
return makePredicateResult('true', { ...dir.result.metrics, direction: dir.name } as any, tuple);
}
}
const metrics = {
leftGap: directions[0].result.metrics?.gap ?? NaN,
rightGap: directions[1].result.metrics?.gap ?? NaN,
aboveGap: directions[2].result.metrics?.gap ?? NaN,
belowGap: directions[3].result.metrics?.gap ?? NaN,
};
return makePredicateResult('false', metrics, tuple, [
{
code: 'IMH_RELATION_ADJACENT_FAILED',
severity: 'error',
message: `Predicate "adjacent" failed: no direction satisfied with maxGap=0.`,
},
]);
},
};
/**
* touching(subject, opts) = adjacent(subject, opts)
* Synonym for adjacent — delegates to the adjacent evaluator.
*/
export const touchingPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[20]!,
evaluateTuple(world, tuple, options) {
const adjacentResult = adjacentPredicate.evaluateTuple(world, tuple, options);
if (adjacentResult.truth === 'true') {
return makePredicateResult('true', { ...adjacentResult.metrics, alias: 'touching' } as any, tuple);
}
const diagnostics = adjacentResult.diagnostics?.map((d) => ({
...d,
code: 'IMH_RELATION_ADJACENT_FAILED' as const,
message: d.message.replace('"adjacent"', '"touching"'),
}));
return makePredicateResult('false', adjacentResult.metrics ?? {}, tuple, diagnostics);
},
};
/**
* near(subject, opts) = overlaps(subject) OR inside a proximity radius
* First checks overlaps. If that fails, checks directional proximity using leftOf/rightOf/above/below
* with a large maxGap (default 100px, overridable via opts.maxGap).
*/
export const nearPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[21]!,
evaluateTuple(world, tuple, options) {
const overlapsResult = overlapsPredicate.evaluateTuple(world, tuple);
if (overlapsResult.truth === 'true') {
return makePredicateResult('true', { ...overlapsResult.metrics, mode: 'overlaps' } as any, tuple);
}
const radius = (options?.maxGap as number | undefined) ?? 100;
const proximityOptions = { ...options, maxGap: radius };
const directions = [
{ name: 'leftOf', result: leftOfPredicate.evaluateTuple(world, tuple, proximityOptions) },
{ name: 'rightOf', result: rightOfPredicate.evaluateTuple(world, tuple, proximityOptions) },
{ name: 'above', result: abovePredicate.evaluateTuple(world, tuple, proximityOptions) },
{ name: 'below', result: belowPredicate.evaluateTuple(world, tuple, proximityOptions) },
];
for (const dir of directions) {
if (dir.result.truth === 'true') {
return makePredicateResult('true', { ...dir.result.metrics, direction: dir.name, mode: 'proximity', radius } as any, tuple);
}
}
const metrics = {
radius,
leftGap: directions[0].result.metrics?.gap ?? NaN,
rightGap: directions[1].result.metrics?.gap ?? NaN,
aboveGap: directions[2].result.metrics?.gap ?? NaN,
belowGap: directions[3].result.metrics?.gap ?? NaN,
};
return makePredicateResult('false', metrics, tuple, [
{
code: 'IMH_RELATION_NEAR_FAILED',
severity: 'error',
message: `Predicate "near" failed: elements neither overlap nor are within proximity radius ${radius}.`,
},
]);
},
};
/**
* under(subject, opts) = below(subject, opts)
* Synonym for below — delegates to the below evaluator.
*/
export const underPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[22]!,
evaluateTuple(world, tuple, options) {
return belowPredicate.evaluateTuple(world, tuple, options);
},
};
/**
* within(subject, opts) = inside(subject, opts)
* Synonym for inside — delegates to the inside evaluator.
*/
export const withinPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[23]!,
evaluateTuple(world, tuple, options) {
return insidePredicate.evaluateTuple(world, tuple, options);
},
};
// ---------------------------------------------------------------------------
// Not-Yet-Implemented Predicates
// ---------------------------------------------------------------------------
function makeNotImplementedPredicate(name: string): PredicateEvaluator {
return {
descriptor: {
name,
arity: 2,
domains: ['element', 'element'],
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
},
evaluateTuple(_world, tuple) {
const subjectId = tuple[0];
const referenceId = tuple[1];
return makePredicateResult('false', {}, [subjectId, referenceId].filter((id): id is number => id !== undefined), [
{
code: 'IMH_FEATURE_NOT_YET_IMPLEMENTED',
severity: 'error',
message: `Predicate "${name}" is not yet implemented.`,
},
]);
},
};
}
export const separatedFromPredicate: PredicateEvaluator = {
descriptor: BUILTIN_PREDICATES[16]!,
evaluateTuple(world, tuple, options) {
const [subjectId, referenceId] = tuple;
if (subjectId === undefined || referenceId === undefined) {
return makePredicateResult('indeterminate');
}
const sRect = getBorderRect(world, subjectId);
const rRect = getBorderRect(world, referenceId);
if (!sRect || !rRect) {
return makePredicateResult('indeterminate');
}
// separatedFrom = NOT overlaps
const overlaps =
sRect.left < rRect.right &&
sRect.right > rRect.left &&
sRect.top < rRect.bottom &&
sRect.bottom > rRect.top;
const pass = !overlaps;
const metrics: Record<string, number> = {};
if (!pass) {
// Calculate overlap area for diagnostics
const overlapLeft = Math.max(sRect.left, rRect.left);
const overlapTop = Math.max(sRect.top, rRect.top);
const overlapRight = Math.min(sRect.right, rRect.right);
const overlapBottom = Math.min(sRect.bottom, rRect.bottom);
metrics.overlapWidth = overlapRight - overlapLeft;
metrics.overlapHeight = overlapBottom - overlapTop;
}
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId]);
},
};
export const leftAlignedWithPredicate: PredicateEvaluator = {
descriptor: { name: 'leftAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
evaluateTuple(world, tuple, options) {
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'left' });
},
};
export const rightAlignedWithPredicate: PredicateEvaluator = {
descriptor: { name: 'rightAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
evaluateTuple(world, tuple, options) {
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'right' });
},
};
export const topAlignedWithPredicate: PredicateEvaluator = {
descriptor: { name: 'topAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
evaluateTuple(world, tuple, options) {
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'top' });
},
};
export const bottomAlignedWithPredicate: PredicateEvaluator = {
descriptor: { name: 'bottomAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
evaluateTuple(world, tuple, options) {
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'bottom' });
},
};
// ---------------------------------------------------------------------------
// Register Defaults
// ---------------------------------------------------------------------------
let defaultPredicatesRegistered = false;
export function registerDefaultPredicates(): void {
if (defaultPredicatesRegistered) return;
defaultPredicatesRegistered = true;
registerPredicate(widthPredicate);
registerPredicate(heightPredicate);
registerPredicate(abovePredicate);
registerPredicate(belowPredicate);
registerPredicate(leftOfPredicate);
registerPredicate(rightOfPredicate);
registerPredicate(insidePredicate);
registerPredicate(containsPredicate);
registerPredicate(overlapsPredicate);
registerPredicate(alignedWithPredicate);
registerPredicate(centeredWithinPredicate);
registerPredicate(atLeastPredicate);
registerPredicate(atMostPredicate);
registerPredicate(betweenPredicate);
registerPredicate(clippedByPredicate);
registerPredicate(inStackingContextPredicate);
registerPredicate(separatedFromPredicate);
registerPredicate(leftAlignedWithPredicate);
registerPredicate(rightAlignedWithPredicate);
registerPredicate(topAlignedWithPredicate);
registerPredicate(bottomAlignedWithPredicate);
registerPredicate(besidePredicate);
registerPredicate(nextToPredicate);
registerPredicate(adjacentPredicate);
registerPredicate(touchingPredicate);
registerPredicate(nearPredicate);
registerPredicate(underPredicate);
registerPredicate(withinPredicate);
}