refactor: introduce unified PredicateSpec table, convert extraction consumers

Imhotep-core: add predicate-specs.ts with 34 PredicateSpec entries as
  the single source of truth for predicate metadata (name, arity,
  aliases, requiredFacts, validOptions, diagnosticCode, relationCode,
  decompose rules, category flags). Lookup helpers derive all
  per-predicate information from the static table.

Extraction.ts (3 consumers converted):
  - computeRequiredFacts: replace getRequiredFactsForPredicate (global
    registry) with getPredicateRequiredFacts (static spec table).
    Removes registerDefaultPredicates() dependency from fact planning.
  - compileCanonicalClauseToFormula: replace 4 string-branch patterns
    ('between'/'separatedFrom'/'atLeast'/'aspectRatio'/'inStackingContext')
    with spec-driven getPredicateDecomposition() and isUnaryPredicate().
    Same behavior, zero string dispatch in predicate selection.
  - mapFolDiagnostic: replace PREDICATE_TO_DIAGNOSTIC_CODE (13-entry
    Record) with getPredicateDiagnosticCode() from spec table.

595 SDK + 57 hard E2E tests pass.
This commit is contained in:
John Dvorak
2026-05-22 12:44:57 -07:00
parent aa69ddc52f
commit c0357b152f
3 changed files with 600 additions and 35 deletions
+22 -35
View File
@@ -48,10 +48,15 @@ import {
evaluateLogic,
registerDefaultPredicates,
getPredicateEvaluator,
getRequiredFactsForPredicate,
type DomainResolver,
BindingEnv,
} from 'imhotep-solver'
import {
getPredicateRequiredFacts,
getPredicateDiagnosticCode,
getPredicateDecomposition,
isUnaryPredicate,
} from 'imhotep-core'
import { buildGeometryWorld } from './world-builder.js'
import {
materializeSemanticSelector,
@@ -197,12 +202,11 @@ export function computeRequiredFacts(formulas: FormulaNode[]): {
fragments: boolean
domAncestry: boolean
} {
registerDefaultPredicates()
const facts = new Set<string>()
for (const formula of formulas) {
const predicates = collectPredicates(formula)
for (const p of predicates) {
const required = getRequiredFactsForPredicate(p)
const required = getPredicateRequiredFacts(p)
for (const f of required) {
facts.add(f)
}
@@ -1157,7 +1161,10 @@ export function compileCanonicalClauseToFormula(clause: CanonicalClauseDescripto
let body: FormulaNode
if (clause.relation === 'between') {
const decomp = getPredicateDecomposition(clause.relation)
const hasInStackingFlag = (clause.flags & 8) !== 0
if (decomp?.kind === 'between') {
const minVal = extended.options?.min as number | undefined
const maxVal = extended.options?.max as number | undefined
const atLeastOpts: Record<string, unknown> = {}
@@ -1184,24 +1191,22 @@ export function compileCanonicalClauseToFormula(clause: CanonicalClauseDescripto
...(maxVal !== undefined ? { options: { ...atMostOpts, value: maxVal } } : { options: atMostOpts }),
} as FormulaNode,
}
} else if (clause.relation === 'separatedFrom') {
const overlapsOpts: Record<string, unknown> = { ...options }
} else if (decomp?.kind === 'negate' && decomp.inner) {
const innerOpts: Record<string, unknown> = { ...options }
body = {
type: 'FormulaNode',
kind: 'not',
operand: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'overlaps',
predicate: decomp.inner,
args: [subjectVar, referenceVar],
...(Object.keys(overlapsOpts).length > 0 ? { options: overlapsOpts } : {}),
...(Object.keys(innerOpts).length > 0 ? { options: innerOpts } : {}),
} as FormulaNode,
}
} else {
const unaryPredicate = clause.relation === 'atLeast'
|| clause.relation === 'atMost'
|| clause.relation === 'aspectRatio'
|| (clause.relation === 'inStackingContext' && !clause.reference)
const unaryPredicate = isUnaryPredicate(clause.relation)
&& !(clause.relation === 'inStackingContext' && clause.reference)
body = {
type: 'FormulaNode',
@@ -1211,7 +1216,7 @@ export function compileCanonicalClauseToFormula(clause: CanonicalClauseDescripto
...(Object.keys(options).length > 0 ? { options } : {}),
} as FormulaNode
if ((clause.flags & 8) !== 0 && !unaryPredicate) {
if (hasInStackingFlag && !unaryPredicate) {
body = {
type: 'FormulaNode',
kind: 'and',
@@ -1245,11 +1250,8 @@ export function compileCanonicalClauseToFormula(clause: CanonicalClauseDescripto
domain: { type: 'DomainRef' as const, domain: 'elements', selector: clause.reference },
}
const isUnary = clause.relation === 'atLeast'
|| clause.relation === 'atMost'
|| clause.relation === 'between'
|| clause.relation === 'aspectRatio'
|| (clause.relation === 'inStackingContext' && !clause.reference)
const isUnary = isUnaryPredicate(clause.relation)
&& !(clause.relation === 'inStackingContext' && clause.reference)
if (isUnary) {
return {
@@ -1613,21 +1615,6 @@ export function evaluateCardinalityAssertion(
// Result Adapter: FOL → Public API shape
// ---------------------------------------------------------------------------
const PREDICATE_TO_DIAGNOSTIC_CODE: Record<string, DiagnosticCode> = {
leftOf: 'IMH_RELATION_LEFT_OF_FAILED',
rightOf: 'IMH_RELATION_RIGHT_OF_FAILED',
above: 'IMH_RELATION_ABOVE_FAILED',
below: 'IMH_RELATION_BELOW_FAILED',
inside: 'IMH_RELATION_INSIDE_FAILED',
contains: 'IMH_RELATION_CONTAINS_FAILED',
alignedWith: 'IMH_ALIGNMENT_FAILED',
centeredWithin: 'IMH_RELATION_CENTERED_FAILED',
overlaps: 'IMH_RELATION_OVERLAPS_FAILED',
atLeast: 'IMH_SIZE_AT_LEAST_FAILED',
atMost: 'IMH_SIZE_AT_MOST_FAILED',
between: 'IMH_SIZE_BETWEEN_FAILED',
}
export function mapFolDiagnostic(
d: { code: string; severity: 'error' | 'warning' | 'info'; category?: string; message: string; clauseId?: string },
metrics?: Record<string, number>,
@@ -1645,8 +1632,8 @@ export function mapFolDiagnostic(
if ((code as string) === 'IMH_PREDICATE_FAILED') {
const match = message.match(/Predicate "([^"]+)" failed/)
const predicateName = match?.[1]
if (predicateName && PREDICATE_TO_DIAGNOSTIC_CODE[predicateName]) {
code = PREDICATE_TO_DIAGNOSTIC_CODE[predicateName]
if (predicateName && getPredicateDiagnosticCode(predicateName)) {
code = getPredicateDiagnosticCode(predicateName) as DiagnosticCode
const gapMatch = message.match(/gap=([\d.-]+)/)
const observedGap = gapMatch ? parseFloat(gapMatch[1]) : undefined
const minGapMatch = message.match(/minGap=([\d.-]+)/)