v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)
This commit is contained in:
@@ -0,0 +1,997 @@
|
||||
// Compiler: lowers AST -> Semantic IR -> Execution IR
|
||||
// Pure function, no browser dependencies
|
||||
|
||||
import type {
|
||||
RelationAssertion,
|
||||
SemanticIr,
|
||||
ExecutionIr,
|
||||
SemanticClause,
|
||||
SemanticSubject,
|
||||
SemanticFrame,
|
||||
SemanticState,
|
||||
SemanticTimeline,
|
||||
SemanticTolerance,
|
||||
SemanticEnvGuard,
|
||||
Diagnostic,
|
||||
Position,
|
||||
ImhotepId,
|
||||
FormulaNode,
|
||||
VariableRef,
|
||||
DomainRef,
|
||||
TupleBinding,
|
||||
PredicateCall,
|
||||
AssertionNode,
|
||||
SizeAssertion,
|
||||
TopologyAssertion,
|
||||
CompoundAssertion,
|
||||
SelectorRef,
|
||||
} from 'imhotep-core'
|
||||
|
||||
import { createEmptySemanticIr, getDefaultContext } from 'imhotep-core'
|
||||
import { parseTolerance, parseGap } from './validator.js'
|
||||
|
||||
// DSL grammar FOL types (distinct from solver FormulaNode imported above)
|
||||
import type {
|
||||
FormulaNode as DslFormulaNode,
|
||||
ForAllFormula as DslForAll,
|
||||
ExistsFormula as DslExists,
|
||||
AndFormula as DslAnd,
|
||||
OrFormula as DslOr,
|
||||
NotFormula as DslNot,
|
||||
ImpliesFormula as DslImplies,
|
||||
PredicateCall as DslPredicateCall,
|
||||
VariableRef as DslVariableRef,
|
||||
DomainRef as DslDomainRef,
|
||||
} from './logic-fluent.js'
|
||||
|
||||
// ---- Compiler Options (dependency injection) ----
|
||||
|
||||
export interface CompilerOptions {
|
||||
// Injected id generator for determinism in tests
|
||||
generateId?: (prefix: string) => string
|
||||
// Default frame when none specified
|
||||
defaultFrameKind?: string
|
||||
// Default tolerance when none specified
|
||||
defaultTolerance?: { value: number; unit: 'px' | 'jnd' }
|
||||
}
|
||||
|
||||
// ---- Id Generator ----
|
||||
|
||||
function defaultNextId(prefix: string): string {
|
||||
return `${prefix}_${getDefaultContext().idGenerator()}`
|
||||
}
|
||||
|
||||
// ---- Option Value Normalization ----
|
||||
// INVARIANT: Both fluent API and string DSL must lower to equivalent Execution IR.
|
||||
// The string parser produces LiteralNode / ToleranceLiteralNode AST wrappers,
|
||||
// while the fluent API stores raw numbers. These normalizers extract the scalar
|
||||
// value from both formats so parseGap / parseTolerance receive consistent input.
|
||||
//
|
||||
// Bug fixed: Previously parseGap(LiteralNode) silently returned null because
|
||||
// String(object) === "[object Object]", causing string DSL gap options to be
|
||||
// lost in compilation. The extractGapValue helper now handles range literals.
|
||||
|
||||
function normalizeOptionValue(raw: unknown): number | string | undefined {
|
||||
if (raw === undefined || raw === null) return undefined
|
||||
if (typeof raw === 'number' || typeof raw === 'string') return raw
|
||||
|
||||
// String-parser/fluent path: LiteralNode or ToleranceLiteralNode
|
||||
if (typeof raw === 'object') {
|
||||
const obj = raw as Record<string, unknown>
|
||||
if ('value' in obj && typeof obj.value === 'number') {
|
||||
if ('unit' in obj && typeof obj.unit === 'string') {
|
||||
return `${obj.value}${obj.unit}`
|
||||
}
|
||||
return obj.value
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Extract a numeric gap value from fluent raw numbers, string-parser LiteralNodes,
|
||||
// or string-parser range LiteralNodes ( picking min or max edge ).
|
||||
function extractGapValue(raw: unknown, edge: 'min' | 'max'): number | undefined {
|
||||
if (raw === undefined || raw === null) return undefined
|
||||
if (typeof raw === 'number') return raw
|
||||
if (typeof raw === 'string') {
|
||||
const num = parseFloat(raw.trim())
|
||||
if (!Number.isNaN(num)) return num
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof raw === 'object') {
|
||||
const obj = raw as Record<string, unknown>
|
||||
|
||||
// Simple length literal: { type: 'Literal', kind: 'length', value: number }
|
||||
if ('value' in obj && typeof obj.value === 'number') {
|
||||
return obj.value
|
||||
}
|
||||
|
||||
// Range literal: { type: 'Literal', kind: 'range', value: { min: { value }, max: { value } } }
|
||||
if (obj.kind === 'range' && obj.value && typeof obj.value === 'object') {
|
||||
const range = obj.value as Record<string, unknown>
|
||||
const target = edge === 'min' ? range.min : range.max
|
||||
if (target && typeof target === 'object') {
|
||||
const targetObj = target as Record<string, unknown>
|
||||
if (typeof targetObj.value === 'number') {
|
||||
return targetObj.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---- Semantic Lowering ----
|
||||
|
||||
function toSemanticIr(
|
||||
ast: RelationAssertion[],
|
||||
opts: CompilerOptions,
|
||||
): { semanticIr: SemanticIr; diagnostics: Diagnostic[] } {
|
||||
const generateId = opts.generateId || defaultNextId
|
||||
const ir = createEmptySemanticIr()
|
||||
const diagnostics: Diagnostic[] = []
|
||||
|
||||
// Default frame
|
||||
const defaultFrameId = generateId('frame')
|
||||
ir.frames.set(defaultFrameId, {
|
||||
id: defaultFrameId,
|
||||
kind: (opts.defaultFrameKind || 'viewport') as SemanticFrame['kind'],
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
writingMode: 'horizontal-tb',
|
||||
origin: { astNodeIds: [], positions: [] },
|
||||
})
|
||||
|
||||
// Default state
|
||||
const defaultStateId = generateId('state')
|
||||
ir.states.set(defaultStateId, {
|
||||
id: defaultStateId,
|
||||
kind: 'default',
|
||||
origin: { astNodeIds: [], positions: [] },
|
||||
})
|
||||
|
||||
// Default timeline
|
||||
const defaultTimelineId = generateId('timeline')
|
||||
ir.timelines.set(defaultTimelineId, {
|
||||
id: defaultTimelineId,
|
||||
mode: 'static',
|
||||
origin: { astNodeIds: [], positions: [] },
|
||||
})
|
||||
|
||||
// Default tolerance
|
||||
const defaultToleranceId = generateId('tolerance')
|
||||
ir.tolerances.set(defaultToleranceId, {
|
||||
id: defaultToleranceId,
|
||||
value: opts.defaultTolerance?.value ?? 0,
|
||||
unit: opts.defaultTolerance?.unit ?? 'px',
|
||||
origin: { astNodeIds: [], positions: [] },
|
||||
})
|
||||
|
||||
for (const assertion of ast) {
|
||||
// Build origin from source span
|
||||
const origin = buildOrigin(assertion.position)
|
||||
|
||||
// Access extended DSL properties via cast
|
||||
const extended = assertion as unknown as Record<string, unknown>
|
||||
|
||||
// Subject
|
||||
const subjectId = generateId('subject')
|
||||
const subjectDef: SemanticSubject = {
|
||||
id: subjectId,
|
||||
selector: assertion.subject.value,
|
||||
kind: 'element',
|
||||
origin,
|
||||
}
|
||||
ir.subjects.set(subjectId, subjectDef)
|
||||
|
||||
// Reference
|
||||
const referenceId = generateId('subject')
|
||||
const referenceDef: SemanticSubject = {
|
||||
id: referenceId,
|
||||
selector: assertion.reference.value,
|
||||
kind: 'element',
|
||||
origin,
|
||||
}
|
||||
ir.subjects.set(referenceId, referenceDef)
|
||||
|
||||
// Frame
|
||||
let frameId = defaultFrameId
|
||||
const frameNode = extended.frame as { kind: string; selector?: string; name?: string } | undefined
|
||||
if (frameNode) {
|
||||
frameId = generateId('frame')
|
||||
const frameDef: SemanticFrame = {
|
||||
id: frameId,
|
||||
kind: frameNode.kind as SemanticFrame['kind'],
|
||||
selector: frameNode.selector,
|
||||
name: frameNode.name,
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
writingMode: 'horizontal-tb',
|
||||
origin,
|
||||
}
|
||||
ir.frames.set(frameId, frameDef)
|
||||
}
|
||||
|
||||
// State
|
||||
let stateId = defaultStateId
|
||||
const stateNode = extended.state as { kind: string; name?: string } | undefined
|
||||
if (stateNode) {
|
||||
stateId = generateId('state')
|
||||
const stateDef: SemanticState = {
|
||||
id: stateId,
|
||||
kind: stateNode.kind as SemanticState['kind'],
|
||||
name: stateNode.name,
|
||||
origin,
|
||||
}
|
||||
ir.states.set(stateId, stateDef)
|
||||
}
|
||||
|
||||
// Tolerance
|
||||
let toleranceId = defaultToleranceId
|
||||
const rawOpts = assertion.options as unknown as Record<string, unknown> | undefined
|
||||
const tol = parseTolerance(normalizeOptionValue(rawOpts?.tolerance))
|
||||
if (tol) {
|
||||
toleranceId = generateId('tolerance')
|
||||
ir.tolerances.set(toleranceId, {
|
||||
id: toleranceId,
|
||||
value: tol.value,
|
||||
unit: tol.unit,
|
||||
origin,
|
||||
})
|
||||
}
|
||||
|
||||
// Environment guard
|
||||
const envGuardId = generateId('guard')
|
||||
const envGuard = extended.envGuard as { condition: string } | undefined
|
||||
const guardDef: SemanticEnvGuard = {
|
||||
id: envGuardId,
|
||||
expression: envGuard?.condition || 'true',
|
||||
normalizedCases: [],
|
||||
origin,
|
||||
}
|
||||
ir.envGuards.set(envGuardId, guardDef)
|
||||
|
||||
// Bounds
|
||||
const bounds: SemanticClause['bounds'] = {}
|
||||
if (rawOpts) {
|
||||
if ('minGap' in rawOpts) {
|
||||
const minVal = extractGapValue(rawOpts.minGap, 'min')
|
||||
if (minVal !== undefined) {
|
||||
bounds.minGap = { value: minVal, unit: 'px' }
|
||||
}
|
||||
}
|
||||
if ('maxGap' in rawOpts) {
|
||||
const maxVal = extractGapValue(rawOpts.maxGap, 'max')
|
||||
if (maxVal !== undefined) {
|
||||
bounds.maxGap = { value: maxVal, unit: 'px' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flags
|
||||
let flags = 0
|
||||
const quantifier = extended.quantifier as string | undefined
|
||||
if (quantifier === 'all') flags |= 1
|
||||
if (quantifier === 'any') flags |= 2
|
||||
if (quantifier === 'none') flags |= 4
|
||||
if (rawOpts?.inStackingContext) flags |= 8
|
||||
if ((assertion as any).negated) flags |= 16
|
||||
|
||||
// Clause
|
||||
const clauseId = generateId('clause')
|
||||
const clause: SemanticClause = {
|
||||
id: clauseId,
|
||||
origin,
|
||||
subjectRef: subjectId,
|
||||
referenceRef: referenceId,
|
||||
relation: assertion.relation,
|
||||
frameRef: frameId,
|
||||
stateRef: stateId,
|
||||
timelineRef: defaultTimelineId,
|
||||
envGuardRef: envGuardId,
|
||||
toleranceRef: toleranceId,
|
||||
bounds,
|
||||
}
|
||||
|
||||
// Attach flags via cast since core SemanticClause doesn't have flags field yet
|
||||
;(clause as unknown as Record<string, unknown>).flags = flags
|
||||
|
||||
ir.clauses.set(clauseId, clause)
|
||||
}
|
||||
|
||||
return { semanticIr: ir, diagnostics }
|
||||
}
|
||||
|
||||
function buildOrigin(position?: Position): { astNodeIds: string[]; positions: Position[] } {
|
||||
if (position) {
|
||||
return { astNodeIds: [], positions: [position] }
|
||||
}
|
||||
return { astNodeIds: [], positions: [] }
|
||||
}
|
||||
|
||||
// ---- Execution IR Compilation ----
|
||||
|
||||
function toExecutionIr(semanticIr: SemanticIr): ExecutionIr {
|
||||
const clauses = Array.from(semanticIr.clauses.values())
|
||||
const count = clauses.length
|
||||
|
||||
if (count === 0) {
|
||||
return {
|
||||
clauseCount: 0,
|
||||
clauseType: new Uint16Array(0),
|
||||
clauseSubject: new Uint32Array(0),
|
||||
clauseReference: new Uint32Array(0),
|
||||
clauseFrame: new Uint32Array(0),
|
||||
clauseState: new Uint32Array(0),
|
||||
clauseTimeline: new Uint32Array(0),
|
||||
clauseTolerance: new Uint32Array(0),
|
||||
clauseEnvGuard: new Uint32Array(0),
|
||||
clauseArg0: new Float64Array(0),
|
||||
clauseArg1: new Float64Array(0),
|
||||
clauseFlags: new Uint32Array(0),
|
||||
clauseOrigin: new Uint32Array(0),
|
||||
}
|
||||
}
|
||||
|
||||
// Build lookup maps for numeric indices — iterate Maps directly to avoid Array.from().
|
||||
const subjectIndex = new Map<ImhotepId, number>()
|
||||
let idx = 0
|
||||
for (const k of semanticIr.subjects.keys()) subjectIndex.set(k, idx++)
|
||||
|
||||
const frameIndex = new Map<ImhotepId, number>()
|
||||
idx = 0
|
||||
for (const k of semanticIr.frames.keys()) frameIndex.set(k, idx++)
|
||||
|
||||
const stateIndex = new Map<ImhotepId, number>()
|
||||
idx = 0
|
||||
for (const k of semanticIr.states.keys()) stateIndex.set(k, idx++)
|
||||
|
||||
const timelineIndex = new Map<ImhotepId, number>()
|
||||
idx = 0
|
||||
for (const k of semanticIr.timelines.keys()) timelineIndex.set(k, idx++)
|
||||
|
||||
const toleranceIndex = new Map<ImhotepId, number>()
|
||||
idx = 0
|
||||
for (const k of semanticIr.tolerances.keys()) toleranceIndex.set(k, idx++)
|
||||
|
||||
const guardIndex = new Map<ImhotepId, number>()
|
||||
idx = 0
|
||||
for (const k of semanticIr.envGuards.keys()) guardIndex.set(k, idx++)
|
||||
|
||||
// Encode relation as small integer
|
||||
const relationCodes: Record<string, number> = {
|
||||
leftOf: 1,
|
||||
rightOf: 2,
|
||||
above: 3,
|
||||
below: 4,
|
||||
alignedWith: 5,
|
||||
leftAlignedWith: 6,
|
||||
rightAlignedWith: 7,
|
||||
topAlignedWith: 8,
|
||||
bottomAlignedWith: 9,
|
||||
centeredWithin: 10,
|
||||
inside: 11,
|
||||
contains: 12,
|
||||
overlaps: 13,
|
||||
separatedFrom: 14,
|
||||
aspectRatioBetween: 15,
|
||||
between: 16,
|
||||
}
|
||||
|
||||
const clauseType = new Uint16Array(count)
|
||||
const clauseSubject = new Uint32Array(count)
|
||||
const clauseReference = new Uint32Array(count)
|
||||
const clauseFrame = new Uint32Array(count)
|
||||
const clauseState = new Uint32Array(count)
|
||||
const clauseTimeline = new Uint32Array(count)
|
||||
const clauseTolerance = new Uint32Array(count)
|
||||
const clauseEnvGuard = new Uint32Array(count)
|
||||
const clauseArg0 = new Float64Array(count)
|
||||
const clauseArg1 = new Float64Array(count)
|
||||
const clauseFlags = new Uint32Array(count)
|
||||
const clauseOrigin = new Uint32Array(count)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const c = clauses[i]
|
||||
clauseType[i] = relationCodes[c.relation] || 0
|
||||
clauseSubject[i] = subjectIndex.get(c.subjectRef) ?? 0
|
||||
clauseReference[i] = c.referenceRef ? (subjectIndex.get(c.referenceRef) ?? 0) : 0
|
||||
clauseFrame[i] = frameIndex.get(c.frameRef) ?? 0
|
||||
clauseState[i] = stateIndex.get(c.stateRef) ?? 0
|
||||
clauseTimeline[i] = timelineIndex.get(c.timelineRef) ?? 0
|
||||
clauseTolerance[i] = toleranceIndex.get(c.toleranceRef) ?? 0
|
||||
clauseEnvGuard[i] = guardIndex.get(c.envGuardRef) ?? 0
|
||||
clauseOrigin[i] = i // origin index same as clause index for now
|
||||
|
||||
// Retrieve flags via cast
|
||||
const flags = (c as unknown as Record<string, unknown>).flags as number | undefined
|
||||
clauseFlags[i] = flags || 0
|
||||
|
||||
// Pack bounds into arg0/arg1 where applicable
|
||||
if (c.bounds.minGap && c.bounds.maxGap) {
|
||||
clauseArg0[i] = c.bounds.minGap.value
|
||||
clauseArg1[i] = c.bounds.maxGap.value
|
||||
} else if (c.bounds.minGap) {
|
||||
clauseArg0[i] = c.bounds.minGap.value
|
||||
clauseArg1[i] = Number.POSITIVE_INFINITY
|
||||
} else if (c.bounds.maxGap) {
|
||||
clauseArg0[i] = Number.NEGATIVE_INFINITY
|
||||
clauseArg1[i] = c.bounds.maxGap.value
|
||||
} else {
|
||||
clauseArg0[i] = Number.NEGATIVE_INFINITY
|
||||
clauseArg1[i] = Number.POSITIVE_INFINITY
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clauseCount: count,
|
||||
clauseType,
|
||||
clauseSubject,
|
||||
clauseReference,
|
||||
clauseFrame,
|
||||
clauseState,
|
||||
clauseTimeline,
|
||||
clauseTolerance,
|
||||
clauseEnvGuard,
|
||||
clauseArg0,
|
||||
clauseArg1,
|
||||
clauseFlags,
|
||||
clauseOrigin,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- FOL Formula Compilation ----
|
||||
|
||||
const subjectVar: VariableRef = { type: 'VariableRef', name: '$subject' }
|
||||
const referenceVar: VariableRef = { type: 'VariableRef', name: '$reference' }
|
||||
|
||||
function makeDomain(selector: string): DomainRef {
|
||||
return { type: 'DomainRef', domain: 'elements', selector }
|
||||
}
|
||||
|
||||
function makeBinding(varName: string, selector: string): TupleBinding {
|
||||
return {
|
||||
type: 'TupleBinding',
|
||||
variables: [varName],
|
||||
domain: makeDomain(selector),
|
||||
}
|
||||
}
|
||||
|
||||
function buildOptionsFromAssertion(assertion: RelationAssertion | SizeAssertion | TopologyAssertion): Record<string, unknown> {
|
||||
const options: Record<string, unknown> = {}
|
||||
|
||||
if (assertion.type === 'RelationAssertion') {
|
||||
const opts = assertion.options as unknown as Record<string, unknown>
|
||||
if (opts?.minGap !== undefined) {
|
||||
const v = normalizeOptionValue(opts.minGap)
|
||||
if (v !== undefined) options.minGap = v
|
||||
}
|
||||
if (opts?.maxGap !== undefined) {
|
||||
const v = normalizeOptionValue(opts.maxGap)
|
||||
if (v !== undefined) options.maxGap = v
|
||||
}
|
||||
if (opts?.tolerance !== undefined) {
|
||||
const tol = parseTolerance(normalizeOptionValue(opts.tolerance))
|
||||
if (tol) {
|
||||
options.tolerance = tol.value
|
||||
options.toleranceUnit = tol.unit
|
||||
}
|
||||
}
|
||||
if (opts?.axis !== undefined) options.axis = opts.axis
|
||||
if (opts?.inStackingContext === true) options.inStackingContext = true
|
||||
}
|
||||
|
||||
if (assertion.type === 'SizeAssertion') {
|
||||
const bounds = assertion.bounds as unknown as Record<string, unknown>
|
||||
if (bounds?.min && typeof (bounds.min as any).value === 'number') {
|
||||
options.min = (bounds.min as any).value
|
||||
options.value = (bounds.min as any).value
|
||||
}
|
||||
if (bounds?.max && typeof (bounds.max as any).value === 'number') {
|
||||
options.max = (bounds.max as any).value
|
||||
}
|
||||
if (bounds?.exact && typeof (bounds.exact as any).value === 'number') {
|
||||
options.value = (bounds.exact as any).value
|
||||
}
|
||||
if (assertion.property) {
|
||||
options.dimension = assertion.property
|
||||
}
|
||||
}
|
||||
|
||||
if (assertion.type === 'TopologyAssertion') {
|
||||
const opts = assertion.options as unknown as Record<string, unknown>
|
||||
if (opts?.tolerance !== undefined) {
|
||||
const tol = parseTolerance(normalizeOptionValue(opts.tolerance))
|
||||
if (tol) {
|
||||
options.tolerance = tol.value
|
||||
options.toleranceUnit = tol.unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
function compileSimpleAssertionToFormula(
|
||||
assertion: RelationAssertion | SizeAssertion | TopologyAssertion,
|
||||
): FormulaNode {
|
||||
const options = buildOptionsFromAssertion(assertion)
|
||||
const isUnary = assertion.type === 'SizeAssertion'
|
||||
|| (assertion.type === 'RelationAssertion'
|
||||
&& (assertion.relation === 'atLeast' || assertion.relation === 'atMost'))
|
||||
|
||||
let predicateName: string
|
||||
let args: VariableRef[]
|
||||
|
||||
if (assertion.type === 'RelationAssertion') {
|
||||
predicateName = assertion.relation
|
||||
args = isUnary ? [subjectVar] : [subjectVar, referenceVar]
|
||||
} else if (assertion.type === 'SizeAssertion') {
|
||||
if (assertion.property === 'aspectRatio') {
|
||||
predicateName = 'aspectRatio'
|
||||
} else if (assertion.bounds.min && assertion.bounds.max) {
|
||||
predicateName = 'between'
|
||||
} else if (assertion.bounds.max) {
|
||||
predicateName = 'atMost'
|
||||
} else if (assertion.bounds.min) {
|
||||
predicateName = 'atLeast'
|
||||
} else {
|
||||
predicateName = 'atLeast'
|
||||
}
|
||||
args = [subjectVar]
|
||||
} else {
|
||||
predicateName = assertion.predicate
|
||||
args = assertion.reference ? [subjectVar, referenceVar] : [subjectVar]
|
||||
}
|
||||
|
||||
let body: FormulaNode = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: predicateName,
|
||||
args,
|
||||
...(Object.keys(options).length > 0 ? { options } : {}),
|
||||
} as FormulaNode
|
||||
|
||||
// Negation
|
||||
if ((assertion as any).negated) {
|
||||
body = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'not',
|
||||
operand: body,
|
||||
}
|
||||
}
|
||||
|
||||
// Bindings
|
||||
const subjectBinding = makeBinding('$subject', assertion.subject.value)
|
||||
const bindings: TupleBinding[] = [subjectBinding]
|
||||
|
||||
if (!isUnary && assertion.type !== 'TopologyAssertion') {
|
||||
const ref = (assertion as RelationAssertion).reference
|
||||
if (ref?.value) {
|
||||
bindings.push(makeBinding('$reference', ref.value))
|
||||
}
|
||||
}
|
||||
if (assertion.type === 'TopologyAssertion' && assertion.reference?.value) {
|
||||
bindings.push(makeBinding('$reference', assertion.reference.value))
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings,
|
||||
body,
|
||||
} as FormulaNode
|
||||
}
|
||||
|
||||
function compileQuantifierToFormula(
|
||||
assertion: CompoundAssertion,
|
||||
body: FormulaNode,
|
||||
): FormulaNode {
|
||||
const quantifier = assertion.quantifier?.kind
|
||||
const subject = (assertion.children[0] as any).subject as SelectorRef
|
||||
const reference = (assertion.children[0] as any).reference as SelectorRef | undefined
|
||||
|
||||
const subjectBinding = makeBinding('$subject', subject.value)
|
||||
|
||||
if (quantifier === 'any') {
|
||||
const refBinding = reference?.value
|
||||
? makeBinding('$reference', reference.value)
|
||||
: null
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'exists',
|
||||
bindings: [subjectBinding],
|
||||
body: refBinding
|
||||
? {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [refBinding],
|
||||
body,
|
||||
}
|
||||
: body,
|
||||
} as FormulaNode
|
||||
}
|
||||
|
||||
if (quantifier === 'none') {
|
||||
const refBinding = reference?.value
|
||||
? makeBinding('$reference', reference.value)
|
||||
: null
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [subjectBinding],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'not',
|
||||
operand: refBinding
|
||||
? {
|
||||
type: 'FormulaNode',
|
||||
kind: 'exists',
|
||||
bindings: [refBinding],
|
||||
body,
|
||||
}
|
||||
: body,
|
||||
},
|
||||
} as FormulaNode
|
||||
}
|
||||
|
||||
// Default / 'all'
|
||||
const bindings: TupleBinding[] = [subjectBinding]
|
||||
if (reference?.value) {
|
||||
bindings.push(makeBinding('$reference', reference.value))
|
||||
}
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings,
|
||||
body,
|
||||
} as FormulaNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a single DSL assertion AST node to a FOL FormulaNode.
|
||||
* Handles RelationAssertion, SizeAssertion, TopologyAssertion,
|
||||
* and CompoundAssertion (with operators and/or quantifiers).
|
||||
*/
|
||||
export function compileToFormula(assertion: AssertionNode): FormulaNode | null {
|
||||
if (!assertion) return null
|
||||
|
||||
// Compound assertion: operator (and/or) or quantifier wrapper
|
||||
if (assertion.type === 'CompoundAssertion') {
|
||||
const compound = assertion as CompoundAssertion
|
||||
|
||||
if (compound.operator) {
|
||||
// Logical compound: compile children and wrap in and/or
|
||||
const children = (compound.children || [])
|
||||
.map((child) => compileToFormula(child))
|
||||
.filter((f): f is FormulaNode => f !== null)
|
||||
|
||||
if (children.length === 0) return null
|
||||
if (children.length === 1) return children[0]
|
||||
|
||||
let result = children[0]
|
||||
for (let i = 1; i < children.length; i++) {
|
||||
result = {
|
||||
type: 'FormulaNode',
|
||||
kind: compound.operator,
|
||||
left: result,
|
||||
right: children[i],
|
||||
} as FormulaNode
|
||||
}
|
||||
|
||||
// If quantifier is present, wrap the whole compound
|
||||
if (compound.quantifier) {
|
||||
return compileQuantifierToFormula(compound, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Quantifier-only compound
|
||||
const childFormulas = (compound.children || [])
|
||||
.map((child) => compileToFormula(child))
|
||||
.filter((f): f is FormulaNode => f !== null)
|
||||
|
||||
if (childFormulas.length === 0) return null
|
||||
const body = childFormulas.length === 1
|
||||
? childFormulas[0]
|
||||
: childFormulas.reduce((left, right) => ({
|
||||
type: 'FormulaNode',
|
||||
kind: 'and',
|
||||
left,
|
||||
right,
|
||||
} as FormulaNode))
|
||||
|
||||
return compileQuantifierToFormula(compound, body)
|
||||
}
|
||||
|
||||
// Simple assertions
|
||||
if (
|
||||
assertion.type === 'RelationAssertion'
|
||||
|| assertion.type === 'SizeAssertion'
|
||||
|| assertion.type === 'TopologyAssertion'
|
||||
) {
|
||||
return compileSimpleAssertionToFormula(assertion as RelationAssertion | SizeAssertion | TopologyAssertion)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- Dense DSL FOL Compilation (bypasses canonical lowering) ----
|
||||
|
||||
/**
|
||||
* Convert a DSL grammar FOL formula AST node to a solver FormulaNode.
|
||||
*
|
||||
* Dense DSL forall/exists formulas use a distinct AST shape from the solver's
|
||||
* FormulaNode (e.g. type: 'ForAll' vs type: 'FormulaNode' kind: 'forall').
|
||||
* This function bridges the two representations so that parsed dense FOL specs
|
||||
* can be evaluated directly by evaluateLogic() without going through canonical
|
||||
* clause descriptors.
|
||||
*
|
||||
* String arguments inside PredicateCall are lifted into implicit forall
|
||||
* bindings because the solver only accepts VariableRef and AccessorTerm
|
||||
* as predicate arguments.
|
||||
*/
|
||||
export function compileDenseFOLToFormula(dslFormula: DslFormulaNode): FormulaNode {
|
||||
let freshVarCounter = 0
|
||||
function makeFreshVar(): string {
|
||||
freshVarCounter += 1
|
||||
return `$str_${freshVarCounter}`
|
||||
}
|
||||
|
||||
function compile(node: DslFormulaNode): FormulaNode {
|
||||
switch (node.type) {
|
||||
case 'ForAll': {
|
||||
const fa = node as DslForAll
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [makeTupleBinding(fa.variable, fa.domain)],
|
||||
body: compile(fa.body),
|
||||
} as FormulaNode
|
||||
}
|
||||
case 'Exists': {
|
||||
const ex = node as DslExists
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'exists',
|
||||
bindings: [makeTupleBinding(ex.variable, ex.domain)],
|
||||
body: compile(ex.body),
|
||||
} as FormulaNode
|
||||
}
|
||||
case 'And': {
|
||||
const a = node as DslAnd
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'and',
|
||||
left: compile(a.left),
|
||||
right: compile(a.right),
|
||||
} as FormulaNode
|
||||
}
|
||||
case 'Or': {
|
||||
const o = node as DslOr
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'or',
|
||||
left: compile(o.left),
|
||||
right: compile(o.right),
|
||||
} as FormulaNode
|
||||
}
|
||||
case 'Not': {
|
||||
const n = node as DslNot
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'not',
|
||||
operand: compile(n.operand),
|
||||
} as FormulaNode
|
||||
}
|
||||
case 'Implies': {
|
||||
const imp = node as DslImplies
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'implies',
|
||||
antecedent: compile(imp.left),
|
||||
consequent: compile(imp.right),
|
||||
} as FormulaNode
|
||||
}
|
||||
case 'PredicateCall': {
|
||||
const pc = node as DslPredicateCall
|
||||
const implicitBindings: TupleBinding[] = []
|
||||
const compiledArgs: VariableRef[] = []
|
||||
|
||||
for (const arg of pc.args) {
|
||||
if (typeof arg === 'string') {
|
||||
const varName = makeFreshVar()
|
||||
implicitBindings.push({
|
||||
type: 'TupleBinding',
|
||||
variables: [varName],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: arg },
|
||||
})
|
||||
compiledArgs.push({ type: 'VariableRef', name: varName })
|
||||
} else if (typeof arg === 'number') {
|
||||
// Numbers are not supported as predicate args in the solver.
|
||||
// Skip them — the caller should have encoded numeric values
|
||||
// in predicate options instead.
|
||||
continue
|
||||
} else {
|
||||
compiledArgs.push(convertTerm(arg) as VariableRef)
|
||||
}
|
||||
}
|
||||
|
||||
// Size predicates with comparison operators: width($x) >= 44 → size.atLeast
|
||||
let predicateBody: FormulaNode
|
||||
if (['width', 'height', 'size'].includes(pc.name) && pc.operator && pc.right !== undefined) {
|
||||
const options: Record<string, unknown> = {}
|
||||
if (pc.name === 'width') options.dimension = 'width'
|
||||
else if (pc.name === 'height') options.dimension = 'height'
|
||||
else options.dimension = 'width' // size defaults to width
|
||||
|
||||
let predicateName: string
|
||||
switch (pc.operator) {
|
||||
case '>=':
|
||||
predicateName = 'atLeast'
|
||||
options.min = pc.right
|
||||
break
|
||||
case '<=':
|
||||
predicateName = 'atMost'
|
||||
options.max = pc.right
|
||||
break
|
||||
case '==':
|
||||
predicateName = 'between'
|
||||
options.min = pc.right
|
||||
options.max = pc.right
|
||||
break
|
||||
case '!=':
|
||||
// Not-equal is compiled as negated equality
|
||||
predicateBody = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'not',
|
||||
operand: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'between',
|
||||
args: compiledArgs,
|
||||
options: { ...options, min: pc.right, max: pc.right },
|
||||
} as FormulaNode,
|
||||
} as FormulaNode
|
||||
// Wrap implicit bindings if any
|
||||
return wrapInForAll(implicitBindings, predicateBody)
|
||||
case '>':
|
||||
predicateName = 'atLeast'
|
||||
options.min = pc.right
|
||||
break
|
||||
case '<':
|
||||
predicateName = 'atMost'
|
||||
options.max = pc.right
|
||||
break
|
||||
default:
|
||||
predicateName = pc.name
|
||||
}
|
||||
|
||||
predicateBody = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: predicateName,
|
||||
args: compiledArgs,
|
||||
...(Object.keys(options).length > 0 ? { options } : {}),
|
||||
} as FormulaNode
|
||||
} else {
|
||||
predicateBody = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: pc.name,
|
||||
args: compiledArgs,
|
||||
} as FormulaNode
|
||||
}
|
||||
|
||||
// Wrap implicit forall bindings around the predicate body.
|
||||
// String literals in predicate args are treated as universally
|
||||
// quantified domains (same semantics as canonical clause compilation).
|
||||
return wrapInForAll(implicitBindings, predicateBody)
|
||||
}
|
||||
case 'VariableRef':
|
||||
case 'DomainRef':
|
||||
// VariableRef and DomainRef are terms, not formulas. They should only
|
||||
// appear as arguments inside PredicateCall, never as top-level formulas.
|
||||
throw new Error(`DSL ${node.type} is a term, not a formula, and cannot be compiled standalone`)
|
||||
default:
|
||||
throw new Error(`Unknown DSL formula node type: ${(node as any).type}`)
|
||||
}
|
||||
}
|
||||
|
||||
return compile(dslFormula)
|
||||
}
|
||||
|
||||
function wrapInForAll(bindings: TupleBinding[], body: FormulaNode): FormulaNode {
|
||||
if (bindings.length === 0) return body
|
||||
let result = body
|
||||
// Wrap from last binding to first so that the outermost forall
|
||||
// corresponds to the leftmost argument.
|
||||
for (let i = bindings.length - 1; i >= 0; i--) {
|
||||
result = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [bindings[i]],
|
||||
body: result,
|
||||
} as FormulaNode
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function makeTupleBinding(variable: DslVariableRef, domain: DslDomainRef): TupleBinding {
|
||||
return {
|
||||
type: 'TupleBinding',
|
||||
variables: [variable.name],
|
||||
domain: convertDomain(domain),
|
||||
}
|
||||
}
|
||||
|
||||
function convertDomain(domain: DslDomainRef): DomainRef {
|
||||
const selectorFromVar = domain.variableSelector
|
||||
? `$${domain.variableSelector.name}`
|
||||
: undefined
|
||||
const extraArgFromVar = domain.variableExtraArg
|
||||
? `$${domain.variableExtraArg.name}`
|
||||
: undefined
|
||||
|
||||
// Descendant domains use the first argument as parent and second as filter:
|
||||
// descendants($card, '.title') => parentVar: '$card', selector: '.title'
|
||||
if (domain.kind === 'descendants') {
|
||||
const parentVar = selectorFromVar ?? domain.selector
|
||||
const selector = extraArgFromVar ?? (domain as any).extraArg
|
||||
return {
|
||||
type: 'DomainRef',
|
||||
domain: domain.kind,
|
||||
selector,
|
||||
parentVar,
|
||||
}
|
||||
}
|
||||
|
||||
// Default mapping for non-descendant domains.
|
||||
const selector = selectorFromVar ?? domain.selector
|
||||
const parentVar = extraArgFromVar ?? (domain as any).extraArg
|
||||
return {
|
||||
type: 'DomainRef',
|
||||
domain: domain.kind,
|
||||
selector,
|
||||
parentVar,
|
||||
}
|
||||
}
|
||||
|
||||
function convertTerm(term: DslVariableRef | string | number): VariableRef | string | number {
|
||||
if (typeof term === 'object' && term !== null && term.type === 'VariableRef') {
|
||||
return { type: 'VariableRef', name: (term as DslVariableRef).name }
|
||||
}
|
||||
return term
|
||||
}
|
||||
|
||||
// ---- Public Compiler ----
|
||||
|
||||
export interface CompileResult {
|
||||
ast: { type: 'Program'; children: RelationAssertion[] }
|
||||
semanticIr: SemanticIr
|
||||
executionIr: ExecutionIr
|
||||
diagnostics: Diagnostic[]
|
||||
}
|
||||
|
||||
export function compile(
|
||||
ast: RelationAssertion[],
|
||||
options: CompilerOptions = {},
|
||||
): CompileResult {
|
||||
const programNode = {
|
||||
type: 'Program' as const,
|
||||
children: ast,
|
||||
}
|
||||
|
||||
const { semanticIr, diagnostics } = toSemanticIr(ast, options)
|
||||
const executionIr = toExecutionIr(semanticIr)
|
||||
|
||||
return {
|
||||
ast: programNode,
|
||||
semanticIr,
|
||||
executionIr,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user