refactor: eliminate remaining hardcoded predicate-name dispatch

Extraction.ts (3 fixes):
  - Replace 2 'inStackingContext' string checks with isVariableArityPredicate()
  - Replace 7-name diagnostic formatting if/else with spec-driven
    getPredicateSpec() checks (isDirectional → gap message,
    isSize → threshold hint, else generic)

Grammar.ts: Replace 8 hardcoded parser routing checks
  (atLeast/atMost/aspectRatio/between/clippedBy/attachedToScrollContainer/
  escapeClippingChainOf/inStackingContext) with SIZE_PREDICATE_NAMES and
  TOPOLOGY_PREDICATE_NAMES Sets derived from spec table.

Pipeline.ts: Replace 15-entry CODE_TO_CLAUSE_KIND map with runtime
  generation from PREDICATE_SPECS. Prefix derived from spec.isSize
  ('size.*') / validOptions.includes('axis') ('alignment.*') /
  else ('relation.*'). Manual override for aspectRatio code 15.

Proofs.ts: Replace 11-case switch(kind) with 5 spec-driven if/else
  branches categorized by validOptions presence (hasGap→directional,
  hasAxis→alignment) + 2 specific name checks (inside overflow,
  aspectRatio ratio). 11 predicate names → 0 hardcoded.

Lexer.ts: Export KEYWORDS map for conformance testing.

Conformance tests:
  - Solver: every BUILTIN_PREDICATES entry matches its PREDICATE_SPECS
    counterpart; every spec name (incl. aliases) has a registered
    evaluator with matching descriptor (2 tests)
  - DSL: every predicate name from collectAllPredicateNames() appears
    in the lexer KEYWORDS table (1 test)

