998 lines
30 KiB
TypeScript
998 lines
30 KiB
TypeScript
// 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,
|
|
}
|
|
}
|