2025-08-15 10:00:00 -07:00
|
|
|
/**
|
|
|
|
|
* Proof object generation.
|
|
|
|
|
*
|
|
|
|
|
* Every evaluated clause produces a proof object carrying used facts,
|
|
|
|
|
* derived facts, failed predicates, and witness data.
|
|
|
|
|
*
|
|
|
|
|
* Relation-aware synthesis: generateProof inspects the clause kind and
|
|
|
|
|
* metrics to produce rich, relation-specific diagnostics instead of a
|
|
|
|
|
* generic left/right comparison.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
type ClauseResult,
|
|
|
|
|
type ClauseDescriptor,
|
|
|
|
|
type Proof,
|
|
|
|
|
type GeometryWorld,
|
|
|
|
|
} from './registry.js';
|
|
|
|
|
|
2026-05-22 13:15:35 -07:00
|
|
|
import { getPredicateSpec } from 'imhotep-core'
|
|
|
|
|
|
2025-08-15 10:00:00 -07:00
|
|
|
let proofCounter = 0;
|
|
|
|
|
|
|
|
|
|
function nextProofId(): string {
|
|
|
|
|
return `proof_${++proofCounter}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Reset the proof counter (mainly for tests).
|
|
|
|
|
*/
|
|
|
|
|
export function resetProofCounter(): void {
|
|
|
|
|
proofCounter = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Relation-specific proof synthesis
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function buildFailedPredicate(
|
|
|
|
|
clauseKind: string,
|
|
|
|
|
metrics: Record<string, number>,
|
|
|
|
|
): Proof['failedPredicate'] {
|
|
|
|
|
// Normalize kind to handle prefix forms like "relation.leftOf".
|
|
|
|
|
const kind = clauseKind.includes('.')
|
|
|
|
|
? clauseKind.slice(clauseKind.indexOf('.') + 1)
|
|
|
|
|
: clauseKind;
|
|
|
|
|
|
|
|
|
|
const base = {
|
|
|
|
|
op: '<',
|
|
|
|
|
left: 0,
|
|
|
|
|
right: 0,
|
|
|
|
|
relationKind: kind,
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-22 13:15:35 -07:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
// --- 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 };
|
2025-08-15 10:00:00 -07:00
|
|
|
}
|
2026-05-22 13:15:35 -07:00
|
|
|
return fallback;
|
2025-08-15 10:00:00 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function synthesizeGenericFailedPredicate(
|
|
|
|
|
metrics: Record<string, number>,
|
|
|
|
|
): Proof['failedPredicate'] {
|
|
|
|
|
const keys = Object.keys(metrics);
|
|
|
|
|
if (keys.length >= 2) {
|
|
|
|
|
return {
|
|
|
|
|
op: '<',
|
|
|
|
|
left: metrics[keys[0]] ?? 0,
|
|
|
|
|
right: metrics[keys[1]] ?? 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (keys.length === 1) {
|
|
|
|
|
return {
|
|
|
|
|
op: '<',
|
|
|
|
|
left: metrics[keys[0]] ?? 0,
|
|
|
|
|
right: 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Public API
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate a single proof object from a clause result.
|
|
|
|
|
*
|
|
|
|
|
* @param result - The evaluation result for the clause.
|
|
|
|
|
* @param clause - The clause descriptor that was evaluated.
|
|
|
|
|
* @param world - The geometry world the clause was evaluated against.
|
|
|
|
|
* @param usedFacts - Array of fact ids that were read during evaluation.
|
|
|
|
|
* @param derivedFacts - Array of fact ids that were derived during evaluation.
|
|
|
|
|
*/
|
|
|
|
|
export function generateProof(
|
|
|
|
|
result: ClauseResult,
|
|
|
|
|
clause: ClauseDescriptor,
|
|
|
|
|
world: GeometryWorld,
|
|
|
|
|
usedFacts: number[] = [],
|
|
|
|
|
derivedFacts: number[] = [],
|
|
|
|
|
): Proof {
|
|
|
|
|
const proof: Proof = {
|
|
|
|
|
proofId: nextProofId(),
|
|
|
|
|
clauseId: result.clauseId,
|
|
|
|
|
outcome: result.status,
|
|
|
|
|
truth: result.truth,
|
|
|
|
|
usedFacts: [...usedFacts],
|
|
|
|
|
derivedFacts: [...derivedFacts],
|
|
|
|
|
witness: {
|
|
|
|
|
subjectId: result.witness?.subjectId ?? clause.subjectRef,
|
|
|
|
|
referenceId: result.witness?.referenceId ?? clause.referenceRef,
|
|
|
|
|
frameId: result.witness?.frameId ?? clause.frameRef,
|
|
|
|
|
snapshotId: world.snapshotId,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (result.status === 'fail' && result.metrics) {
|
|
|
|
|
proof.failedPredicate = buildFailedPredicate(
|
|
|
|
|
clause.clauseKind,
|
|
|
|
|
result.metrics,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return proof;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Batch-generate proofs for a set of clause results.
|
|
|
|
|
*
|
|
|
|
|
* @param results - Array of clause results.
|
|
|
|
|
* @param clauses - Array of clause descriptors (must cover all result clauseIds).
|
|
|
|
|
* @param world - The geometry world used during evaluation.
|
|
|
|
|
* @param usedFactsMap - Optional map from clauseId to fact ids.
|
|
|
|
|
* @param derivedFactsMap - Optional map from clauseId to derived fact ids.
|
|
|
|
|
*/
|
|
|
|
|
export function generateProofs(
|
|
|
|
|
results: ClauseResult[],
|
|
|
|
|
clauses: ClauseDescriptor[],
|
|
|
|
|
world: GeometryWorld,
|
|
|
|
|
usedFactsMap: Map<string, number[]> = new Map(),
|
|
|
|
|
derivedFactsMap: Map<string, number[]> = new Map(),
|
|
|
|
|
): Proof[] {
|
|
|
|
|
const clauseMap = new Map(clauses.map((c) => [c.clauseId, c]));
|
|
|
|
|
return results.map((r) => {
|
|
|
|
|
const clause = clauseMap.get(r.clauseId);
|
|
|
|
|
if (!clause) {
|
|
|
|
|
throw new Error(`Clause descriptor not found for ${r.clauseId}`);
|
|
|
|
|
}
|
|
|
|
|
return generateProof(
|
|
|
|
|
r,
|
|
|
|
|
clause,
|
|
|
|
|
world,
|
|
|
|
|
usedFactsMap.get(r.clauseId),
|
|
|
|
|
derivedFactsMap.get(r.clauseId),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|