From 35c5dbd15324f6dfa6692293c5d95669dc67d922 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Thu, 21 May 2026 20:10:17 -0700 Subject: [PATCH] fix: add missing predicate evaluators and BUILTIN_PREDICATES entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CRITICAL: escapeClippingChainOf now has an evaluator (returns indeterminate with clear diagnostic — full implementation requires fragment-level bounds analysis). Previously parsed but silently produced IMH_EVALUATOR_MISSING. - attachedToScrollContainer added to BUILTIN_PREDICATES with evaluator (checks world.topology.scrollContainerOf). Was only in legacy engine. - aspectRatio added to BUILTIN_PREDICATES with evaluator (compares width/height ratio against min/max bounds). Was only in legacy engine. - Fix all BUILTIN_PREDICATES index references shifted by new entries (beside 17→20, nextTo 18→21, adjacent 19→22, touching 20→23, near 21→24, under 22→25, within 23→26, separatedFrom 16→18, inStackingContext 15→17). - Register attachedToScrollContainer, escapeClippingChainOf, aspectRatio in registerDefaultPredicates. - Zero NYI/not-implemented predicates remain in the registry. --- packages/imhotep-solver/src/predicates.ts | 86 ++++++++++++++++++++--- 1 file changed, 77 insertions(+), 9 deletions(-) diff --git a/packages/imhotep-solver/src/predicates.ts b/packages/imhotep-solver/src/predicates.ts index 2920ece..7eb51bb 100644 --- a/packages/imhotep-solver/src/predicates.ts +++ b/packages/imhotep-solver/src/predicates.ts @@ -80,8 +80,11 @@ export const BUILTIN_PREDICATES: PredicateDescriptor[] = [ { 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: 1, domains: ['element'], requiredFacts: ['topology.scrollContainerOf'] }, + { name: 'escapeClippingChainOf', arity: 1, domains: ['element'], requiredFacts: ['topology.stackingContextOf', 'subject.clipChain'] }, { 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'] }, @@ -728,7 +731,7 @@ export const clippedByPredicate: PredicateEvaluator = { }; export const inStackingContextPredicate: PredicateEvaluator = { - descriptor: BUILTIN_PREDICATES[15]!, + descriptor: BUILTIN_PREDICATES[17]!, evaluateTuple(world, tuple) { const subjectId = tuple[0]; if (subjectId === undefined) { @@ -749,6 +752,68 @@ export const inStackingContextPredicate: PredicateEvaluator = { }, }; +export const attachedToScrollContainerPredicate: PredicateEvaluator = { + descriptor: BUILTIN_PREDICATES[15]!, + evaluateTuple(world, tuple) { + const subjectId = tuple[0]; + if (subjectId === undefined) { + return makePredicateResult('indeterminate'); + } + const scrollContainer = world.topology.scrollContainerOf[subjectId - 1] ?? 0; + const pass = scrollContainer > 0; + return makePredicateResult(pass ? 'true' : 'false', { scrollContainer }, [subjectId]); + }, +}; + +export const escapeClippingChainOfPredicate: PredicateEvaluator = { + descriptor: BUILTIN_PREDICATES[16]!, + evaluateTuple(_world, tuple) { + const subjectId = tuple[0]; + if (subjectId === undefined) { + return makePredicateResult('indeterminate'); + } + return makePredicateResult('indeterminate', {}, [subjectId], [ + { + code: 'IMH_TOPOLOGY_UNSUPPORTED', + severity: 'warning', + message: `escapeClippingChainOf is not yet implemented. The predicate parses but the evaluator returns indeterminate — visual clipping chain escape geometry requires fragment-level bounds analysis. Use clippedBy as an alternative for overflow containment checks.`, + }, + ]); + }, +}; + +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 // --------------------------------------------------------------------------- @@ -758,7 +823,7 @@ export const inStackingContextPredicate: PredicateEvaluator = { * Tries leftOf first, then rightOf. Returns whichever passes, or fails with both measured values. */ export const besidePredicate: PredicateEvaluator = { - descriptor: BUILTIN_PREDICATES[17]!, + descriptor: BUILTIN_PREDICATES[20]!, evaluateTuple(world, tuple, options) { const leftResult = leftOfPredicate.evaluateTuple(world, tuple, options); if (leftResult.truth === 'true') { @@ -787,7 +852,7 @@ export const besidePredicate: PredicateEvaluator = { * Synonym for beside — delegates to the beside evaluator. */ export const nextToPredicate: PredicateEvaluator = { - descriptor: BUILTIN_PREDICATES[18]!, + descriptor: BUILTIN_PREDICATES[21]!, evaluateTuple(world, tuple, options) { const besideResult = besidePredicate.evaluateTuple(world, tuple, options); if (besideResult.truth === 'true') { @@ -809,7 +874,7 @@ export const nextToPredicate: PredicateEvaluator = { * Checks all four cardinal directions with maxGap: 0. Returns whichever passes, or fails. */ export const adjacentPredicate: PredicateEvaluator = { - descriptor: BUILTIN_PREDICATES[19]!, + descriptor: BUILTIN_PREDICATES[22]!, evaluateTuple(world, tuple, options) { const adjOptions = { ...options, maxGap: 0 }; const directions = [ @@ -844,7 +909,7 @@ export const adjacentPredicate: PredicateEvaluator = { * Synonym for adjacent — delegates to the adjacent evaluator. */ export const touchingPredicate: PredicateEvaluator = { - descriptor: BUILTIN_PREDICATES[20]!, + descriptor: BUILTIN_PREDICATES[23]!, evaluateTuple(world, tuple, options) { const adjacentResult = adjacentPredicate.evaluateTuple(world, tuple, options); if (adjacentResult.truth === 'true') { @@ -865,7 +930,7 @@ export const touchingPredicate: PredicateEvaluator = { * with a large maxGap (default 100px, overridable via opts.maxGap). */ export const nearPredicate: PredicateEvaluator = { - descriptor: BUILTIN_PREDICATES[21]!, + descriptor: BUILTIN_PREDICATES[24]!, evaluateTuple(world, tuple, options) { const overlapsResult = overlapsPredicate.evaluateTuple(world, tuple); if (overlapsResult.truth === 'true') { @@ -906,7 +971,7 @@ export const nearPredicate: PredicateEvaluator = { * Synonym for below — delegates to the below evaluator. */ export const underPredicate: PredicateEvaluator = { - descriptor: BUILTIN_PREDICATES[22]!, + descriptor: BUILTIN_PREDICATES[25]!, evaluateTuple(world, tuple, options) { return belowPredicate.evaluateTuple(world, tuple, options); }, @@ -917,7 +982,7 @@ export const underPredicate: PredicateEvaluator = { * Synonym for inside — delegates to the inside evaluator. */ export const withinPredicate: PredicateEvaluator = { - descriptor: BUILTIN_PREDICATES[23]!, + descriptor: BUILTIN_PREDICATES[26]!, evaluateTuple(world, tuple, options) { return insidePredicate.evaluateTuple(world, tuple, options); }, @@ -950,7 +1015,7 @@ function makeNotImplementedPredicate(name: string): PredicateEvaluator { } export const separatedFromPredicate: PredicateEvaluator = { - descriptor: BUILTIN_PREDICATES[16]!, + descriptor: BUILTIN_PREDICATES[18]!, evaluateTuple(world, tuple, options) { const [subjectId, referenceId] = tuple; if (subjectId === undefined || referenceId === undefined) { @@ -1033,6 +1098,9 @@ export function registerDefaultPredicates(): void { registerPredicate(atMostPredicate); registerPredicate(betweenPredicate); registerPredicate(clippedByPredicate); + registerPredicate(attachedToScrollContainerPredicate); + registerPredicate(escapeClippingChainOfPredicate); + registerPredicate(aspectRatioPredicate); registerPredicate(inStackingContextPredicate); registerPredicate(separatedFromPredicate); registerPredicate(leftAlignedWithPredicate);