From 1bc92e1f7dcd01f06e73b99037d01bee647eefe1 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Fri, 22 May 2026 10:20:06 -0700 Subject: [PATCH] fix: CDP extraction concurrency + topology ID remapping + predicate completeness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CDP protocol fixes: - session.ts: getDocumentRootNodeId() now pushes {depth:-1} full DOM tree (was shallow default, causing querySelectorAll to find zero nodes) - extraction.ts: serialize resolveSelector calls (was concurrent Promise.all, CDP DOM.querySelectorAll is not safe for concurrent calls on same session) Topology ID mismatch fixes (critical — backendNodeId vs solver subject ID): - extraction.ts: add remapTopologyIds() — builds Map from world.subjects.domNodeId/ids, remaps all 6 topology arrays + clipping.subjectId + scroll.containerId - topology.ts: createsStackingContext() now checks contain:paint|layout (both create stacking contexts per CSS spec, was missing) - topology.ts: evaluateInStackingContext() handles unary (no reference) Predicate completeness: - predicates.ts: 3 new evaluators — intersects, touches, hasGap (indices 31-33) - predicates.ts: escapeClippingChainOf replaced indeterminate stub with deterministic 2-ary evaluator (overflow + clip chain + ancestry checks) - predicates.ts: attachedToScrollContainer arity 1→2, checks reference match - predicates.ts: 7 aliases + 4 alignment aliases promoted to BUILTIN_PREDICATES DSL grammar fixes: - grammar.ts: isKeywordThatCanBePredicate() extended with all missing keywords - lower-to-canonical.ts: add TopologyAssertion lowering (was silently dropped) - compiler.ts: add intersects/touches/hasGap relation codes - validator.ts: add option sets for new predicates - lexer.ts: add intersects/touches/hasGap token kinds Safety + infrastructure: - joins.ts: MAX_CARTESIAN_TUPLES=100,000 guard with descriptive error - extraction.ts: computeRequiredFacts() registers defaults + broadens topology gate - package.json: add missing imhotep-state dependency to imhotep-playwright Tests: - 9 new predicate tests (intersects, touches, hasGap, escapeClippingChainOf) - Convert stale separatedFrom/contains GAP tests to working assertions - Add computeRequiredFacts topology gating tests (public.test.ts) --- packages/imhotep-cdp/src/session.ts | 2 +- packages/imhotep-cdp/src/topology.ts | 2 + packages/imhotep-dsl/src/compiler.ts | 3 + .../src/fol-dense-combinations.test.ts | 16 +- packages/imhotep-dsl/src/grammar.ts | 9 +- packages/imhotep-dsl/src/lexer.ts | 6 + .../imhotep-dsl/src/lower-to-canonical.ts | 72 ++++++- packages/imhotep-dsl/src/validator.ts | 9 + packages/imhotep-playwright/package.json | 1 + packages/imhotep-playwright/src/extraction.ts | 51 ++++- .../imhotep-playwright/src/public.test.ts | 31 ++- packages/imhotep-solver/src/joins.ts | 9 + .../imhotep-solver/src/predicates.test.ts | 85 ++++++++ packages/imhotep-solver/src/predicates.ts | 195 +++++++++++++++--- packages/imhotep-solver/src/topology.ts | 14 +- 15 files changed, 458 insertions(+), 47 deletions(-) diff --git a/packages/imhotep-cdp/src/session.ts b/packages/imhotep-cdp/src/session.ts index db2343c..f35bfab 100644 --- a/packages/imhotep-cdp/src/session.ts +++ b/packages/imhotep-cdp/src/session.ts @@ -91,7 +91,7 @@ export class CDPSessionManager { return this.documentRootNodeId } const s = await this.attach() - const result = await s.send<{ root: { nodeId: number } }>('DOM.getDocument') + const result = await s.send<{ root: { nodeId: number } }>('DOM.getDocument', { depth: -1 }) this.documentRootNodeId = result.root.nodeId return this.documentRootNodeId } diff --git a/packages/imhotep-cdp/src/topology.ts b/packages/imhotep-cdp/src/topology.ts index e8d8341..dbe2d79 100644 --- a/packages/imhotep-cdp/src/topology.ts +++ b/packages/imhotep-cdp/src/topology.ts @@ -171,6 +171,8 @@ export async function extractTopology( if (style.clipPath !== 'none') return true if (style.isolation === 'isolate') return true if (style.willChange.includes('transform') || style.willChange.includes('opacity')) return true + const contain = style.contain + if (contain !== 'none' && (contain.includes('paint') || contain.includes('layout'))) return true return false } diff --git a/packages/imhotep-dsl/src/compiler.ts b/packages/imhotep-dsl/src/compiler.ts index 47212dc..980eb69 100644 --- a/packages/imhotep-dsl/src/compiler.ts +++ b/packages/imhotep-dsl/src/compiler.ts @@ -379,6 +379,9 @@ function toExecutionIr(semanticIr: SemanticIr): ExecutionIr { separatedFrom: 14, aspectRatioBetween: 15, between: 16, + intersects: 17, + touches: 18, + hasGap: 19, } const clauseType = new Uint16Array(count) diff --git a/packages/imhotep-dsl/src/fol-dense-combinations.test.ts b/packages/imhotep-dsl/src/fol-dense-combinations.test.ts index e03fb2e..79f2ca2 100644 --- a/packages/imhotep-dsl/src/fol-dense-combinations.test.ts +++ b/packages/imhotep-dsl/src/fol-dense-combinations.test.ts @@ -445,21 +445,19 @@ describe('FOL Dense DSL - Gap Detection', () => { 'Expected parse failure or no formula for between in dense DSL') }) - it('GAP: dense DSL does not support separatedFrom relation', () => { + it('separatedFrom parses as relation predicate in dense DSL', () => { const source = `forall $a in elements('.a'):\n separatedFrom($a, '.b')` const result = parseSpec(source) - // separatedFrom may not be in keyword list - assert.ok(result.diagnostics.length >= 0) + assert.strictEqual(result.diagnostics.length, 0, `Expected no diagnostics but got: ${result.diagnostics.map(d => d.message).join(', ')}`) + const formula = getFormula(result.ast) + assert.ok(formula, 'Expected formula for separatedFrom in dense DSL') }) - it('GAP: dense DSL does not support contains relation', () => { - // contains exists in fluent but may not parse in dense + it('contains parses as relation predicate in dense DSL', () => { const source = `forall $a in elements('.a'):\n contains($a, '.b')` const result = parseSpec(source) - // This might parse as generic predicate + assert.strictEqual(result.diagnostics.length, 0, `Expected no diagnostics but got: ${result.diagnostics.map(d => d.message).join(', ')}`) const formula = getFormula(result.ast) - if (formula) { - assert.ok(true, 'contains parsed as generic predicate') - } + assert.ok(formula, 'Expected formula for contains in dense DSL') }) }) diff --git a/packages/imhotep-dsl/src/grammar.ts b/packages/imhotep-dsl/src/grammar.ts index dc62c61..98fdff6 100644 --- a/packages/imhotep-dsl/src/grammar.ts +++ b/packages/imhotep-dsl/src/grammar.ts @@ -660,7 +660,10 @@ export class GrammarParser { const kind = this.currentToken().kind const predicateKinds: Token['kind'][] = [ 'leftOf', 'rightOf', 'above', 'below', - 'alignedWith', 'centeredWithin', 'inside', 'contains', 'overlaps', 'separatedFrom', + 'alignedWith', 'leftAlignedWith', 'rightAlignedWith', 'topAlignedWith', 'bottomAlignedWith', + 'centeredWithin', 'inside', 'contains', 'overlaps', 'intersects', 'touches', 'separatedFrom', 'hasGap', + // Spatial aliases + 'beside', 'nextTo', 'adjacent', 'touching', 'near', 'under', 'within', // Size predicates that can appear in FOL formula bodies 'width', 'height', 'size', ] @@ -1140,7 +1143,7 @@ export class GrammarParser { const relationKinds: Array = [ 'leftOf', 'rightOf', 'above', 'below', 'alignedWith', 'leftAlignedWith', 'rightAlignedWith', 'topAlignedWith', 'bottomAlignedWith', - 'centeredWithin', 'inside', 'contains', 'overlaps', 'separatedFrom', + 'centeredWithin', 'inside', 'contains', 'overlaps', 'intersects', 'touches', 'separatedFrom', 'hasGap', // Spatial aliases 'beside', 'nextTo', 'adjacent', 'touching', 'near', 'under', 'within', ] @@ -1151,7 +1154,7 @@ export class GrammarParser { } } - throw this.error(`Expected relation (leftOf, rightOf, above, below, alignedWith, centeredWithin, inside, contains, overlaps, separatedFrom, beside, nextTo, adjacent, touching, near, under, within)`) + throw this.error(`Expected relation (leftOf, rightOf, above, below, alignedWith, centeredWithin, inside, contains, overlaps, intersects, touches, separatedFrom, hasGap, beside, nextTo, adjacent, touching, near, under, within)`) } // ------------------------------------------------------------------------- diff --git a/packages/imhotep-dsl/src/lexer.ts b/packages/imhotep-dsl/src/lexer.ts index 356aa72..2f21897 100644 --- a/packages/imhotep-dsl/src/lexer.ts +++ b/packages/imhotep-dsl/src/lexer.ts @@ -61,7 +61,10 @@ export type TokenKind = | 'inside' | 'contains' | 'overlaps' + | 'intersects' + | 'touches' | 'separatedFrom' + | 'hasGap' | 'leftAlignedWith' | 'rightAlignedWith' | 'topAlignedWith' @@ -193,7 +196,10 @@ const KEYWORDS: Record = { inside: 'inside', contains: 'contains', overlaps: 'overlaps', + intersects: 'intersects', + touches: 'touches', separatedFrom: 'separatedFrom', + hasGap: 'hasGap', leftAlignedWith: 'leftAlignedWith', rightAlignedWith: 'rightAlignedWith', topAlignedWith: 'topAlignedWith', diff --git a/packages/imhotep-dsl/src/lower-to-canonical.ts b/packages/imhotep-dsl/src/lower-to-canonical.ts index 075c613..81e4d81 100644 --- a/packages/imhotep-dsl/src/lower-to-canonical.ts +++ b/packages/imhotep-dsl/src/lower-to-canonical.ts @@ -17,7 +17,7 @@ // kind='custom', selector='selector'. These are fundamentally divergent. // Property tests exclude custom domains from FOL equivalence. -import type { RelationAssertion, Program, Position } from 'imhotep-core' +import type { RelationAssertion, TopologyAssertion, Program, Position } from 'imhotep-core' import type { FormulaNode as DslFormulaNode } from './logic-fluent.js' // --------------------------------------------------------------------------- @@ -243,6 +243,17 @@ function lowerBlock( return } + // Topology assertion: clippedBy, attachedToScrollContainer, escapeClippingChainOf, inStackingContext + if (node.type === 'TopologyAssertion') { + const desc = lowerTopologyAssertionToCanonical(node, context) + if (compoundOperator && nextGroupId) { + desc.compoundOperator = compoundOperator + desc.compoundGroupId = nextGroupId() + } + out.push(desc) + return + } + // Quantifier wrapper or logical compound: propagate to children if (node.type === 'CompoundAssertion') { const quantifier = node.quantifier?.kind as 'all' | 'any' | 'none' | undefined @@ -520,6 +531,65 @@ function lowerSizeAssertionToCanonical( return descriptor } +function lowerTopologyAssertionToCanonical( + assertion: TopologyAssertion, + context: Partial, +): CanonicalClauseDescriptor { + const descriptor: CanonicalClauseDescriptor = { + relation: assertion.predicate, + subject: assertion.subject.value, + flags: 0, + } + if (assertion.reference?.value) { + descriptor.reference = assertion.reference.value + } + + if (assertion.position) { + descriptor.position = assertion.position + } + + if (context.quantifier) { + descriptor.quantifier = context.quantifier + } + + const rawOpts = assertion.options as unknown as Record | undefined + if (rawOpts) { + if (rawOpts.tolerance !== undefined) { + const tol = extractTolerance(rawOpts.tolerance) + if (tol) descriptor.tolerance = tol + } + + const bounds: { minGap?: number; maxGap?: number } = {} + if (rawOpts.minGap !== undefined) { + const parsed = extractGap(rawOpts.minGap) + if (parsed) bounds.minGap = parsed.value + } + if (rawOpts.maxGap !== undefined) { + const parsed = extractGap(rawOpts.maxGap) + if (parsed) bounds.maxGap = parsed.value + } + if (Object.keys(bounds).length > 0) { + descriptor.bounds = bounds + } + + const options: Record = {} + if (rawOpts.axis !== undefined) options.axis = rawOpts.axis + if (rawOpts.dimension !== undefined) options.dimension = rawOpts.dimension + if (rawOpts.inStackingContext === true) { + options.inStackingContext = true + descriptor.flags |= 8 + } + if (Object.keys(options).length > 0) { + descriptor.options = options + } + } + + const astNegated = (assertion as any).negated as boolean | undefined + if (astNegated) descriptor.negated = true + + return descriptor +} + // --------------------------------------------------------------------------- // Equivalence // --------------------------------------------------------------------------- diff --git a/packages/imhotep-dsl/src/validator.ts b/packages/imhotep-dsl/src/validator.ts index bd3b2b6..8774426 100644 --- a/packages/imhotep-dsl/src/validator.ts +++ b/packages/imhotep-dsl/src/validator.ts @@ -33,7 +33,10 @@ const SPATIAL_RELATIONS = new Set([ 'inside', 'contains', 'overlaps', + 'intersects', + 'touches', 'separatedFrom', + 'hasGap', // Spatial aliases 'beside', 'nextTo', @@ -73,7 +76,10 @@ const VALID_OPTIONS: Record> = { inside: new Set(['tolerance', 'minGap', 'space']), contains: new Set(['tolerance', 'space']), overlaps: new Set(['tolerance', 'space']), + intersects: new Set(['tolerance', 'space']), + touches: new Set(['tolerance', 'inStackingContext', 'space']), separatedFrom: new Set(['minGap', 'maxGap', 'tolerance', 'space']), + hasGap: new Set(['minGap', 'maxGap', 'tolerance', 'space']), // Spatial alias options beside: new Set(['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space']), nextTo: new Set(['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space']), @@ -101,7 +107,10 @@ const QUANTIFIER_COMPATIBLE = new Set([ 'inside', 'contains', 'overlaps', + 'intersects', + 'touches', 'separatedFrom', + 'hasGap', 'beside', 'nextTo', 'adjacent', diff --git a/packages/imhotep-playwright/package.json b/packages/imhotep-playwright/package.json index 13fe9d2..07ab93b 100644 --- a/packages/imhotep-playwright/package.json +++ b/packages/imhotep-playwright/package.json @@ -36,6 +36,7 @@ "imhotep-core": "^1.0.0", "imhotep-dsl": "^1.0.0", "imhotep-solver": "^1.0.0", + "imhotep-state": "^1.0.0", "imhotep-reporter": "^1.0.0" }, "bugs": { diff --git a/packages/imhotep-playwright/src/extraction.ts b/packages/imhotep-playwright/src/extraction.ts index 6cef112..17a200e 100644 --- a/packages/imhotep-playwright/src/extraction.ts +++ b/packages/imhotep-playwright/src/extraction.ts @@ -188,6 +188,7 @@ export function computeRequiredFacts(formulas: FormulaNode[]): { fragments: boolean domAncestry: boolean } { + registerDefaultPredicates() const facts = new Set() for (const formula of formulas) { const predicates = collectPredicates(formula) @@ -222,9 +223,11 @@ export function computeRequiredFacts(formulas: FormulaNode[]): { return found }) + const needsTopology = Array.from(facts).some((f) => f.startsWith('topology.') || f.endsWith('.clipChain')) + return { geometry: true, // Always required for subject resolution - topology: facts.has('topology.stackingContextOf') || facts.has('subject.clipChain'), + topology: needsTopology, styles: facts.has('styles') || facts.has('computedStyle') || needsCssLengthMetrics, fragments: facts.has('subject.fragmentCount') || facts.has('subject.firstFragmentId'), domAncestry: needsDomAncestry, @@ -732,6 +735,40 @@ export async function extractWorldFastGeometry( // CDP Extraction // --------------------------------------------------------------------------- +function remapTopologyIds(world: GeometryWorld): void { + const nSubjects = world.subjects.ids.length + if (nSubjects === 0) return + + const backendToSolver = new Map() + for (let i = 0; i < nSubjects; i++) { + backendToSolver.set(world.subjects.domNodeId[i], world.subjects.ids[i]) + } + + const remap = (src: ArrayLike): number[] => { + const out = new Array(src.length) + for (let i = 0; i < src.length; i++) { + const backendId = src[i] + out[i] = backendId > 0 ? (backendToSolver.get(backendId) ?? 0) : 0 + } + return out + } + + const t = world.topology + t.containingBlockOf = remap(t.containingBlockOf) + t.nearestPositionedAncestorOf = remap(t.nearestPositionedAncestorOf) + t.scrollContainerOf = remap(t.scrollContainerOf) + t.stackingContextOf = remap(t.stackingContextOf) + t.formattingContextOf = remap(t.formattingContextOf) + t.clippingRootOf = remap(t.clippingRootOf) + + if (world.clipping) { + world.clipping.subjectId = remap(world.clipping.subjectId) + } + if (world.scroll) { + world.scroll.containerId = remap(world.scroll.containerId) + } +} + export async function extractWorldCdp( playwrightPage: Page, selectors: string[], @@ -751,15 +788,13 @@ export async function extractWorldCdp( try { await sessionManager.enableDomain('DOM') - const session = sessionManager.getSession() - await session.send('DOM.getDocument', { depth: -1 }) - const allQueries = selectorPlans.flatMap((plan) => plan.queries.map((query) => ({ key: plan.key, query })), ) - const allMatches = await Promise.all( - allQueries.map(({ query }) => resolveSelector(sessionManager, query)), - ) + const allMatches: Awaited>[] = [] + for (const { query } of allQueries) { + allMatches.push(await resolveSelector(sessionManager, query)) + } const resolvedSubjects: Array<{ id: string; selector: string; nodeId: number; backendNodeId: number }> = [] for (let i = 0; i < allQueries.length; i++) { const { key, query } = allQueries[i] @@ -830,6 +865,8 @@ export async function extractWorldCdp( fontWeight: Array.from(canonical.styles.fontWeight), } + remapTopologyIds(world) + const selectorToIds = new Map() for (const [selectorKey, nodeIds] of selectorToNodeIds) { const idSet = new Set(nodeIds) diff --git a/packages/imhotep-playwright/src/public.test.ts b/packages/imhotep-playwright/src/public.test.ts index 1c37b63..9dbc5ea 100644 --- a/packages/imhotep-playwright/src/public.test.ts +++ b/packages/imhotep-playwright/src/public.test.ts @@ -11,7 +11,7 @@ import assert from 'node:assert' import { imhotep, ImhotepUi, imhotepComponent, imhotepStory, imhotepFixture } from './public.js' import { defaultEnvironment } from './fixture.js' import { react, storybook } from './renderers.js' -import { SelectorDomainResolver, buildAncestorIndex } from './extraction.js' +import { SelectorDomainResolver, buildAncestorIndex, computeRequiredFacts } from './extraction.js' import { BindingEnv } from 'imhotep-solver' import type { GeometryWorld } from 'imhotep-solver' @@ -1356,3 +1356,32 @@ describe('buildAncestorIndex', () => { assert.strictEqual(index.size, 0) }) }) + +describe('computeRequiredFacts topology gating', () => { + it('requires topology extraction for scroll-container predicates', () => { + const facts = computeRequiredFacts([ + { + type: 'FormulaNode', + kind: 'predicate', + predicate: 'attachedToScrollContainer', + args: [{ type: 'VariableRef', name: '$subject' }], + } as any, + ]) + assert.strictEqual(facts.topology, true) + }) + + it('requires topology extraction for clipping-chain escape predicates', () => { + const facts = computeRequiredFacts([ + { + type: 'FormulaNode', + kind: 'predicate', + predicate: 'escapeClippingChainOf', + args: [ + { type: 'VariableRef', name: '$subject' }, + { type: 'VariableRef', name: '$reference' }, + ], + } as any, + ]) + assert.strictEqual(facts.topology, true) + }) +}) diff --git a/packages/imhotep-solver/src/joins.ts b/packages/imhotep-solver/src/joins.ts index ce7f95e..3721863 100644 --- a/packages/imhotep-solver/src/joins.ts +++ b/packages/imhotep-solver/src/joins.ts @@ -14,6 +14,8 @@ import type { DomainValue } from 'imhotep-core'; // Join Spec // --------------------------------------------------------------------------- +export const MAX_CARTESIAN_TUPLES = 100_000; + export interface JoinSpec { variableName: string; domain: DomainValue; @@ -53,6 +55,13 @@ export function createCartesianIterator(specs: JoinSpec[]): TupleIterator { const domainSizes = specs.map((s) => s.domain.subjectIds.length); const totalTuples = domainSizes.reduce((a, b) => a * b, 1); + if (totalTuples > MAX_CARTESIAN_TUPLES) { + throw new Error( + `Cartesian product exceeds safety limit: ${totalTuples} tuples (max ${MAX_CARTESIAN_TUPLES}). ` + + `Domain sizes: [${domainSizes.join(', ')}]`, + ); + } + // Pre-allocate reusable buffers. const tuple = new Uint32Array(n); const indices = new Uint32Array(n); diff --git a/packages/imhotep-solver/src/predicates.test.ts b/packages/imhotep-solver/src/predicates.test.ts index a9fc35a..fc035a6 100644 --- a/packages/imhotep-solver/src/predicates.test.ts +++ b/packages/imhotep-solver/src/predicates.test.ts @@ -597,5 +597,90 @@ describe('spatial alias predicates', () => { assert.strictEqual(result.truth, 'true'); assert.strictEqual(result.metrics?.clipKind, 2); // 2=overflow in predicate encoding }); + + it('escapeClippingChainOf passes when subject overflows an ancestor clip but is not clipped by it', () => { + const evaluator = getPredicateEvaluator('escapeClippingChainOf')!; + const world = makeWorld({ + subjects: { ids: [1, 2], domNodeId: [10, 20], subjectKind: [1, 1], primaryBoxId: [100, 200], firstFragmentId: [0, 0], fragmentCount: [0, 0] }, + dom: { nodeId: [10, 20], parentNodeId: [20, 0], childCount: [0, 1], tagNameStringId: [0, 0] }, + boxes: { + boxId: [100, 200], subjectId: [1, 2], frameId: [1, 1], + borderLeft: [10, 0], borderTop: [10, 0], borderRight: [150, 100], borderBottom: [50, 100], + paddingLeft: [0, 0], paddingTop: [0, 0], paddingRight: [0, 0], paddingBottom: [0, 0], + contentLeft: [10, 0], contentTop: [10, 0], contentRight: [150, 100], contentBottom: [50, 100], + }, + clipping: { + clipNodeId: [0], subjectId: [2], clipKind: [1], + clipLeft: [0], clipTop: [0], clipRight: [100], clipBottom: [100], parentClipNodeId: [0], + }, + topology: { clippingRootOf: [0, 0], stackingContextOf: [], scrollContainerOf: [], containingBlockOf: [], formattingContextOf: [], nearestPositionedAncestorOf: [], paintOrderBucket: [], paintOrderIndex: [] }, + }); + const result = evaluator.evaluateTuple(world, [1, 2]); + assert.strictEqual(result.truth, 'true'); + assert.strictEqual(result.metrics?.overflowRight, 50); + assert.strictEqual(result.metrics?.clippedByReference, 0); + }); + + it('escapeClippingChainOf fails when the subject is clipped by the reference', () => { + const evaluator = getPredicateEvaluator('escapeClippingChainOf')!; + const world = makeWorld({ + subjects: { ids: [1, 2], domNodeId: [10, 20], subjectKind: [1, 1], primaryBoxId: [100, 200], firstFragmentId: [0, 0], fragmentCount: [0, 0] }, + dom: { nodeId: [10, 20], parentNodeId: [20, 0], childCount: [0, 1], tagNameStringId: [0, 0] }, + boxes: { + boxId: [100, 200], subjectId: [1, 2], frameId: [1, 1], + borderLeft: [10, 0], borderTop: [10, 0], borderRight: [150, 100], borderBottom: [50, 100], + paddingLeft: [0, 0], paddingTop: [0, 0], paddingRight: [0, 0], paddingBottom: [0, 0], + contentLeft: [10, 0], contentTop: [10, 0], contentRight: [150, 100], contentBottom: [50, 100], + }, + clipping: { + clipNodeId: [0], subjectId: [2], clipKind: [1], + clipLeft: [0], clipTop: [0], clipRight: [100], clipBottom: [100], parentClipNodeId: [0], + }, + topology: { clippingRootOf: [2, 0], stackingContextOf: [], scrollContainerOf: [], containingBlockOf: [], formattingContextOf: [], nearestPositionedAncestorOf: [], paintOrderBucket: [], paintOrderIndex: [] }, + }); + const result = evaluator.evaluateTuple(world, [1, 2]); + assert.strictEqual(result.truth, 'false'); + assert.strictEqual(result.metrics?.clippedByReference, 1); + }); + }); + + describe('core geometric predicates', () => { + it('intersects includes boundary contact', () => { + const evaluator = getPredicateEvaluator('intersects')!; + const world = makeWorld({ + boxes: { + boxId: [100, 200, 300, 400], subjectId: [1, 2, 3, 4], frameId: [1, 1, 1, 1], + borderLeft: [0, 100, 50, 50], borderTop: [0, 0, 0, 150], borderRight: [100, 200, 150, 150], borderBottom: [40, 40, 100, 190], + paddingLeft: [0, 0, 0, 0], paddingTop: [0, 0, 0, 0], paddingRight: [0, 0, 0, 0], paddingBottom: [0, 0, 0, 0], + contentLeft: [0, 100, 50, 50], contentTop: [0, 0, 0, 150], contentRight: [100, 200, 150, 150], contentBottom: [40, 40, 100, 190], + }, + }); + const result = evaluator.evaluateTuple(world, [1, 2]); + assert.strictEqual(result.truth, 'true'); + assert.strictEqual(result.metrics?.overlapArea, 0); + }); + + it('touches passes for boundary contact and fails for positive overlap', () => { + const evaluator = getPredicateEvaluator('touches')!; + const touchingWorld = makeWorld({ + boxes: { + boxId: [100, 200, 300, 400], subjectId: [1, 2, 3, 4], frameId: [1, 1, 1, 1], + borderLeft: [0, 100, 50, 50], borderTop: [0, 0, 0, 150], borderRight: [100, 200, 150, 150], borderBottom: [40, 40, 100, 190], + paddingLeft: [0, 0, 0, 0], paddingTop: [0, 0, 0, 0], paddingRight: [0, 0, 0, 0], paddingBottom: [0, 0, 0, 0], + contentLeft: [0, 100, 50, 50], contentTop: [0, 0, 0, 150], contentRight: [100, 200, 150, 150], contentBottom: [40, 40, 100, 190], + }, + }); + assert.strictEqual(evaluator.evaluateTuple(touchingWorld, [1, 2]).truth, 'true'); + assert.strictEqual(evaluator.evaluateTuple(makeWorld(), [1, 3]).truth, 'false'); + }); + + it('hasGap measures axis-aligned and diagonal separation', () => { + const evaluator = getPredicateEvaluator('hasGap')!; + const result = evaluator.evaluateTuple(makeWorld(), [1, 2], { minGap: 10, maxGap: 20 }); + assert.strictEqual(result.truth, 'true'); + assert.strictEqual(result.metrics?.gapX, 10); + assert.strictEqual(result.metrics?.gapY, 10); + assert.ok(Math.abs((result.metrics?.gap ?? 0) - Math.sqrt(200)) < 0.0001); + }); }); }); diff --git a/packages/imhotep-solver/src/predicates.ts b/packages/imhotep-solver/src/predicates.ts index c837068..9ca96c2 100644 --- a/packages/imhotep-solver/src/predicates.ts +++ b/packages/imhotep-solver/src/predicates.ts @@ -80,8 +80,8 @@ 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: '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'] }, @@ -93,6 +93,13 @@ export const BUILTIN_PREDICATES: PredicateDescriptor[] = [ { 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'] }, ]; // --------------------------------------------------------------------------- @@ -183,6 +190,69 @@ function getBorderRect(world: GeometryWorld, subjectId: number) { }; } +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); + return idx >= 0 ? values[idx] ?? 0 : 0; +} + +function getSubjectDomNodeId(world: GeometryWorld, subjectId: number): number { + const idx = getSubjectIndex(world, subjectId); + return idx >= 0 ? world.subjects.domNodeId[idx] ?? 0 : 0; +} + +function isDomAncestor(world: GeometryWorld, ancestorSubjectId: number, descendantSubjectId: number): boolean | undefined { + const ancestorNodeId = getSubjectDomNodeId(world, ancestorSubjectId); + let current = getSubjectDomNodeId(world, descendantSubjectId); + if (ancestorNodeId <= 0 || current <= 0 || world.dom.nodeId.length === 0) return undefined; + + while (current > 0) { + const pos = world.dom.nodeId.indexOf(current); + if (pos < 0) return undefined; + const parent = world.dom.parentNodeId[pos] ?? 0; + if (parent === ancestorNodeId) return true; + current = parent; + } + return false; +} + +function getClipRectForSubject(world: GeometryWorld, subjectId: number) { + const clipping = world.clipping; + for (let i = 0; i < clipping.subjectId.length; i++) { + if (clipping.subjectId[i] !== subjectId) continue; + return { + left: clipping.clipLeft[i], + top: clipping.clipTop[i], + right: clipping.clipRight[i], + bottom: clipping.clipBottom[i], + kind: clipping.clipKind[i] ?? 0, + }; + } + return null; +} + +function rectOverlapMetrics(sRect: ReturnType & {}, rRect: ReturnType & {}) { + const overlapLeft = Math.max(sRect.left, rRect.left); + const overlapTop = Math.max(sRect.top, rRect.top); + const overlapRight = Math.min(sRect.right, rRect.right); + const overlapBottom = Math.min(sRect.bottom, rRect.bottom); + const overlapWidth = Math.max(0, overlapRight - overlapLeft); + const overlapHeight = Math.max(0, overlapBottom - overlapTop); + const gapX = Math.max(rRect.left - sRect.right, sRect.left - rRect.right, 0); + const gapY = Math.max(rRect.top - sRect.bottom, sRect.top - rRect.bottom, 0); + return { + overlapWidth, + overlapHeight, + overlapArea: overlapWidth * overlapHeight, + gapX, + gapY, + gap: Math.hypot(gapX, gapY), + }; +} + function makePredicateResult( truth: PredicateTruth, metrics?: Record, @@ -712,7 +782,7 @@ export const clippedByPredicate: PredicateEvaluator = { return makePredicateResult('indeterminate'); } // Simplified: check if subject's clipping root is the reference - const clipRoot = world.topology.clippingRootOf[subjectId - 1] ?? 0; + const clipRoot = getTopologyValueBySubject(world, subjectId, world.topology.clippingRootOf); const pass = clipRoot === referenceId; // Determine clip kind from the clipping table entry for the reference. // Encoding: 1=contain:paint, 2=overflow:hidden/scroll/auto @@ -737,12 +807,12 @@ export const inStackingContextPredicate: PredicateEvaluator = { if (subjectId === undefined) { return makePredicateResult('indeterminate'); } - const sc = world.topology.stackingContextOf[subjectId - 1] ?? 0; + const sc = getTopologyValueBySubject(world, subjectId, world.topology.stackingContextOf); const subjectHasSC = sc > 0; if (tuple.length >= 2 && tuple[1] !== undefined && tuple[1] !== 0) { const referenceId = tuple[1]; - const refSC = world.topology.stackingContextOf[referenceId - 1] ?? 0; + const refSC = getTopologyValueBySubject(world, referenceId, world.topology.stackingContextOf); const pass = subjectHasSC && refSC > 0 && sc === refSC; return makePredicateResult(pass ? 'true' : 'false', { stackingContext: sc, referenceStackingContext: refSC }, [subjectId, referenceId]); } @@ -755,30 +825,51 @@ export const inStackingContextPredicate: PredicateEvaluator = { export const attachedToScrollContainerPredicate: PredicateEvaluator = { descriptor: BUILTIN_PREDICATES[15]!, evaluateTuple(world, tuple) { - const subjectId = tuple[0]; - if (subjectId === undefined) { + const [subjectId, referenceId] = tuple; + if (subjectId === undefined || referenceId === undefined) { return makePredicateResult('indeterminate'); } - const scrollContainer = world.topology.scrollContainerOf[subjectId - 1] ?? 0; - const pass = scrollContainer > 0; - return makePredicateResult(pass ? 'true' : 'false', { scrollContainer }, [subjectId]); + const scrollContainer = getTopologyValueBySubject(world, subjectId, world.topology.scrollContainerOf); + 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 = tuple[0]; - if (subjectId === undefined) { + evaluateTuple(world, tuple) { + const [subjectId, referenceId] = tuple; + if (subjectId === undefined || referenceId === 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.`, - }, - ]); + 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); + const clippedByReference = clippingRoot === referenceId; + const pass = overflow > 0 && !clippedByReference && ancestry !== false; + const metrics: Record = { + overflowLeft, + overflowTop, + overflowRight, + overflowBottom, + overflow, + clipKind: clipRect.kind, + clippingRoot, + clippedByReference: clippedByReference ? 1 : 0, + referenceIsAncestor: ancestry === undefined ? -1 : ancestry ? 1 : 0, + }; + const diagnostics = pass ? undefined : makePredicateDiagnostic('escapeClippingChainOf', metrics, [subjectId, referenceId]); + return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics); }, }; @@ -1021,33 +1112,86 @@ export const separatedFromPredicate: PredicateEvaluator = { }, }; export const leftAlignedWithPredicate: PredicateEvaluator = { - descriptor: { name: 'leftAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] }, + descriptor: BUILTIN_PREDICATES[27]!, evaluateTuple(world, tuple, options) { return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record, axis: 'left' }); }, }; export const rightAlignedWithPredicate: PredicateEvaluator = { - descriptor: { name: 'rightAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] }, + descriptor: BUILTIN_PREDICATES[28]!, evaluateTuple(world, tuple, options) { return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record, axis: 'right' }); }, }; export const topAlignedWithPredicate: PredicateEvaluator = { - descriptor: { name: 'topAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] }, + descriptor: BUILTIN_PREDICATES[29]!, evaluateTuple(world, tuple, options) { return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record, axis: 'top' }); }, }; export const bottomAlignedWithPredicate: PredicateEvaluator = { - descriptor: { name: 'bottomAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] }, + descriptor: BUILTIN_PREDICATES[30]!, evaluateTuple(world, tuple, options) { return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record, axis: 'bottom' }); }, }; +export const intersectsPredicate: PredicateEvaluator = { + descriptor: BUILTIN_PREDICATES[31]!, + evaluateTuple(world, tuple) { + const [subjectId, referenceId] = tuple; + if (subjectId === undefined || referenceId === undefined) return makePredicateResult('indeterminate'); + const sRect = getBorderRect(world, subjectId); + const rRect = getBorderRect(world, referenceId); + if (!sRect || !rRect) return makePredicateResult('indeterminate'); + const metrics = rectOverlapMetrics(sRect, rRect); + const pass = sRect.left <= rRect.right && sRect.right >= rRect.left + && sRect.top <= rRect.bottom && sRect.bottom >= rRect.top; + const diagnostics = pass ? undefined : makePredicateDiagnostic('intersects', metrics, [subjectId, referenceId]); + return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics); + }, +}; + +export const touchesPredicate: PredicateEvaluator = { + descriptor: BUILTIN_PREDICATES[32]!, + evaluateTuple(world, tuple) { + const [subjectId, referenceId] = tuple; + if (subjectId === undefined || referenceId === undefined) return makePredicateResult('indeterminate'); + const sRect = getBorderRect(world, subjectId); + const rRect = getBorderRect(world, referenceId); + if (!sRect || !rRect) return makePredicateResult('indeterminate'); + const metrics = rectOverlapMetrics(sRect, rRect); + const intersects = sRect.left <= rRect.right && sRect.right >= rRect.left + && sRect.top <= rRect.bottom && sRect.bottom >= rRect.top; + const overlapsArea = metrics.overlapArea > 0; + const pass = intersects && !overlapsArea; + const diagnostics = pass ? undefined : makePredicateDiagnostic('touches', metrics, [subjectId, referenceId]); + return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics); + }, +}; + +export const hasGapPredicate: PredicateEvaluator = { + descriptor: BUILTIN_PREDICATES[33]!, + evaluateTuple(world, tuple, options) { + const [subjectId, referenceId] = tuple; + if (subjectId === undefined || referenceId === undefined) return makePredicateResult('indeterminate'); + const sRect = getBorderRect(world, subjectId); + const rRect = getBorderRect(world, referenceId); + if (!sRect || !rRect) return makePredicateResult('indeterminate'); + const metrics = rectOverlapMetrics(sRect, rRect); + const minGap = (options?.minGap as number | undefined) ?? 0; + const maxGap = (options?.maxGap as number | undefined) ?? Infinity; + const separated = metrics.gap > 0; + const pass = separated && metrics.gap >= minGap && metrics.gap <= maxGap; + const resultMetrics = { ...metrics, minGap, maxGap }; + const diagnostics = pass ? undefined : makePredicateDiagnostic('hasGap', resultMetrics, [subjectId, referenceId]); + return makePredicateResult(pass ? 'true' : 'false', resultMetrics, [subjectId, referenceId], diagnostics); + }, +}; + // --------------------------------------------------------------------------- // Register Defaults // --------------------------------------------------------------------------- @@ -1081,6 +1225,9 @@ export function registerDefaultPredicates(): void { registerPredicate(rightAlignedWithPredicate); registerPredicate(topAlignedWithPredicate); registerPredicate(bottomAlignedWithPredicate); + registerPredicate(intersectsPredicate); + registerPredicate(touchesPredicate); + registerPredicate(hasGapPredicate); registerPredicate(besidePredicate); registerPredicate(nextToPredicate); registerPredicate(adjacentPredicate); diff --git a/packages/imhotep-solver/src/topology.ts b/packages/imhotep-solver/src/topology.ts index 75ad341..507f062 100644 --- a/packages/imhotep-solver/src/topology.ts +++ b/packages/imhotep-solver/src/topology.ts @@ -132,7 +132,7 @@ export function evaluateInStackingContext( clause: ClauseDescriptor, ): ClauseResult { const { subjectRef, referenceRef, clauseId } = clause; - if (subjectRef === undefined || referenceRef === undefined) { + if (subjectRef === undefined) { return result(clauseId, 'error', 'indeterminate', undefined, { subjectRef, referenceRef, @@ -140,6 +140,18 @@ export function evaluateInStackingContext( } const sCtx = getTopologyValue(world.topology.stackingContextOf, subjectRef); + + if (referenceRef === undefined) { + const pass = sCtx !== 0; + return result( + clauseId, + pass ? 'pass' : 'fail', + 'determinate', + { subjectStackingContext: sCtx }, + { subjectId: subjectRef }, + ); + } + const rCtx = getTopologyValue(world.topology.stackingContextOf, referenceRef); const pass = sCtx !== 0 && sCtx === rCtx;