feat: implement variable-bound FOL domain resolution for descendants/children

- Extend DomainResolver.resolve() signature to accept optional BindingEnv
  so that parentVar domains can be resolved with runtime variable bindings
- Pass BindingEnv through evaluateForAll/evaluateExists to resolver calls
- Add buildAncestorIndex() to precompute DOM ancestor sets from CDP data
- SelectorDomainResolver now filters descendant domains by the bound parent
  when domain.parentVar is present and ancestor index is available
- Return undefined for parentVar domains when no ancestor index or env
  (prevents silent fallback to global domain resolution)
- Update all test DomainResolver mocks for new resolve interface
- Add 10 unit tests covering ancestor index construction, backward compat,
  descendant filtering, exclusion of non-descendants, empty descendants,
  missing parentVar/env, and no-ancestor-index safety
This commit is contained in:
John Dvorak
2026-05-21 17:05:35 -07:00
parent b7ac0e8f31
commit 19559b658b
9 changed files with 465 additions and 270 deletions
@@ -31,6 +31,7 @@ import {
specStore,
extractWorld,
SelectorDomainResolver,
buildAncestorIndex,
compileCanonicalClauseToFormula,
getSelectorsFromAssertion,
getSelectorsFromFormula,
@@ -552,6 +553,10 @@ export function makeCheckAll(deps: CheckAllDeps): ImhotepUi['checkAll'] {
resolver.register(selector, [])
}
}
const ancestorIndex = buildAncestorIndex(world)
if (ancestorIndex.size > 0) {
resolver.setAncestorIndex(ancestorIndex)
}
for (let i = 0; i < validChecks.length; i++) {
const check = validChecks[i]
@@ -642,6 +647,10 @@ export function makeCheckAll(deps: CheckAllDeps): ImhotepUi['checkAll'] {
for (const [selector, ids] of extracted.selectorToIds) {
resolver.register(selector, ids)
}
const stateAncestorIndex = buildAncestorIndex(extracted.world)
if (stateAncestorIndex.size > 0) {
resolver.setAncestorIndex(stateAncestorIndex)
}
const folResult = evaluateLogic({
formula: check.formula,
+66 -2
View File
@@ -41,6 +41,7 @@ import {
getPredicateEvaluator,
getRequiredFactsForPredicate,
type DomainResolver,
BindingEnv,
} from 'imhotep-solver'
import { buildGeometryWorld } from './world-builder.js'
import {
@@ -943,8 +944,49 @@ export async function extractWorld(
// Domain Resolver
// ---------------------------------------------------------------------------
export function buildAncestorIndex(world: GeometryWorld): Map<number, Set<number>> {
const ancestors = new Map<number, Set<number>>()
const nodeIdToSubjectId = new Map<number, number>()
for (let i = 0; i < world.subjects.ids.length; i++) {
const sid = world.subjects.ids[i]
const nodeId = world.subjects.domNodeId[i]
if (nodeId > 0) {
nodeIdToSubjectId.set(nodeId, sid)
}
}
if (nodeIdToSubjectId.size === 0) return ancestors
for (const [cid, sid] of nodeIdToSubjectId) {
const chain = new Set<number>()
let current = cid
while (current > 0) {
const pos = world.dom.nodeId.indexOf(current)
if (pos === -1) break
const parent = world.dom.parentNodeId[pos]
if (parent <= 0) break
const parentSid = nodeIdToSubjectId.get(parent)
if (parentSid !== undefined) {
chain.add(parentSid)
}
current = parent
}
if (chain.size > 0) {
ancestors.set(sid, chain)
}
}
return ancestors
}
export class SelectorDomainResolver implements DomainResolver {
private domains = new Map<string, DomainValue>()
private ancestorIndex?: Map<number, Set<number>>
setAncestorIndex(ancestors: Map<number, Set<number>>) {
this.ancestorIndex = ancestors
}
register(selector: string, subjectIds: number[]) {
this.domains.set(selector, {
@@ -955,7 +997,29 @@ export class SelectorDomainResolver implements DomainResolver {
})
}
resolve(domain: DomainRef): DomainValue | undefined {
resolve(domain: DomainRef, env?: BindingEnv): DomainValue | undefined {
if (domain.parentVar) {
if (!env || !this.ancestorIndex) return undefined
const parentId = env.lookup(domain.parentVar)
if (parentId === undefined) return undefined
const selector = domain.selector ?? domain.domain
const globalDomain = this.domains.get(selector)
if (!globalDomain) return undefined
const filtered: number[] = []
for (const id of globalDomain.subjectIds) {
const ancestors = this.ancestorIndex.get(id)
if (ancestors?.has(parentId)) {
filtered.push(id)
}
}
const key = `descendants_p${parentId}_${selector}`
return {
domainId: `dom_${key}`,
subjectIds: new Uint32Array(filtered),
provenance: `descendants(parentId=${parentId}, ${selector})`,
closed: true,
}
}
return this.domains.get(domain.selector ?? domain.domain)
}
}
@@ -1685,7 +1749,7 @@ export function buildCompatibilityReport(ui: ImhotepUi): CompatibilityReport {
})
const emptyResolver: DomainResolver = {
resolve(domain) {
resolve(domain, _env?) {
if (domain.selector === '.none') {
return {
domainId: 'dom_none',
@@ -11,6 +11,9 @@ 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 { BindingEnv } from 'imhotep-solver'
import type { GeometryWorld } from 'imhotep-solver'
// Minimal Playwright page mock
function createMockPage(): any {
@@ -1207,3 +1210,149 @@ describe('Geometry cache API', () => {
ui.clearCache()
})
})
// ---------------------------------------------------------------------------
// Variable-bound Domain Resolution
// ---------------------------------------------------------------------------
function makeMockWorld(domData: { nodeId: number[]; parentNodeId: number[] }): GeometryWorld {
const count = Math.max(domData.nodeId.length, 1)
const ids = Array.from({ length: count }, (_, i) => i + 1)
return {
sceneId: 'test',
snapshotId: 'snap1',
env: { viewportWidth: 1280, viewportHeight: 720, deviceScaleFactor: 1, colorScheme: 'light', pointer: 'fine', hover: false, reducedMotion: false, locale: 'en', writingMode: 'horizontal-tb' },
strings: { values: [] },
subjects: { ids, domNodeId: ids, subjectKind: Array(count).fill(0), primaryBoxId: ids, firstFragmentId: Array(count).fill(0), fragmentCount: Array(count).fill(0) },
dom: { nodeId: domData.nodeId, parentNodeId: domData.parentNodeId, childCount: Array(count).fill(0), tagNameStringId: Array(count).fill(0) },
boxes: { boxId: ids, subjectId: ids, frameId: Array(count).fill(0), borderLeft: Array(count).fill(0), borderTop: Array(count).fill(0), borderRight: Array(count).fill(0), borderBottom: Array(count).fill(0), paddingLeft: Array(count).fill(0), paddingTop: Array(count).fill(0), paddingRight: Array(count).fill(0), paddingBottom: Array(count).fill(0), contentLeft: Array(count).fill(0), contentTop: Array(count).fill(0), contentRight: Array(count).fill(0), contentBottom: Array(count).fill(0) },
visualBoxes: { boxId: ids, subjectId: ids, frameId: Array(count).fill(0), borderLeft: Array(count).fill(0), borderTop: Array(count).fill(0), borderRight: Array(count).fill(0), borderBottom: Array(count).fill(0), paddingLeft: Array(count).fill(0), paddingTop: Array(count).fill(0), paddingRight: Array(count).fill(0), paddingBottom: Array(count).fill(0), contentLeft: Array(count).fill(0), contentTop: Array(count).fill(0), contentRight: Array(count).fill(0), contentBottom: Array(count).fill(0) },
transforms: { transformId: [], subjectId: [], matrixStart: [], matrixLength: [], originX: [], originY: [] },
matrices: { values: [] },
rects: { rectId: [], left: [], top: [], right: [], bottom: [] },
topology: { containingBlockOf: [], nearestPositionedAncestorOf: [], scrollContainerOf: [], stackingContextOf: [], formattingContextOf: [], clippingRootOf: [], paintOrderBucket: [], paintOrderIndex: [] },
scroll: { containerId: [], scrollLeft: [], scrollTop: [], scrollWidth: [], scrollHeight: [], clientWidth: [], clientHeight: [] },
clipping: { clipNodeId: [], subjectId: [], clipKind: [], clipLeft: [], clipTop: [], clipRight: [], clipBottom: [], parentClipNodeId: [] },
visibility: { subjectId: [], isRendered: [], isVisible: [], visibleArea: [], clippedArea: [] },
}
}
describe('SelectorDomainResolver — variable-bound domain resolution', () => {
it('resolves without parentVar (backward compatible)', () => {
const resolver = new SelectorDomainResolver()
resolver.register('.card', [1, 2, 3])
const domain = resolver.resolve({ type: 'DomainRef', domain: 'elements', selector: '.card' })
assert.ok(domain)
assert.deepStrictEqual(Array.from(domain!.subjectIds), [1, 2, 3])
assert.strictEqual(domain!.closed, true)
})
it('resolves with parentVar and env, filtering to descendants', () => {
const world = makeMockWorld({
nodeId: [1, 2, 3],
parentNodeId: [0, 1, 1],
})
const ancestors = buildAncestorIndex(world)
const resolver = new SelectorDomainResolver()
resolver.setAncestorIndex(ancestors)
resolver.register('.title', [2, 3])
const env = new BindingEnv().bind('$card', 1)
const domain = resolver.resolve(
{ type: 'DomainRef', domain: 'descendants', selector: '.title', parentVar: '$card' },
env,
)
assert.ok(domain)
assert.deepStrictEqual(Array.from(domain!.subjectIds), [2, 3])
})
it('parentVar resolution excludes non-descendants', () => {
const world = makeMockWorld({
nodeId: [1, 2, 3],
parentNodeId: [0, 2, 0],
})
const ancestors = buildAncestorIndex(world)
const resolver = new SelectorDomainResolver()
resolver.setAncestorIndex(ancestors)
resolver.register('.item', [1, 3])
const env = new BindingEnv().bind('$container', 2)
const domain = resolver.resolve(
{ type: 'DomainRef', domain: 'descendants', selector: '.item', parentVar: '$container' },
env,
)
assert.ok(domain)
assert.deepStrictEqual(Array.from(domain!.subjectIds), [1])
})
it('parentVar not found in env returns undefined', () => {
const resolver = new SelectorDomainResolver()
resolver.register('.title', [1, 2])
const env = new BindingEnv().bind('$other', 5)
const domain = resolver.resolve(
{ type: 'DomainRef', domain: 'descendants', selector: '.title', parentVar: '$card' },
env,
)
assert.strictEqual(domain, undefined)
})
it('parentVar without ancestorIndex returns undefined', () => {
const resolver = new SelectorDomainResolver()
resolver.register('.title', [1, 2])
const env = new BindingEnv().bind('$card', 1)
const domain = resolver.resolve(
{ type: 'DomainRef', domain: 'descendants', selector: '.title', parentVar: '$card' },
env,
)
assert.strictEqual(domain, undefined)
})
it('no descendants yields empty domain', () => {
const world = makeMockWorld({
nodeId: [1, 2, 3],
parentNodeId: [0, 0, 2],
})
const ancestors = buildAncestorIndex(world)
const resolver = new SelectorDomainResolver()
resolver.setAncestorIndex(ancestors)
resolver.register('.orphan', [1])
const env = new BindingEnv().bind('$parent', 3)
const domain = resolver.resolve(
{ type: 'DomainRef', domain: 'descendants', selector: '.orphan', parentVar: '$parent' },
env,
)
assert.ok(domain)
assert.strictEqual(domain!.subjectIds.length, 0)
})
})
describe('buildAncestorIndex', () => {
it('returns empty map for world with no DOM data', () => {
const world = makeMockWorld({ nodeId: [], parentNodeId: [] })
const index = buildAncestorIndex(world)
assert.strictEqual(index.size, 0)
})
it('builds ancestor sets from parent-child DOM relationships', () => {
const world = makeMockWorld({
nodeId: [1, 2, 3, 4],
parentNodeId: [0, 1, 1, 2],
})
const index = buildAncestorIndex(world)
assert.ok(index.has(3))
assert.ok(index.get(3)!.has(1))
assert.ok(index.has(4))
assert.ok(index.get(4)!.has(2))
assert.ok(index.get(4)!.has(1))
})
it('no ancestors for root elements', () => {
const world = makeMockWorld({
nodeId: [1, 2],
parentNodeId: [0, 0],
})
const index = buildAncestorIndex(world)
assert.strictEqual(index.size, 0)
})
})