598 SDK + 3 conformance + 57 E2E = 658 tests pass.
This commit is contained in:
John Dvorak
2026-05-22 13:15:35 -07:00
parent 283ab1b39f
commit 9df295b915
7 changed files with 238 additions and 165 deletions
@@ -17,8 +17,10 @@ import {
clearPredicateRegistry,
registerDefaultPredicates,
getPredicateEvaluator,
BUILTIN_PREDICATES,
type GeometryWorld,
} from './index.js';
import { PREDICATE_SPECS, getAllPredicateSpecs, getPredicateSpec } from 'imhotep-core';
// --- Test helpers ------------------------------------------------------------
@@ -683,4 +685,55 @@ describe('spatial alias predicates', () => {
assert.ok(Math.abs((result.metrics?.gap ?? 0) - Math.sqrt(200)) < 0.0001);
});
});
// ---------------------------------------------------------------------------
// Conformance: PREDICATE_SPECS ↔ BUILTIN_PREDICATES sync
// ---------------------------------------------------------------------------
describe('PredicateSpec ↔ BUILTIN_PREDICATES conformance', () => {
it('every BUILTIN_PREDICATES entry has a matching PREDICATE_SPECS entry', () => {
for (const bp of BUILTIN_PREDICATES) {
const spec = getPredicateSpec(bp.name)
assert.ok(spec, `BUILTIN_PREDICATES entry "${bp.name}" not found in PREDICATE_SPECS`)
assert.strictEqual(
spec.arity === 'variable' ? 2 : spec.arity,
bp.arity,
`Arity mismatch for "${bp.name}": PREDICATE_SPECS has ${spec.arity}, BUILTIN_PREDICATES has ${bp.arity}`,
)
for (const fact of bp.requiredFacts) {
assert.ok(
spec.requiredFacts.includes(fact),
`Required fact "${fact}" for "${bp.name}" is in BUILTIN_PREDICATES but not in PREDICATE_SPECS`,
)
}
}
})
it('every registered predicate has an evaluator with matching descriptor', () => {
registerDefaultPredicates()
// Collect all predicate names from spec table (including aliases as separate evaluator names).
const specNames = new Set<string>()
for (const spec of PREDICATE_SPECS) {
specNames.add(spec.name)
for (const alias of spec.aliases) specNames.add(alias)
}
for (const name of specNames) {
const evaluator = getPredicateEvaluator(name)
assert.ok(evaluator, `No evaluator registered for predicate "${name}" from PREDICATE_SPECS`)
const spec = getPredicateSpec(name)
if (spec) {
const desc = evaluator.descriptor
assert.strictEqual(desc.name, name, `Evaluator name mismatch for "${name}"`)
const expectedArity = spec.arity === 'variable' ? 2 : spec.arity
assert.strictEqual(desc.arity, expectedArity, `Evaluator arity mismatch for "${name}"`)
for (const fact of spec.requiredFacts) {
assert.ok(
desc.requiredFacts.includes(fact),
`Required fact "${fact}" for "${name}" is in PREDICATE_SPECS but missing from evaluator descriptor`,
)
}
}
}
})
})
});
+130 -126
View File
@@ -16,6 +16,8 @@ import {
type GeometryWorld,
} from './registry.js';
import { getPredicateSpec } from 'imhotep-core'
let proofCounter = 0;
function nextProofId(): string {
@@ -49,134 +51,136 @@ function buildFailedPredicate(
relationKind: kind,
};
switch (kind) {
case 'leftOf':
case 'rightOf':
case 'above':
case 'below': {
const gap = metrics.observedGap ?? metrics.gap ?? 0;
const min = metrics.minGap ?? 0;
const max = metrics.maxGap ?? Infinity;
return {
...base,
op: gap < min ? '<' : '>',
left: gap,
right: gap < min ? min : max,
measuredGap: gap,
expectedMinGap: Number.isFinite(min) ? min : undefined,
expectedMaxGap: Number.isFinite(max) ? max : undefined,
subjectRect:
metrics.subjectLeft !== undefined
? {
left: metrics.subjectLeft,
top: metrics.subjectTop ?? 0,
right: metrics.subjectRight ?? 0,
bottom: metrics.subjectBottom ?? 0,
}
: undefined,
referenceRect:
metrics.refLeft !== undefined
? {
left: metrics.refLeft,
top: metrics.refTop ?? 0,
right: metrics.refRight ?? 0,
bottom: metrics.refBottom ?? 0,
}
: undefined,
};
}
const spec = getPredicateSpec(kind)
// Determine diagnostic shape family from spec metadata.
const hasGap = spec?.validOptions.includes('minGap') && spec?.validOptions.includes('maxGap')
const hasAxis = spec?.validOptions.includes('axis')
const isSize = spec?.isSize
case 'inside': {
const overflowLeft = metrics.overflowLeft ?? 0;
const overflowTop = metrics.overflowTop ?? 0;
const overflowRight = metrics.overflowRight ?? 0;
const overflowBottom = metrics.overflowBottom ?? 0;
return {
...base,
op: 'not-contained',
left: 0,
right: 0,
overflowEdges: {
left: overflowLeft,
top: overflowTop,
right: overflowRight,
bottom: overflowBottom,
},
subjectRect:
metrics.subjectLeft !== undefined
? {
left: metrics.subjectLeft,
top: metrics.subjectTop ?? 0,
right: metrics.subjectRight ?? 0,
bottom: metrics.subjectBottom ?? 0,
}
: undefined,
referenceRect:
metrics.refLeft !== undefined
? {
left: metrics.refLeft,
top: metrics.refTop ?? 0,
right: metrics.refRight ?? 0,
bottom: metrics.refBottom ?? 0,
}
: undefined,
};
}
case 'atLeast':
case 'atMost':
case 'between': {
const observed = metrics.observed ?? metrics.value ?? 0;
const min = metrics.min ?? -Infinity;
const max = metrics.max ?? Infinity;
return {
...base,
op: kind === 'atMost' ? '>' : '<',
left: observed,
right: kind === 'atMost' ? max : min,
measuredValue: observed,
expectedMin: Number.isFinite(min) ? min : undefined,
expectedMax: Number.isFinite(max) ? max : undefined,
};
}
case 'aspectRatio': {
const observed = metrics.observed ?? 0;
const minRatio = metrics.minRatio ?? -Infinity;
const maxRatio = metrics.maxRatio ?? Infinity;
return {
...base,
op: observed < minRatio ? '<' : '>',
left: observed,
right: observed < minRatio ? minRatio : maxRatio,
measuredValue: observed,
expectedMin: Number.isFinite(minRatio) ? minRatio : undefined,
expectedMax: Number.isFinite(maxRatio) ? maxRatio : undefined,
};
}
case 'alignedWith':
case 'centeredWithin': {
const delta = metrics.delta ?? metrics.deltaX ?? metrics.deltaY ?? 0;
const tolerance = metrics.tolerance ?? 0;
return {
...base,
op: '>',
left: delta,
right: tolerance,
measuredValue: delta,
expectedMax: tolerance,
};
}
default:
// Fallback to generic synthesis from the first two numeric metrics.
const fallback = synthesizeGenericFailedPredicate(metrics);
if (fallback) {
return { ...fallback, relationKind: kind };
}
return fallback;
// --- Directional gap (leftOf / rightOf / above / below) ---
if (hasGap) {
const gap = metrics.observedGap ?? metrics.gap ?? 0;
const min = metrics.minGap ?? 0;
const max = metrics.maxGap ?? Infinity;
return {
...base,
op: gap < min ? '<' : '>',
left: gap,
right: gap < min ? min : max,
measuredGap: gap,
expectedMinGap: Number.isFinite(min) ? min : undefined,
expectedMaxGap: Number.isFinite(max) ? max : undefined,
subjectRect:
metrics.subjectLeft !== undefined
? {
left: metrics.subjectLeft,
top: metrics.subjectTop ?? 0,
right: metrics.subjectRight ?? 0,
bottom: metrics.subjectBottom ?? 0,
}
: undefined,
referenceRect:
metrics.refLeft !== undefined
? {
left: metrics.refLeft,
top: metrics.refTop ?? 0,
right: metrics.refRight ?? 0,
bottom: metrics.refBottom ?? 0,
}
: undefined,
};
}
// --- Containment / inside (unique overflow metrics) ---
if (kind === 'inside') {
const overflowLeft = metrics.overflowLeft ?? 0;
const overflowTop = metrics.overflowTop ?? 0;
const overflowRight = metrics.overflowRight ?? 0;
const overflowBottom = metrics.overflowBottom ?? 0;
return {
...base,
op: 'not-contained',
left: 0,
right: 0,
overflowEdges: {
left: overflowLeft,
top: overflowTop,
right: overflowRight,
bottom: overflowBottom,
},
subjectRect:
metrics.subjectLeft !== undefined
? {
left: metrics.subjectLeft,
top: metrics.subjectTop ?? 0,
right: metrics.subjectRight ?? 0,
bottom: metrics.subjectBottom ?? 0,
}
: undefined,
referenceRect:
metrics.refLeft !== undefined
? {
left: metrics.refLeft,
top: metrics.refTop ?? 0,
right: metrics.refRight ?? 0,
bottom: metrics.refBottom ?? 0,
}
: undefined,
};
}
// --- Size threshold (atLeast / atMost / between) ---
if (isSize && kind !== 'aspectRatio') {
const observed = metrics.observed ?? metrics.value ?? 0;
const min = metrics.min ?? -Infinity;
const max = metrics.max ?? Infinity;
return {
...base,
op: kind === 'atMost' ? '>' : '<',
left: observed,
right: kind === 'atMost' ? max : min,
measuredValue: observed,
expectedMin: Number.isFinite(min) ? min : undefined,
expectedMax: Number.isFinite(max) ? max : undefined,
};
}
// --- Aspect ratio ---
if (kind === 'aspectRatio') {
const observed = metrics.observed ?? 0;
const minRatio = metrics.minRatio ?? -Infinity;
const maxRatio = metrics.maxRatio ?? Infinity;
return {
...base,
op: observed < minRatio ? '<' : '>',
left: observed,
right: observed < minRatio ? minRatio : maxRatio,
measuredValue: observed,
expectedMin: Number.isFinite(minRatio) ? minRatio : undefined,
expectedMax: Number.isFinite(maxRatio) ? maxRatio : undefined,
};
}
// --- Alignment (alignedWith / centeredWithin) ---
if (hasAxis || kind === 'centeredWithin') {
const delta = metrics.delta ?? metrics.deltaX ?? metrics.deltaY ?? 0;
const tolerance = metrics.tolerance ?? 0;
return {
...base,
op: '>',
left: delta,
right: tolerance,
measuredValue: delta,
expectedMax: tolerance,
};
}
// Fallback to generic synthesis from the first two numeric metrics.
const fallback = synthesizeGenericFailedPredicate(metrics);
if (fallback) {
return { ...fallback, relationKind: kind };
}
return fallback;
}
function synthesizeGenericFailedPredicate(