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:
@@ -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`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user