v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* AST node definitions for Imhotep authoring syntax.
|
||||
*
|
||||
* All AST nodes follow a unist-style shape:
|
||||
* {
|
||||
* type: string,
|
||||
* position?: Position,
|
||||
* data?: Record<string, unknown>,
|
||||
* children?: AstNode[]
|
||||
* }
|
||||
*
|
||||
* This structure is intentionally rich for tooling, transforms, and diagnostics.
|
||||
*/
|
||||
|
||||
import type { Position, LiteralValue } from './types.js'
|
||||
|
||||
import type {
|
||||
FormulaNode,
|
||||
PropertyRunBlock,
|
||||
SampledRunBlock,
|
||||
} from './logic-ast.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base Node
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AstNode {
|
||||
type: string
|
||||
position?: Position
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ParentNode extends AstNode {
|
||||
children: AstNode[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Program
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Program extends ParentNode {
|
||||
type: 'Program'
|
||||
children: BlockNode[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block-Level Nodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BlockNode =
|
||||
| FrameBlock
|
||||
| AcrossBlock
|
||||
| AlwaysBlock
|
||||
| WhenBlock
|
||||
| FormulaNode
|
||||
| PropertyRunBlock
|
||||
| SampledRunBlock
|
||||
|
||||
export interface FrameBlock extends ParentNode {
|
||||
type: 'FrameBlock'
|
||||
frame: FrameRef
|
||||
children: AssertionNode[]
|
||||
}
|
||||
|
||||
export interface AcrossBlock extends ParentNode {
|
||||
type: 'AcrossBlock'
|
||||
axes: AcrossAxis[]
|
||||
children: BlockNode[]
|
||||
}
|
||||
|
||||
export interface AcrossAxis {
|
||||
name: string
|
||||
values: (string | number)[]
|
||||
}
|
||||
|
||||
export interface AlwaysBlock extends ParentNode {
|
||||
type: 'AlwaysBlock'
|
||||
children: AssertionNode[]
|
||||
}
|
||||
|
||||
export interface WhenBlock extends ParentNode {
|
||||
type: 'WhenBlock'
|
||||
guard: GuardExpression
|
||||
children: AssertionNode[]
|
||||
}
|
||||
|
||||
export interface GuardExpression extends AstNode {
|
||||
type: 'GuardExpression'
|
||||
op: string
|
||||
left: string | number
|
||||
right?: string | number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assertion Nodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AssertionNode =
|
||||
| RelationAssertion
|
||||
| TopologyAssertion
|
||||
| SizeAssertion
|
||||
| StateAssertion
|
||||
| TemporalAssertion
|
||||
| CompoundAssertion
|
||||
| FormulaNode
|
||||
|
||||
export interface RelationAssertion extends AstNode {
|
||||
type: 'RelationAssertion'
|
||||
relation: string
|
||||
subject: SelectorRef
|
||||
reference: SelectorRef
|
||||
options: RelationOptions
|
||||
}
|
||||
|
||||
export interface RelationOptions extends AstNode {
|
||||
type: 'RelationOptions'
|
||||
minGap?: LiteralValue
|
||||
maxGap?: LiteralValue
|
||||
tolerance?: LiteralValue
|
||||
inStackingContext?: boolean
|
||||
}
|
||||
|
||||
export interface TopologyAssertion extends AstNode {
|
||||
type: 'TopologyAssertion'
|
||||
predicate: string
|
||||
subject: SelectorRef
|
||||
reference?: SelectorRef
|
||||
options?: TopologyOptions
|
||||
}
|
||||
|
||||
export interface TopologyOptions extends AstNode {
|
||||
type: 'TopologyOptions'
|
||||
tolerance?: LiteralValue
|
||||
}
|
||||
|
||||
export interface SizeAssertion extends AstNode {
|
||||
type: 'SizeAssertion'
|
||||
property: string
|
||||
subject: SelectorRef
|
||||
bounds: SizeBounds
|
||||
}
|
||||
|
||||
export interface SizeBounds extends AstNode {
|
||||
type: 'SizeBounds'
|
||||
min?: LiteralValue
|
||||
max?: LiteralValue
|
||||
exact?: LiteralValue
|
||||
}
|
||||
|
||||
export interface StateAssertion extends AstNode {
|
||||
type: 'StateAssertion'
|
||||
stateSelector: StateSelector
|
||||
assertion: AssertionNode
|
||||
}
|
||||
|
||||
export interface TemporalAssertion extends AstNode {
|
||||
type: 'TemporalAssertion'
|
||||
timelineSelector: TimelineSelector
|
||||
assertion: AssertionNode
|
||||
}
|
||||
|
||||
export interface CompoundAssertion extends ParentNode {
|
||||
type: 'CompoundAssertion'
|
||||
quantifier?: QuantifierNode
|
||||
operator?: 'and' | 'or'
|
||||
children: AssertionNode[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selector and Reference Nodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SelectorRef extends AstNode {
|
||||
type: 'SelectorRef'
|
||||
value: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Semantic Subject Reference (Stream 7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SubjectReference =
|
||||
| { kind: 'css'; selector: string }
|
||||
| { kind: 'role'; role: string; name?: string }
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'labelText'; label: string }
|
||||
| { kind: 'testId'; testId: string }
|
||||
|
||||
export function subjectReferenceToString(ref: SubjectReference): string {
|
||||
switch (ref.kind) {
|
||||
case 'css':
|
||||
return `css='${ref.selector}'`
|
||||
case 'role':
|
||||
return ref.name
|
||||
? `role='${ref.role}' name='${ref.name}'`
|
||||
: `role='${ref.role}'`
|
||||
case 'text':
|
||||
return `text='${ref.text}'`
|
||||
case 'labelText':
|
||||
return `labelText='${ref.label}'`
|
||||
case 'testId':
|
||||
return `testId='${ref.testId}'`
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
export interface StateSelector extends AstNode {
|
||||
type: 'StateSelector'
|
||||
kind: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface TimelineSelector extends AstNode {
|
||||
type: 'TimelineSelector'
|
||||
mode: string
|
||||
samples?: number[]
|
||||
from?: number
|
||||
to?: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Literal Nodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LiteralNode extends AstNode {
|
||||
type: 'Literal'
|
||||
kind: 'length' | 'range' | 'tolerance' | 'string' | 'number' | 'boolean'
|
||||
value: unknown
|
||||
unit?: string
|
||||
}
|
||||
|
||||
export interface RangeLiteralNode extends AstNode {
|
||||
type: 'RangeLiteral'
|
||||
min: LiteralNode
|
||||
max: LiteralNode
|
||||
}
|
||||
|
||||
export interface ToleranceLiteralNode extends AstNode {
|
||||
type: 'ToleranceLiteral'
|
||||
value: number
|
||||
unit: 'jnd' | 'px'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quantifier Nodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface QuantifierNode extends AstNode {
|
||||
type: 'Quantifier'
|
||||
kind: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame Reference
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FrameRef extends AstNode {
|
||||
type: 'FrameRef'
|
||||
kind: string
|
||||
selector?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AST Type Guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isAstNode(value: unknown): value is AstNode {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'type' in value &&
|
||||
typeof (value as Record<string, unknown>).type === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function isParentNode(node: AstNode): node is ParentNode {
|
||||
return 'children' in node && Array.isArray((node as ParentNode).children)
|
||||
}
|
||||
|
||||
export function isProgram(node: AstNode): node is Program {
|
||||
return node.type === 'Program'
|
||||
}
|
||||
|
||||
export function isFrameBlock(node: AstNode): node is FrameBlock {
|
||||
return node.type === 'FrameBlock'
|
||||
}
|
||||
|
||||
export function isRelationAssertion(node: AstNode): node is RelationAssertion {
|
||||
return node.type === 'RelationAssertion'
|
||||
}
|
||||
|
||||
export function isCompoundAssertion(node: AstNode): node is CompoundAssertion {
|
||||
return node.type === 'CompoundAssertion'
|
||||
}
|
||||
|
||||
export function isFormulaNode(node: AstNode): node is FormulaNode {
|
||||
return node.type === 'FormulaNode'
|
||||
}
|
||||
|
||||
export function isPropertyRunBlock(node: AstNode): node is PropertyRunBlock {
|
||||
return node.type === 'PropertyRunBlock'
|
||||
}
|
||||
|
||||
export function isSampledRunBlock(node: AstNode): node is SampledRunBlock {
|
||||
return node.type === 'SampledRunBlock'
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Determinism proof tests — RED phase.
|
||||
*
|
||||
* These tests assert that Imhotep execution is deterministic when a
|
||||
* seeded context is provided. They currently FAIL because the codebase
|
||||
* relies on global mutable state (module-global id counter, Date.now,
|
||||
* Math.random).
|
||||
*
|
||||
* Run: npm test -w packages/imhotep-core
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import {
|
||||
createDeterministicContext,
|
||||
createVolatileContext,
|
||||
getDefaultContext,
|
||||
setDefaultContext,
|
||||
resetDefaultContext,
|
||||
} from './context.js'
|
||||
|
||||
import { compile } from 'imhotep-dsl'
|
||||
import { createSnapshotId } from 'imhotep-state'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ExecutionContext', () => {
|
||||
beforeEach(() => {
|
||||
resetDefaultContext()
|
||||
})
|
||||
|
||||
it('deterministic context produces identical ids across calls', () => {
|
||||
const ctx = createDeterministicContext(42)
|
||||
const a = ctx.idGenerator()
|
||||
const b = ctx.idGenerator()
|
||||
const c = ctx.idGenerator()
|
||||
assert.deepStrictEqual([a, b, c], ['id-1', 'id-2', 'id-3'])
|
||||
})
|
||||
|
||||
it('deterministic context with same seed produces same rng sequence', () => {
|
||||
const ctx1 = createDeterministicContext(123)
|
||||
const ctx2 = createDeterministicContext(123)
|
||||
const seq1 = Array.from({ length: 5 }, () => ctx1.rng())
|
||||
const seq2 = Array.from({ length: 5 }, () => ctx2.rng())
|
||||
assert.deepStrictEqual(seq1, seq2)
|
||||
})
|
||||
|
||||
it('deterministic context with different seeds produces different rng sequence', () => {
|
||||
const ctx1 = createDeterministicContext(123)
|
||||
const ctx2 = createDeterministicContext(456)
|
||||
const seq1 = Array.from({ length: 5 }, () => ctx1.rng())
|
||||
const seq2 = Array.from({ length: 5 }, () => ctx2.rng())
|
||||
assert.notDeepStrictEqual(seq1, seq2)
|
||||
})
|
||||
|
||||
it('deterministic context clock is stable', () => {
|
||||
const ctx = createDeterministicContext()
|
||||
assert.strictEqual(ctx.clock(), 0)
|
||||
assert.strictEqual(ctx.clock(), 0)
|
||||
})
|
||||
|
||||
it('volatile context clock returns real time', () => {
|
||||
const before = Date.now()
|
||||
const ctx = createVolatileContext()
|
||||
const t = ctx.clock()
|
||||
const after = Date.now()
|
||||
assert.ok(t >= before)
|
||||
assert.ok(t <= after)
|
||||
})
|
||||
|
||||
it('default context is deterministic in test mode', () => {
|
||||
const ctx1 = getDefaultContext()
|
||||
const ctx2 = getDefaultContext()
|
||||
// In test mode, each call returns a fresh deterministic context.
|
||||
assert.notStrictEqual(ctx1, ctx2)
|
||||
assert.strictEqual(typeof ctx1.seed, 'number')
|
||||
assert.strictEqual(ctx1.clock(), 0)
|
||||
assert.strictEqual(ctx2.clock(), 0)
|
||||
})
|
||||
|
||||
it('setDefaultContext overrides the global default', () => {
|
||||
const det = createDeterministicContext(99)
|
||||
setDefaultContext(det)
|
||||
assert.strictEqual(getDefaultContext(), det)
|
||||
assert.strictEqual(getDefaultContext().seed, 99)
|
||||
})
|
||||
|
||||
it('parallel deterministic contexts do not interfere', () => {
|
||||
const ctxA = createDeterministicContext(1)
|
||||
const ctxB = createDeterministicContext(1)
|
||||
const idsA = [ctxA.idGenerator(), ctxA.idGenerator()]
|
||||
const idsB = [ctxB.idGenerator(), ctxB.idGenerator(), ctxB.idGenerator()]
|
||||
assert.deepStrictEqual(idsA, ['id-1', 'id-2'])
|
||||
assert.deepStrictEqual(idsB, ['id-1', 'id-2', 'id-3'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-package determinism proof tests (currently RED)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Determinism across packages', () => {
|
||||
it('default compiler ids are non-deterministic across runs', () => {
|
||||
// RED: defaultNextId uses a module-global counter.
|
||||
// Two compile() calls without options produce different ids.
|
||||
const ast = [
|
||||
{
|
||||
type: 'RelationAssertion' as const,
|
||||
subject: { type: 'SelectorRef' as const, value: '.a' },
|
||||
relation: 'leftOf' as const,
|
||||
reference: { type: 'SelectorRef' as const, value: '.b' },
|
||||
options: { type: 'RelationOptions' as const },
|
||||
position: { start: { line: 1, column: 1, offset: 0 }, end: { line: 1, column: 10, offset: 10 } },
|
||||
},
|
||||
] as const
|
||||
|
||||
const result1 = compile(ast as any)
|
||||
const result2 = compile(ast as any)
|
||||
|
||||
const ids1 = Array.from(result1.semanticIr.clauses.values()).map((c: any) => c.id)
|
||||
const ids2 = Array.from(result2.semanticIr.clauses.values()).map((c: any) => c.id)
|
||||
|
||||
// This assertion documents the desired deterministic behavior.
|
||||
// It currently FAILS because the global counter advances between calls.
|
||||
assert.deepStrictEqual(ids1, ids2, 'clause ids must be deterministic with same context')
|
||||
})
|
||||
|
||||
it('injected compiler ids are deterministic across runs', () => {
|
||||
// GREEN preview: when generateId is injected, ids are deterministic.
|
||||
const ctx1 = createDeterministicContext(7)
|
||||
const ctx2 = createDeterministicContext(7)
|
||||
const ast = [
|
||||
{
|
||||
type: 'RelationAssertion' as const,
|
||||
subject: { type: 'SelectorRef' as const, value: '.a' },
|
||||
relation: 'leftOf' as const,
|
||||
reference: { type: 'SelectorRef' as const, value: '.b' },
|
||||
options: { type: 'RelationOptions' as const },
|
||||
position: { start: { line: 1, column: 1, offset: 0 }, end: { line: 1, column: 10, offset: 10 } },
|
||||
},
|
||||
] as const
|
||||
|
||||
const result1 = compile(ast as any, { generateId: () => ctx1.idGenerator() })
|
||||
const result2 = compile(ast as any, { generateId: () => ctx2.idGenerator() })
|
||||
|
||||
const ids1 = Array.from(result1.semanticIr.clauses.values()).map((c: any) => c.id)
|
||||
const ids2 = Array.from(result2.semanticIr.clauses.values()).map((c: any) => c.id)
|
||||
|
||||
assert.deepStrictEqual(ids1, ids2, 'injected ids must be deterministic with same seed')
|
||||
})
|
||||
|
||||
it('default snapshot ids are deterministic and unique', () => {
|
||||
// With the monotonic counter in createSnapshotId, consecutive calls
|
||||
// produce predictable but unique ids in deterministic test contexts.
|
||||
const id1 = createSnapshotId()
|
||||
const id2 = createSnapshotId()
|
||||
assert.strictEqual(id1, 'snap_0_id-1_1')
|
||||
assert.strictEqual(id2, 'snap_0_id-1_2')
|
||||
assert.notStrictEqual(id1, id2)
|
||||
})
|
||||
|
||||
it('snapshot timestamps are stable with deterministic clock', () => {
|
||||
const ctx = createDeterministicContext(7)
|
||||
const t1 = ctx.clock()
|
||||
const t2 = ctx.clock()
|
||||
assert.strictEqual(t1, t2)
|
||||
assert.strictEqual(t1, 0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Execution context for deterministic and reproducible Imhotep runs.
|
||||
*
|
||||
* Every source of non-determinism (id generation, timestamps, randomness)
|
||||
* is captured behind injectable functions so tests can control them.
|
||||
*
|
||||
* In production, use `createVolatileContext()`. In tests, use
|
||||
* `createDeterministicContext(seed)`.
|
||||
*/
|
||||
|
||||
export interface ExecutionContext {
|
||||
/** Stable id generator (e.g. counter-based or UUID). */
|
||||
idGenerator: () => string
|
||||
/** Stable clock (e.g. fixed start time). */
|
||||
clock: () => number
|
||||
/** Seeded random number generator returning values in [0, 1). */
|
||||
rng: () => number
|
||||
/** The seed driving rng; stored in replay metadata. */
|
||||
seed: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic default (test mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Simple linear congruential generator for deterministic random. */
|
||||
function lcg(seed: number): () => number {
|
||||
let s = seed >>> 0
|
||||
return () => {
|
||||
s = (1664525 * s + 1013904223) >>> 0
|
||||
return s / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
/** Counter-based id generator. */
|
||||
function counterIdGenerator(prefix = 'id'): () => string {
|
||||
let n = 0
|
||||
return () => `${prefix}-${++n}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fully deterministic execution context.
|
||||
*
|
||||
* IDs are counter-based, timestamps start at 0, and rng is a seeded LCG.
|
||||
*/
|
||||
export function createDeterministicContext(seed = 42): ExecutionContext {
|
||||
return {
|
||||
idGenerator: counterIdGenerator('id'),
|
||||
clock: () => 0,
|
||||
rng: lcg(seed),
|
||||
seed,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Volatile context (production)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generate a random 32-bit integer seed. */
|
||||
export function generateSeed(): number {
|
||||
return Math.floor(Math.random() * 1_000_000_000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a volatile execution context using real clocks and Math.random.
|
||||
*
|
||||
* This is the production default. The returned seed can be captured in
|
||||
* replay metadata.
|
||||
*/
|
||||
export function createVolatileContext(seed?: number): ExecutionContext {
|
||||
const actualSeed = seed ?? generateSeed()
|
||||
// Use a fresh LCG even in volatile mode so that the seed is meaningful.
|
||||
// If caller passes a seed we honour it; otherwise we use Math.random.
|
||||
const rng = lcg(actualSeed)
|
||||
return {
|
||||
idGenerator: () => {
|
||||
// Simple nanoid-like fallback (21 chars, base-64 alphabet)
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'
|
||||
let id = ''
|
||||
for (let i = 0; i < 21; i++) {
|
||||
id += alphabet[Math.floor(Math.random() * alphabet.length)]
|
||||
}
|
||||
return id
|
||||
},
|
||||
clock: () => Date.now(),
|
||||
rng,
|
||||
seed: actualSeed,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global default (backward compatible)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _defaultContext: ExecutionContext | undefined
|
||||
|
||||
function isTestEnvironment(): boolean {
|
||||
return (
|
||||
typeof process !== 'undefined' &&
|
||||
!!process.env.NODE_TEST_CONTEXT
|
||||
)
|
||||
}
|
||||
|
||||
/** Return the current default context.
|
||||
*
|
||||
* In production, a single volatile context is created and cached.
|
||||
* In tests (detected via NODE_TEST_CONTEXT), a fresh deterministic
|
||||
* context is returned on each call unless one has been explicitly set
|
||||
* via setDefaultContext().
|
||||
*/
|
||||
export function getDefaultContext(): ExecutionContext {
|
||||
if (_defaultContext) {
|
||||
return _defaultContext
|
||||
}
|
||||
if (isTestEnvironment()) {
|
||||
return createDeterministicContext()
|
||||
}
|
||||
_defaultContext = createVolatileContext()
|
||||
return _defaultContext
|
||||
}
|
||||
|
||||
/** Set the global default context (useful for test setup). */
|
||||
export function setDefaultContext(ctx: ExecutionContext): void {
|
||||
_defaultContext = ctx
|
||||
}
|
||||
|
||||
/** Reset the global default context so the next call creates a fresh one. */
|
||||
export function resetDefaultContext(): void {
|
||||
_defaultContext = undefined
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Extractor and solver contract interfaces for Imhotep.
|
||||
*
|
||||
* The extractor is a compiler target: it consumes a fact request plan and
|
||||
* produces a geometry world. The solver operates on explicit clause families
|
||||
* with declared fact requirements and deterministic outputs.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ImhotepId,
|
||||
Environment,
|
||||
StateSnapshot,
|
||||
TimelineSnapshot,
|
||||
Subject,
|
||||
ClauseResult,
|
||||
GroupResult,
|
||||
Proof,
|
||||
} from './types.js'
|
||||
|
||||
import type { GeometryWorld, FactRequestPlan } from './world.js'
|
||||
|
||||
import type { ExtractionTrace, EvaluationTrace, Diagnostic } from './diagnostics.js'
|
||||
|
||||
import type { SceneTarget, RenderCase } from './scene-target.js'
|
||||
|
||||
export type { SceneTarget, RenderCase }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extractor Request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExtractorRequest {
|
||||
requestId: ImhotepId
|
||||
sceneTarget: SceneTarget
|
||||
env: Environment
|
||||
statePlan: {
|
||||
snapshots: string[]
|
||||
}
|
||||
timelinePlan: {
|
||||
mode: string
|
||||
}
|
||||
subjects: Array<{
|
||||
id: ImhotepId
|
||||
selector: string
|
||||
}>
|
||||
requiredFacts: FactRequestPlan
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extractor Response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExtractorResponse {
|
||||
requestId: ImhotepId
|
||||
status: 'ok' | 'partial' | 'error'
|
||||
snapshots: GeometryWorld[]
|
||||
diagnostics: Diagnostic[]
|
||||
extractionTrace: ExtractionTrace
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extractor Contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExtractorContract {
|
||||
/**
|
||||
* Consume a fact request plan and produce a geometry world.
|
||||
*/
|
||||
extract(request: ExtractorRequest, runtime: unknown): Promise<ExtractorResponse>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extractor Adapter Interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExtractorAdapter {
|
||||
name: string
|
||||
version: string
|
||||
supportedFacts: string[]
|
||||
execute(request: ExtractorRequest): Promise<AdapterExtractionResult>
|
||||
}
|
||||
|
||||
export interface AdapterExtractionResult {
|
||||
status: 'ok' | 'partial' | 'error'
|
||||
world: GeometryWorld
|
||||
diagnostics: Diagnostic[]
|
||||
trace: ExtractionTrace
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Solver Clause Contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SolverClauseContract {
|
||||
/**
|
||||
* Descriptor declaring what facts this clause family requires.
|
||||
*/
|
||||
descriptor: ClauseDescriptor
|
||||
|
||||
/**
|
||||
* Evaluate a single clause against the geometry world.
|
||||
*/
|
||||
evaluate(world: GeometryWorld, clauseId: ImhotepId, traceBuilder: TraceBuilder): ClauseResult
|
||||
}
|
||||
|
||||
export interface ClauseDescriptor {
|
||||
clauseKind: string
|
||||
version: number
|
||||
requiredFacts: string[]
|
||||
}
|
||||
|
||||
export interface TraceBuilder {
|
||||
addStep(step: unknown): void
|
||||
build(): unknown[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Evaluation Result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EvaluationResult {
|
||||
clauseResults: ClauseResult[]
|
||||
groupResults: GroupResult[]
|
||||
proofs: Proof[]
|
||||
diagnostics: Diagnostic[]
|
||||
trace: EvaluationTrace
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Solver Evaluation Contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SolverContract {
|
||||
/**
|
||||
* Evaluate compiled execution IR against a set of geometry worlds.
|
||||
*/
|
||||
evaluate(executionIr: unknown, worldSet: GeometryWorld[]): EvaluationResult
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compiler Contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CompilerContract {
|
||||
compile(input: string | unknown): CompilerOutput
|
||||
}
|
||||
|
||||
export interface CompilerOutput {
|
||||
ast: unknown
|
||||
semanticIr: unknown
|
||||
executionIr: unknown
|
||||
diagnostics: Diagnostic[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reporter Contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ReporterContract {
|
||||
report(evaluationResult: EvaluationResult, options: ReporterOptions): ReporterOutput
|
||||
}
|
||||
|
||||
export interface ReporterOptions {
|
||||
format: 'text' | 'json' | 'html'
|
||||
includeTrace: boolean
|
||||
includeProofs: boolean
|
||||
}
|
||||
|
||||
export interface ReporterOutput {
|
||||
text: string
|
||||
json: string
|
||||
overlays: unknown[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fact Requirement Analysis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FactRequirementAnalysis {
|
||||
requiredFacts: string[]
|
||||
optionalFacts: string[]
|
||||
unsupportedFacts: string[]
|
||||
plan: FactRequestPlan
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene Closure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SceneClosure {
|
||||
subjects: Map<ImhotepId, Subject>
|
||||
frames: Map<ImhotepId, unknown>
|
||||
states: Map<ImhotepId, StateSnapshot>
|
||||
timelines: Map<ImhotepId, TimelineSnapshot>
|
||||
resolved: boolean
|
||||
diagnostics: Diagnostic[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation Contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ValidationContract {
|
||||
validateAst(ast: unknown): ValidationResult
|
||||
validateSemanticIr(semanticIr: unknown): ValidationResult
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean
|
||||
diagnostics: Diagnostic[]
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* Diagnostic and trace types for Imhotep.
|
||||
*
|
||||
* Imhotep maintains two major error classes:
|
||||
* 1. Contract failure diagnostics — the UI violated a declared law
|
||||
* 2. System use diagnostics — the user or engine could not formulate
|
||||
* or evaluate the request correctly
|
||||
*
|
||||
* The diagnostic model is inspired by vfile / vfile-message ecosystems.
|
||||
*/
|
||||
|
||||
import type { ImhotepId, Position, ClauseResult, Proof } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diagnostic Categories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DiagnosticCategory =
|
||||
| 'parse-error'
|
||||
| 'validation-error'
|
||||
| 'resolution-error'
|
||||
| 'extraction-error'
|
||||
| 'contract-failure'
|
||||
| 'indeterminate-result'
|
||||
| 'internal-error'
|
||||
|
||||
export type DiagnosticSeverity = 'error' | 'warning' | 'info'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diagnostic Error Codes (Canonical Registry)
|
||||
// ---------------------------------------------------------------------------
|
||||
// All packages MUST use codes from this union. No package may invent a
|
||||
// code that is not listed here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DiagnosticCode =
|
||||
// -------------------------------------------------------------------------
|
||||
// Parse errors (imhotep-dsl)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_PARSE_UNEXPECTED_TOKEN'
|
||||
| 'IMH_PARSE_INVALID_SYNTAX'
|
||||
| 'IMH_PARSE_UNTERMINATED_BLOCK'
|
||||
| 'IMH_PARSE_ERROR'
|
||||
| 'IMH_PARSE_IS_KEYWORD'
|
||||
| 'IMH_PARSE_MISSING_QUOTES'
|
||||
| 'IMH_PARSE_WRONG_QUOTE_STYLE'
|
||||
| 'IMH_PARSE_MISSING_UNIT'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validation errors (imhotep-dsl)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_VALID_ILLEGAL_RELATION_OPTION'
|
||||
| 'IMH_VALID_INVALID_UNIT'
|
||||
| 'IMH_VALID_INVALID_STATE_TIMELINE'
|
||||
| 'IMH_VALID_INVALID_ENV_GUARD'
|
||||
| 'IMH_VALID_INVALID_QUANTIFIER_NESTING'
|
||||
| 'IMH_VALID_CONTRADICTION'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Resolution errors (imhotep-playwright, imhotep-extractor)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_SELECTOR_ZERO_MATCHES'
|
||||
| 'IMH_SELECTOR_AMBIGUOUS'
|
||||
| 'IMH_SELECTOR_NO_MATCH'
|
||||
| 'IMH_SELECTOR_RESOLUTION_FAILED'
|
||||
| 'IMH_FRAME_AMBIGUOUS'
|
||||
| 'IMH_FRAME_UNSUPPORTED'
|
||||
| 'IMH_TOPOLOGY_UNSUPPORTED'
|
||||
| 'IMH_STATE_MATERIALIZATION_FAILED'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Extraction errors (imhotep-cdp, imhotep-extractor)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_EXTRACT_PARTIAL'
|
||||
| 'IMH_EXTRACT_UNAVAILABLE_FACT'
|
||||
| 'IMH_EXTRACT_PROTOCOL_ERROR'
|
||||
| 'IMH_CDP_SESSION_ATTACH_FAILED'
|
||||
| 'IMH_DOM_EXTRACTION_FAILED'
|
||||
| 'IMH_BOX_MODEL_PARTIAL'
|
||||
| 'IMH_BOX_MODEL_FAILED'
|
||||
| 'IMH_VISUAL_BOX_PARTIAL'
|
||||
| 'IMH_VISUAL_BOX_FAILED'
|
||||
| 'IMH_FRAGMENT_PARTIAL'
|
||||
| 'IMH_FRAGMENT_FAILED'
|
||||
| 'IMH_TRANSFORM_PARTIAL'
|
||||
| 'IMH_TRANSFORM_FAILED'
|
||||
| 'IMH_STYLE_PARTIAL'
|
||||
| 'IMH_STYLE_FAILED'
|
||||
| 'IMH_TOPOLOGY_PARTIAL'
|
||||
| 'IMH_TOPOLOGY_FAILED'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Extractor planner errors (imhotep-extractor)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_EXTRACTOR_EMPTY_SELECTOR'
|
||||
| 'IMH_EXTRACTOR_MISSING_ENV_GUARD'
|
||||
| 'IMH_EXTRACTOR_UNRESOLVED_ENV_GUARD'
|
||||
| 'IMH_EXTRACTOR_MISSING_STATE'
|
||||
| 'IMH_EXTRACTOR_UNRESOLVED_STATE'
|
||||
| 'IMH_EXTRACTOR_UNSUPPORTED_STATE'
|
||||
| 'IMH_EXTRACTOR_NON_STATIC_TIMELINE'
|
||||
| 'IMH_EXTRACTOR_UNSUPPORTED_CLAUSE_TYPE'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Contract failures (imhotep-solver, imhotep-playwright)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_RELATION_LEFT_OF_FAILED'
|
||||
| 'IMH_RELATION_RIGHT_OF_FAILED'
|
||||
| 'IMH_RELATION_ABOVE_FAILED'
|
||||
| 'IMH_RELATION_BELOW_FAILED'
|
||||
| 'IMH_RELATION_ALIGNED_FAILED'
|
||||
| 'IMH_RELATION_CENTERED_FAILED'
|
||||
| 'IMH_RELATION_INSIDE_FAILED'
|
||||
| 'IMH_RELATION_CONTAINS_FAILED'
|
||||
| 'IMH_RELATION_OVERLAPS_FAILED'
|
||||
| 'IMH_RELATION_BESIDE_FAILED'
|
||||
| 'IMH_RELATION_ADJACENT_FAILED'
|
||||
| 'IMH_RELATION_NEAR_FAILED'
|
||||
| 'IMH_RELATION_FAILED'
|
||||
| 'IMH_SIZE_AT_LEAST_FAILED'
|
||||
| 'IMH_SIZE_AT_MOST_FAILED'
|
||||
| 'IMH_SIZE_BETWEEN_FAILED'
|
||||
| 'IMH_ALIGNMENT_FAILED'
|
||||
| 'IMH_TOPOLOGY_CLIPPED_FAILED'
|
||||
| 'IMH_TOPOLOGY_STACKING_FAILED'
|
||||
| 'IMH_VISIBILITY_FAILED'
|
||||
| 'IMH_PREDICATE_FAILED'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cardinality failures (imhotep-playwright)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_CARDINALITY_EXACTLYONE_FAILED'
|
||||
| 'IMH_CARDINALITY_ATLEASTN_FAILED'
|
||||
| 'IMH_CARDINALITY_ATMOSTN_FAILED'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Indeterminate results
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_INDETERMINATE_MISSING_FACT'
|
||||
| 'IMH_INDETERMINATE_UNSTABLE_INPUT'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal errors
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_INTERNAL_UNKNOWN_CLAUSE_KIND'
|
||||
| 'IMH_INTERNAL_EVALUATION_EXCEPTION'
|
||||
| 'IMH_FEATURE_NOT_YET_IMPLEMENTED'
|
||||
| 'IMH_UNKNOWN_FAILURE'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// System use / fact observation diagnostics
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_FACT_OBSERVED_GAP'
|
||||
| 'IMH_FACT_OBSERVED_SIZE'
|
||||
| 'IMH_FACT_OBSERVED_TOPOLOGY'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Logic engine errors (imhotep-solver)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_LOGIC_UNKNOWN_FORMULA_KIND'
|
||||
| 'IMH_LOGIC_DOMAIN_UNRESOLVED'
|
||||
| 'IMH_LOGIC_VACUOUS_FORALL'
|
||||
| 'IMH_LOGIC_EMPTY_DOMAIN_EXISTS'
|
||||
| 'IMH_LOGIC_PREDICATE_MISSING'
|
||||
| 'IMH_LOGIC_UNBOUND_VARIABLE'
|
||||
| 'IMH_LOGIC_UNSUPPORTED_TERM'
|
||||
| 'IMH_LOGIC_UNKNOWN_NODE'
|
||||
| 'IMH_LOGIC_ARITY_MISMATCH'
|
||||
| 'IMH_LOGIC_UNKNOWN_PREDICATE'
|
||||
| 'IMH_LOGIC_FREE_VARIABLE'
|
||||
| 'IMH_LOGIC_EMPTY_DOMAIN'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Solver engine errors (imhotep-solver)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_EVALUATOR_MISSING'
|
||||
| 'IMH_EVALUATOR_EXCEPTION'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Quantifier errors (imhotep-solver)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_QUANTIFIER_NO_SUBCLAUSES'
|
||||
| 'IMH_PAIRWISE_INSUFFICIENT'
|
||||
| 'IMH_UNKNOWN_QUANTIFIER'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Property-run diagnostics (imhotep-playwright)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_ENUMERATED_RUN_ERROR'
|
||||
| 'IMH_SAMPLED_RUN_ERROR'
|
||||
| 'IMH_PROPERTY_RUN_FAILED'
|
||||
| 'IMH_PROPERTY_PASSED'
|
||||
| 'IMH_PROPERTY_FAILED'
|
||||
| 'IMH_PROPERTY_REPLAY'
|
||||
| 'IMH_PROPERTY_SHRUNK'
|
||||
| 'IMH_ENUMERATED_PASSED'
|
||||
| 'IMH_ENUMERATED_FAILED'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reporter internal diagnostics (imhotep-reporter)
|
||||
// -------------------------------------------------------------------------
|
||||
| 'IMH_FRAME_CONTEXT'
|
||||
| 'IMH_WITNESS_ENV'
|
||||
| 'IMH_WITNESS_SNAPSHOT'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Related Diagnostic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RelatedDiagnostic {
|
||||
code?: DiagnosticCode
|
||||
message: string
|
||||
position?: Position
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V1 Diagnostic Schema Extensions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SourceRef {
|
||||
file?: string
|
||||
line?: number
|
||||
column?: number
|
||||
selector?: string
|
||||
}
|
||||
|
||||
export interface SuggestedFix {
|
||||
action: 'remove' | 'replace' | 'insert' | 'wrap'
|
||||
target?: string
|
||||
value?: string
|
||||
rationale: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diagnostic Shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Diagnostic {
|
||||
code: DiagnosticCode
|
||||
severity: DiagnosticSeverity
|
||||
category: DiagnosticCategory
|
||||
message: string
|
||||
position?: Position
|
||||
source: string
|
||||
clauseId?: ImhotepId
|
||||
sceneId?: ImhotepId
|
||||
snapshotId?: ImhotepId
|
||||
envCaseId?: ImhotepId
|
||||
/** Subject resolution origin for semantic selectors (Stream 7). */
|
||||
subjectOrigin?: string
|
||||
related: RelatedDiagnostic[]
|
||||
traceRef?: ImhotepId
|
||||
fixHints: string[]
|
||||
/** V1 required: structured metrics associated with this diagnostic. */
|
||||
metrics: Record<string, number>
|
||||
/** V1 required: canonical source reference. */
|
||||
sourceRef: SourceRef | import('./types.js').SourceReference
|
||||
/** V1 optional: automated fix suggestion. */
|
||||
suggestedFix?: SuggestedFix
|
||||
/** Human-readable label derived from the normalized contract (workstream C). */
|
||||
clauseLabel?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trace Events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TracePhase =
|
||||
| 'parse-started'
|
||||
| 'ast-created'
|
||||
| 'ir-normalized'
|
||||
| 'fact-requirements-computed'
|
||||
| 'extraction-started'
|
||||
| 'extraction-step-completed'
|
||||
| 'world-normalized'
|
||||
| 'clause-evaluated'
|
||||
| 'proof-created'
|
||||
| 'witness-shrunk'
|
||||
| 'diagnostic-emitted'
|
||||
|
||||
export interface TraceEvent {
|
||||
traceEventId: ImhotepId
|
||||
phase: TracePhase
|
||||
at: number
|
||||
refs: {
|
||||
clauseId?: ImhotepId
|
||||
proofId?: ImhotepId
|
||||
snapshotId?: ImhotepId
|
||||
diagnosticId?: ImhotepId
|
||||
}
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface EvaluationTrace {
|
||||
events: TraceEvent[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Witness Shrinking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ShrinkAxis {
|
||||
name: 'environment' | 'state' | 'timeline' | 'subject' | 'facts' | 'clauseGroup'
|
||||
reduced: boolean
|
||||
remainingValue: unknown
|
||||
}
|
||||
|
||||
export interface ShrinkResult {
|
||||
originalWitness: Proof['witness']
|
||||
shrunkWitness: Proof['witness']
|
||||
axes: ShrinkAxis[]
|
||||
preserved: boolean
|
||||
explanation: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extraction Trace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExtractionTrace {
|
||||
steps: ExtractionStep[]
|
||||
timings: TimingEntry[]
|
||||
protocolCalls: ProtocolCall[]
|
||||
}
|
||||
|
||||
export interface ExtractionStep {
|
||||
stepId: ImhotepId
|
||||
factKind: string
|
||||
status: 'ok' | 'partial' | 'error'
|
||||
}
|
||||
|
||||
export interface TimingEntry {
|
||||
stepId: ImhotepId
|
||||
startMs: number
|
||||
endMs: number
|
||||
}
|
||||
|
||||
export interface ProtocolCall {
|
||||
stepId: ImhotepId
|
||||
protocol: string
|
||||
method: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diagnostic Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createDiagnostic(
|
||||
code: DiagnosticCode,
|
||||
category: DiagnosticCategory,
|
||||
severity: DiagnosticSeverity,
|
||||
message: string,
|
||||
opts?: Partial<Omit<Diagnostic, 'code' | 'category' | 'severity' | 'message'>>,
|
||||
): Diagnostic {
|
||||
return {
|
||||
code,
|
||||
severity,
|
||||
category,
|
||||
message,
|
||||
source: opts?.source ?? 'imhotep-core',
|
||||
related: opts?.related ?? [],
|
||||
fixHints: opts?.fixHints ?? getDefaultFixHints(code),
|
||||
metrics: opts?.metrics ?? {},
|
||||
sourceRef: opts?.sourceRef ?? {},
|
||||
...opts,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default Severity / Category / Fix-Hint Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return the default severity for a canonical diagnostic code.
|
||||
* This is a fallback; callers should override when context demands it.
|
||||
*/
|
||||
export function getDefaultSeverity(code: DiagnosticCode): DiagnosticSeverity {
|
||||
switch (code) {
|
||||
// Warnings
|
||||
case 'IMH_SELECTOR_AMBIGUOUS':
|
||||
case 'IMH_FRAME_AMBIGUOUS':
|
||||
case 'IMH_SELECTOR_NO_MATCH':
|
||||
case 'IMH_EXTRACT_PARTIAL':
|
||||
case 'IMH_BOX_MODEL_PARTIAL':
|
||||
case 'IMH_VISUAL_BOX_PARTIAL':
|
||||
case 'IMH_FRAGMENT_PARTIAL':
|
||||
case 'IMH_TRANSFORM_PARTIAL':
|
||||
case 'IMH_STYLE_PARTIAL':
|
||||
case 'IMH_TOPOLOGY_PARTIAL':
|
||||
case 'IMH_INDETERMINATE_MISSING_FACT':
|
||||
case 'IMH_INDETERMINATE_UNSTABLE_INPUT':
|
||||
return 'warning'
|
||||
|
||||
// Info
|
||||
case 'IMH_LOGIC_VACUOUS_FORALL':
|
||||
case 'IMH_LOGIC_EMPTY_DOMAIN_EXISTS':
|
||||
case 'IMH_FACT_OBSERVED_GAP':
|
||||
case 'IMH_FACT_OBSERVED_SIZE':
|
||||
case 'IMH_FACT_OBSERVED_TOPOLOGY':
|
||||
case 'IMH_PROPERTY_PASSED':
|
||||
case 'IMH_PROPERTY_REPLAY':
|
||||
case 'IMH_ENUMERATED_PASSED':
|
||||
case 'IMH_FRAME_CONTEXT':
|
||||
case 'IMH_WITNESS_ENV':
|
||||
case 'IMH_WITNESS_SNAPSHOT':
|
||||
return 'info'
|
||||
|
||||
// Everything else defaults to error
|
||||
default:
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the default category for a canonical diagnostic code.
|
||||
*/
|
||||
export function getDefaultCategory(code: DiagnosticCode): DiagnosticCategory {
|
||||
if (code.startsWith('IMH_PARSE_')) return 'parse-error'
|
||||
if (code.startsWith('IMH_VALID_')) return 'validation-error'
|
||||
if (code.startsWith('IMH_SELECTOR_') || code.startsWith('IMH_FRAME_') || code.startsWith('IMH_STATE_MATERIALIZATION')) return 'resolution-error'
|
||||
if (code.startsWith('IMH_EXTRACTOR_')) return 'resolution-error'
|
||||
if (code.startsWith('IMH_EXTRACT_') || code.startsWith('IMH_CDP_') || code.startsWith('IMH_DOM_') || code.startsWith('IMH_BOX_MODEL') || code.startsWith('IMH_VISUAL_BOX') || code.startsWith('IMH_FRAGMENT') || code.startsWith('IMH_TRANSFORM') || code.startsWith('IMH_STYLE') || code.startsWith('IMH_TOPOLOGY_PARTIAL') || code.startsWith('IMH_TOPOLOGY_FAILED')) return 'extraction-error'
|
||||
if (code.startsWith('IMH_RELATION_') || code.startsWith('IMH_SIZE_') || code.startsWith('IMH_ALIGNMENT') || code.startsWith('IMH_TOPOLOGY_CLIPPED') || code.startsWith('IMH_TOPOLOGY_STACKING') || code.startsWith('IMH_VISIBILITY') || code.startsWith('IMH_PREDICATE') || code.startsWith('IMH_CARDINALITY')) return 'contract-failure'
|
||||
if (code.startsWith('IMH_FACT_OBSERVED_')) return 'contract-failure'
|
||||
if (code.startsWith('IMH_PROPERTY_') || code.startsWith('IMH_ENUMERATED_') || code === 'IMH_PROPERTY_RUN_FAILED') return 'contract-failure'
|
||||
if (code.startsWith('IMH_INDETERMINATE_')) return 'indeterminate-result'
|
||||
if (code.startsWith('IMH_INTERNAL_') || code.startsWith('IMH_FEATURE_') || code === 'IMH_UNKNOWN_FAILURE') return 'internal-error'
|
||||
if (code.startsWith('IMH_LOGIC_') || code.startsWith('IMH_EVALUATOR_') || code.startsWith('IMH_QUANTIFIER_') || code === 'IMH_UNKNOWN_QUANTIFIER') return 'internal-error'
|
||||
if (code.startsWith('IMH_FRAME_CONTEXT') || code.startsWith('IMH_WITNESS_')) return 'internal-error'
|
||||
return 'internal-error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Return default fix-hints for a canonical diagnostic code.
|
||||
* Callers should augment or override these with context-specific hints.
|
||||
*/
|
||||
export function getDefaultFixHints(code: DiagnosticCode): string[] {
|
||||
const hints: string[] = []
|
||||
|
||||
if (code.startsWith('IMH_PARSE_')) {
|
||||
hints.push('Selectors must be single-quoted strings, e.g. \'[data-testid="x"]\'.')
|
||||
hints.push('Use relation keywords directly without "is": e.g. \'a\' leftOf \'b\'.')
|
||||
hints.push('Gap values require units: e.g. gap 8px.')
|
||||
}
|
||||
|
||||
if (code === 'IMH_SELECTOR_ZERO_MATCHES' || code === 'IMH_SELECTOR_NO_MATCH') {
|
||||
hints.push('Check that the selector is valid and matches an element in the DOM.')
|
||||
hints.push('Use ui.extract(selector) to verify the selector resolves to at least one element.')
|
||||
}
|
||||
|
||||
if (code === 'IMH_SELECTOR_AMBIGUOUS') {
|
||||
hints.push('The selector matches more than one element. Use a more specific selector or add a quantifier.')
|
||||
}
|
||||
|
||||
if (code === 'IMH_EXTRACT_PROTOCOL_ERROR' || code === 'IMH_CDP_SESSION_ATTACH_FAILED') {
|
||||
hints.push('Verify the page is fully loaded before running assertions.')
|
||||
hints.push('Check that selectors are valid CSS selectors or semantic references.')
|
||||
hints.push('Use ui.extract(selector) to debug selector resolution.')
|
||||
}
|
||||
|
||||
if (code === 'IMH_FEATURE_NOT_YET_IMPLEMENTED') {
|
||||
hints.push('This assertion uses a relation that is documented but not yet implemented.')
|
||||
hints.push('Use a supported relation such as leftOf, rightOf, above, below, inside, overlaps, alignedWith, or centeredWithin.')
|
||||
}
|
||||
|
||||
if (code.startsWith('IMH_RELATION_') || code === 'IMH_ALIGNMENT_FAILED' || code === 'IMH_PREDICATE_FAILED') {
|
||||
hints.push('Verify the expected layout and consider adjusting thresholds.')
|
||||
hints.push('Check element positions using ui.extract(selector) to inspect actual geometry.')
|
||||
}
|
||||
|
||||
if (code.startsWith('IMH_SIZE_')) {
|
||||
hints.push('Check the expected size threshold and the actual element dimensions using ui.extract(selector).')
|
||||
}
|
||||
|
||||
if (code.startsWith('IMH_CARDINALITY_')) {
|
||||
hints.push('Verify the selector matches the expected number of elements.')
|
||||
hints.push('Use a more specific selector or adjust the cardinality bound.')
|
||||
}
|
||||
|
||||
if (code.startsWith('IMH_LOGIC_')) {
|
||||
hints.push('Check formula syntax and ensure all referenced predicates and domains are defined.')
|
||||
}
|
||||
|
||||
if (code.startsWith('IMH_TOPOLOGY_')) {
|
||||
hints.push('Check CSS properties that affect topology (position, overflow, contain, z-index).')
|
||||
}
|
||||
|
||||
if (code === 'IMH_PROPERTY_RUN_FAILED') {
|
||||
hints.push('Review the failing counterexample and replay with the same seed.')
|
||||
}
|
||||
|
||||
if (code.startsWith('IMH_ENUMERATED_RUN_ERROR') || code.startsWith('IMH_SAMPLED_RUN_ERROR')) {
|
||||
hints.push('Check that the property callback does not throw for the reported input.')
|
||||
}
|
||||
|
||||
return hints
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Category Classification Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isParseError(diagnostic: Diagnostic): boolean {
|
||||
return diagnostic.category === 'parse-error'
|
||||
}
|
||||
|
||||
export function isContractFailure(diagnostic: Diagnostic): boolean {
|
||||
return diagnostic.category === 'contract-failure'
|
||||
}
|
||||
|
||||
export function isSystemUseError(diagnostic: Diagnostic): boolean {
|
||||
return (
|
||||
diagnostic.category === 'parse-error' ||
|
||||
diagnostic.category === 'validation-error' ||
|
||||
diagnostic.category === 'resolution-error' ||
|
||||
diagnostic.category === 'extraction-error' ||
|
||||
diagnostic.category === 'internal-error'
|
||||
)
|
||||
}
|
||||
|
||||
export function isIndeterminate(diagnostic: Diagnostic): boolean {
|
||||
return diagnostic.category === 'indeterminate-result'
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Domain definitions for deterministic scene logic.
|
||||
*
|
||||
* A domain is a finite set of geometry subjects extracted from a scene.
|
||||
* Domains are the ground over which quantifiers range.
|
||||
*
|
||||
* Invariant: every domain is enumerable and closed within a single
|
||||
* materialized GeometryWorld. No domain may silently default to an
|
||||
* empty set; empty domains must be reported explicitly.
|
||||
*/
|
||||
|
||||
import type { ImhotepId, SubjectKind } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain Descriptor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DomainDescriptor {
|
||||
domainId: ImhotepId
|
||||
kind: DomainKind
|
||||
selector?: string
|
||||
parentVar?: string
|
||||
subjectKind?: SubjectKind
|
||||
}
|
||||
|
||||
export type DomainKind =
|
||||
| 'elements'
|
||||
| 'descendants'
|
||||
| 'lineBoxes'
|
||||
| 'textRuns'
|
||||
| 'fragments'
|
||||
| 'frames'
|
||||
| 'custom'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain Value (materialized)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DomainValue {
|
||||
domainId: ImhotepId
|
||||
/** Stable numeric subject ids, stored as a flat array for fast iteration. */
|
||||
subjectIds: Uint32Array
|
||||
/** Source selector or domain expression that produced this set. */
|
||||
provenance: string
|
||||
/** If true, the domain is known to be complete for the scene. */
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Built-in Domain Functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DomainFunctions {
|
||||
/** Select elements matching a CSS selector. */
|
||||
elements(selector: string): DomainDescriptor
|
||||
|
||||
/** Select descendants of a bound parent variable matching a selector. */
|
||||
descendants(parentVar: string, selector: string): DomainDescriptor
|
||||
|
||||
/** Select line boxes for a given text node subject. */
|
||||
lineBoxes(textNodeId: ImhotepId): DomainDescriptor
|
||||
|
||||
/** Select text runs for a given text node subject. */
|
||||
textRuns(textNodeId: ImhotepId): DomainDescriptor
|
||||
|
||||
/** Select fragment boxes for a given subject. */
|
||||
fragments(subjectId: ImhotepId): DomainDescriptor
|
||||
|
||||
/** Select frames of a given kind. */
|
||||
frames(frameKind: string): DomainDescriptor
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain Enumeration Result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DomainEnumeration {
|
||||
domainId: ImhotepId
|
||||
subjectIds: Uint32Array
|
||||
empty: boolean
|
||||
diagnostic?: DomainDiagnostic
|
||||
}
|
||||
|
||||
export interface DomainDiagnostic {
|
||||
code: string
|
||||
message: string
|
||||
domainId: ImhotepId
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default Domain Registry Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class DomainRegistry {
|
||||
private registry = new Map<ImhotepId, DomainDescriptor>()
|
||||
|
||||
register(descriptor: DomainDescriptor): void {
|
||||
this.registry.set(descriptor.domainId, descriptor)
|
||||
}
|
||||
|
||||
lookup(domainId: ImhotepId): DomainDescriptor | undefined {
|
||||
return this.registry.get(domainId)
|
||||
}
|
||||
|
||||
list(): DomainDescriptor[] {
|
||||
return Array.from(this.registry.values())
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.registry.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Global default instance for backward compatibility.
|
||||
const globalDomainRegistry = new DomainRegistry()
|
||||
|
||||
export function registerDomain(descriptor: DomainDescriptor): void {
|
||||
globalDomainRegistry.register(descriptor)
|
||||
}
|
||||
|
||||
export function lookupDomain(domainId: ImhotepId): DomainDescriptor | undefined {
|
||||
return globalDomainRegistry.lookup(domainId)
|
||||
}
|
||||
|
||||
export function listDomains(): DomainDescriptor[] {
|
||||
return globalDomainRegistry.list()
|
||||
}
|
||||
|
||||
export function clearDomainRegistry(): void {
|
||||
globalDomainRegistry.clear()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createElementsDomain(
|
||||
domainId: ImhotepId,
|
||||
selector: string,
|
||||
): DomainDescriptor {
|
||||
return {
|
||||
domainId,
|
||||
kind: 'elements',
|
||||
selector,
|
||||
}
|
||||
}
|
||||
|
||||
export function createDescendantsDomain(
|
||||
domainId: ImhotepId,
|
||||
parentVar: string,
|
||||
selector: string,
|
||||
): DomainDescriptor {
|
||||
return {
|
||||
domainId,
|
||||
kind: 'descendants',
|
||||
parentVar,
|
||||
selector,
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineBoxesDomain(
|
||||
domainId: ImhotepId,
|
||||
textNodeId: ImhotepId,
|
||||
): DomainDescriptor {
|
||||
return {
|
||||
domainId,
|
||||
kind: 'lineBoxes',
|
||||
selector: textNodeId,
|
||||
}
|
||||
}
|
||||
|
||||
export function createTextRunsDomain(
|
||||
domainId: ImhotepId,
|
||||
textNodeId: ImhotepId,
|
||||
): DomainDescriptor {
|
||||
return {
|
||||
domainId,
|
||||
kind: 'textRuns',
|
||||
selector: textNodeId,
|
||||
}
|
||||
}
|
||||
|
||||
export function createFragmentsDomain(
|
||||
domainId: ImhotepId,
|
||||
subjectId: ImhotepId,
|
||||
): DomainDescriptor {
|
||||
return {
|
||||
domainId,
|
||||
kind: 'fragments',
|
||||
selector: subjectId,
|
||||
}
|
||||
}
|
||||
|
||||
export function createFramesDomain(
|
||||
domainId: ImhotepId,
|
||||
frameKind: string,
|
||||
): DomainDescriptor {
|
||||
return {
|
||||
domainId,
|
||||
kind: 'frames',
|
||||
selector: frameKind,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain Value Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createDomainValue(
|
||||
domainId: ImhotepId,
|
||||
subjectIds: Uint32Array,
|
||||
provenance: string,
|
||||
closed = true,
|
||||
): DomainValue {
|
||||
return {
|
||||
domainId,
|
||||
subjectIds,
|
||||
provenance,
|
||||
closed,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Tests for geometry cache serialization and deserialization.
|
||||
*/
|
||||
|
||||
import { test, describe } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
serializeGeometryWorld,
|
||||
deserializeGeometryWorld,
|
||||
computeGeometryCacheKey,
|
||||
readCachedWorld,
|
||||
writeCachedWorld,
|
||||
clearGeometryCache,
|
||||
readCachedExtractionResult,
|
||||
writeCachedExtractionResult,
|
||||
} from './geometry-cache.js'
|
||||
import { createEmptyWorld } from './world.js'
|
||||
import type { GeometryWorld } from './world.js'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
describe('geometry cache', () => {
|
||||
function createTestWorld(): GeometryWorld {
|
||||
const world = createEmptyWorld('scene_1', 'snap_1')
|
||||
world.env.viewportWidth = 1280
|
||||
world.env.viewportHeight = 720
|
||||
world.source.url = 'https://example.com'
|
||||
world.strings.values = ['hello', 'world']
|
||||
|
||||
// Populate subjects table with some data
|
||||
world.subjects.ids = new Uint32Array([1, 2, 3])
|
||||
world.subjects.domNodeId = new Uint32Array([10, 20, 30])
|
||||
world.subjects.subjectKind = new Uint16Array([1, 1, 2])
|
||||
world.subjects.primaryBoxId = new Uint32Array([100, 200, 300])
|
||||
|
||||
// Populate boxes with geometry
|
||||
world.boxes.boxId = new Uint32Array([100, 200])
|
||||
world.boxes.subjectId = new Uint32Array([1, 2])
|
||||
world.boxes.frameId = new Uint32Array([1, 1])
|
||||
world.boxes.borderLeft = new Float64Array([0, 110])
|
||||
world.boxes.borderTop = new Float64Array([0, 0])
|
||||
world.boxes.borderRight = new Float64Array([100, 210])
|
||||
world.boxes.borderBottom = new Float64Array([40, 40])
|
||||
world.boxes.paddingLeft = new Float64Array([0, 0])
|
||||
world.boxes.paddingTop = new Float64Array([0, 0])
|
||||
world.boxes.paddingRight = new Float64Array([0, 0])
|
||||
world.boxes.paddingBottom = new Float64Array([0, 0])
|
||||
world.boxes.contentLeft = new Float64Array([0, 0])
|
||||
world.boxes.contentTop = new Float64Array([0, 0])
|
||||
world.boxes.contentRight = new Float64Array([0, 0])
|
||||
world.boxes.contentBottom = new Float64Array([0, 0])
|
||||
|
||||
// Populate topology
|
||||
world.topology.containingBlockOf = new Uint32Array([0, 1, 2])
|
||||
world.topology.nearestPositionedAncestorOf = new Uint32Array([0, 0, 0])
|
||||
world.topology.scrollContainerOf = new Uint32Array([0, 0, 0])
|
||||
world.topology.stackingContextOf = new Uint32Array([1, 1, 1])
|
||||
world.topology.formattingContextOf = new Uint32Array([1, 1, 1])
|
||||
world.topology.clippingRootOf = new Uint32Array([0, 0, 0])
|
||||
world.topology.paintOrderBucket = new Uint16Array([1, 1, 1])
|
||||
world.topology.paintOrderIndex = new Uint32Array([0, 1, 2])
|
||||
|
||||
return world
|
||||
}
|
||||
|
||||
test('round-trip serialization preserves primitive fields', () => {
|
||||
const world = createTestWorld()
|
||||
const json = serializeGeometryWorld(world)
|
||||
const restored = deserializeGeometryWorld(json)
|
||||
|
||||
assert.strictEqual(restored.sceneId, 'scene_1')
|
||||
assert.strictEqual(restored.snapshotId, 'snap_1')
|
||||
assert.strictEqual(restored.env.viewportWidth, 1280)
|
||||
assert.strictEqual(restored.env.viewportHeight, 720)
|
||||
assert.strictEqual(restored.source.url, 'https://example.com')
|
||||
assert.deepStrictEqual(restored.strings.values, ['hello', 'world'])
|
||||
})
|
||||
|
||||
test('round-trip preserves TypedArray data and types', () => {
|
||||
const world = createTestWorld()
|
||||
const json = serializeGeometryWorld(world)
|
||||
const restored = deserializeGeometryWorld(json)
|
||||
|
||||
// Verify subjects table
|
||||
assert.ok(restored.subjects.ids instanceof Uint32Array)
|
||||
assert.deepStrictEqual(
|
||||
Array.from(restored.subjects.ids),
|
||||
[1, 2, 3],
|
||||
)
|
||||
|
||||
assert.ok(restored.subjects.subjectKind instanceof Uint16Array)
|
||||
assert.deepStrictEqual(
|
||||
Array.from(restored.subjects.subjectKind),
|
||||
[1, 1, 2],
|
||||
)
|
||||
|
||||
// Verify boxes table
|
||||
assert.ok(restored.boxes.borderLeft instanceof Float64Array)
|
||||
assert.deepStrictEqual(
|
||||
Array.from(restored.boxes.borderLeft),
|
||||
[0, 110],
|
||||
)
|
||||
|
||||
assert.ok(restored.boxes.borderRight instanceof Float64Array)
|
||||
assert.deepStrictEqual(
|
||||
Array.from(restored.boxes.borderRight),
|
||||
[100, 210],
|
||||
)
|
||||
})
|
||||
|
||||
test('round-trip preserves topology data', () => {
|
||||
const world = createTestWorld()
|
||||
const json = serializeGeometryWorld(world)
|
||||
const restored = deserializeGeometryWorld(json)
|
||||
|
||||
assert.deepStrictEqual(
|
||||
Array.from(restored.topology.stackingContextOf),
|
||||
[1, 1, 1],
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
Array.from(restored.topology.paintOrderIndex),
|
||||
[0, 1, 2],
|
||||
)
|
||||
})
|
||||
|
||||
test('empty world round-trips', () => {
|
||||
const world = createEmptyWorld('empty_scene', 'empty_snap')
|
||||
const json = serializeGeometryWorld(world)
|
||||
const restored = deserializeGeometryWorld(json)
|
||||
|
||||
assert.strictEqual(restored.sceneId, 'empty_scene')
|
||||
assert.strictEqual(restored.snapshotId, 'empty_snap')
|
||||
assert.strictEqual(restored.subjects.ids.length, 0)
|
||||
assert.strictEqual(restored.boxes.boxId.length, 0)
|
||||
})
|
||||
|
||||
test('world with undefined tables round-trips without crash', () => {
|
||||
const world = createEmptyWorld('partial', 'snap') as any
|
||||
// Simulate a world where some tables are undefined (common after extraction)
|
||||
world.frames = undefined
|
||||
world.fragments = undefined
|
||||
world.styles = undefined
|
||||
world.text = undefined
|
||||
world.paint = undefined
|
||||
world.provenance = undefined
|
||||
world.confidence = undefined
|
||||
|
||||
const json = serializeGeometryWorld(world)
|
||||
const restored = deserializeGeometryWorld(json)
|
||||
|
||||
assert.strictEqual(restored.sceneId, 'partial')
|
||||
assert.ok(restored.frames)
|
||||
assert.ok(restored.fragments)
|
||||
assert.ok(restored.styles)
|
||||
assert.ok(restored.text)
|
||||
assert.ok(restored.paint)
|
||||
assert.ok(restored.provenance)
|
||||
assert.ok(restored.confidence)
|
||||
})
|
||||
|
||||
test('cache key is stable for identical inputs', () => {
|
||||
const key1 = computeGeometryCacheKey('https://example.com', ['.a', '.b'], { viewportWidth: 1280 })
|
||||
const key2 = computeGeometryCacheKey('https://example.com', ['.a', '.b'], { viewportWidth: 1280 })
|
||||
assert.strictEqual(key1, key2)
|
||||
})
|
||||
|
||||
test('cache key differs for different selectors', () => {
|
||||
const key1 = computeGeometryCacheKey('https://example.com', ['.a', '.b'], {})
|
||||
const key2 = computeGeometryCacheKey('https://example.com', ['.a', '.c'], {})
|
||||
assert.notStrictEqual(key1, key2)
|
||||
})
|
||||
|
||||
test('cache key is order-independent for selectors', () => {
|
||||
const key1 = computeGeometryCacheKey('https://example.com', ['.a', '.b', '.c'], {})
|
||||
const key2 = computeGeometryCacheKey('https://example.com', ['.c', '.a', '.b'], {})
|
||||
assert.strictEqual(key1, key2)
|
||||
})
|
||||
|
||||
test('cache key differs for different environments', () => {
|
||||
const key1 = computeGeometryCacheKey('https://example.com', ['.a'], { viewportWidth: 1280 })
|
||||
const key2 = computeGeometryCacheKey('https://example.com', ['.a'], { viewportWidth: 1920 })
|
||||
assert.notStrictEqual(key1, key2)
|
||||
})
|
||||
|
||||
test('write and read cached world', async () => {
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), 'imhotep-cache-test-'))
|
||||
try {
|
||||
const world = createTestWorld()
|
||||
const cacheKey = 'test-key-123'
|
||||
|
||||
await writeCachedWorld(cacheDir, cacheKey, world)
|
||||
const restored = await readCachedWorld(cacheDir, cacheKey)
|
||||
|
||||
assert.ok(restored, 'should read back cached world')
|
||||
assert.strictEqual(restored.sceneId, 'scene_1')
|
||||
assert.deepStrictEqual(
|
||||
Array.from(restored.subjects.ids),
|
||||
[1, 2, 3],
|
||||
)
|
||||
|
||||
// Cache miss for unknown key
|
||||
const miss = await readCachedWorld(cacheDir, 'unknown-key')
|
||||
assert.strictEqual(miss, null)
|
||||
} finally {
|
||||
rmSync(cacheDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('clear cache removes all entries', async () => {
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), 'imhotep-cache-test-'))
|
||||
try {
|
||||
const world = createEmptyWorld('s', 'sn')
|
||||
await writeCachedWorld(cacheDir, 'key1', world)
|
||||
await writeCachedWorld(cacheDir, 'key2', world)
|
||||
|
||||
assert.ok(await readCachedWorld(cacheDir, 'key1'))
|
||||
assert.ok(await readCachedWorld(cacheDir, 'key2'))
|
||||
|
||||
clearGeometryCache(cacheDir)
|
||||
|
||||
assert.strictEqual(await readCachedWorld(cacheDir, 'key1'), null)
|
||||
assert.strictEqual(await readCachedWorld(cacheDir, 'key2'), null)
|
||||
} finally {
|
||||
rmSync(cacheDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('write and read cached extraction result with selector mappings', async () => {
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), 'imhotep-cache-test-'))
|
||||
try {
|
||||
const world = createTestWorld()
|
||||
const selectorToIds = new Map<string, number[]>([
|
||||
['.button', [1, 2]],
|
||||
['.label', [3]],
|
||||
])
|
||||
const errors = [
|
||||
{
|
||||
code: 'IMH_TEST_ERROR',
|
||||
severity: 'error' as const,
|
||||
category: 'test-error' as const,
|
||||
message: 'Test error',
|
||||
source: 'test',
|
||||
related: [] as string[],
|
||||
fixHints: [] as string[],
|
||||
metrics: {} as Record<string, unknown>,
|
||||
sourceRef: {} as Record<string, unknown>,
|
||||
},
|
||||
]
|
||||
|
||||
await writeCachedExtractionResult(cacheDir, 'extract-1', world, selectorToIds, errors)
|
||||
const cached = await readCachedExtractionResult(cacheDir, 'extract-1')
|
||||
|
||||
assert.ok(cached, 'should read back cached extraction result')
|
||||
assert.strictEqual(cached.world.sceneId, 'scene_1')
|
||||
assert.deepStrictEqual(
|
||||
Array.from(cached.selectorToIds.entries()),
|
||||
[['.button', [1, 2]], ['.label', [3]]],
|
||||
)
|
||||
assert.strictEqual(cached.errors.length, 1)
|
||||
assert.strictEqual(cached.errors[0].code, 'IMH_TEST_ERROR')
|
||||
|
||||
// Cache miss for unknown key
|
||||
assert.strictEqual(await readCachedExtractionResult(cacheDir, 'unknown-key'), null)
|
||||
} finally {
|
||||
rmSync(cacheDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('cached extraction result round-trips empty maps and arrays', async () => {
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), 'imhotep-cache-test-'))
|
||||
try {
|
||||
const world = createEmptyWorld('empty', 'snap')
|
||||
const selectorToIds = new Map<string, number[]>()
|
||||
const errors: any[] = []
|
||||
|
||||
await writeCachedExtractionResult(cacheDir, 'empty-extract', world, selectorToIds, errors)
|
||||
const cached = await readCachedExtractionResult(cacheDir, 'empty-extract')
|
||||
|
||||
assert.ok(cached)
|
||||
assert.strictEqual(cached.selectorToIds.size, 0)
|
||||
assert.strictEqual(cached.errors.length, 0)
|
||||
} finally {
|
||||
rmSync(cacheDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('cache read is fast for large worlds', async () => {
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), 'imhotep-cache-test-'))
|
||||
try {
|
||||
// Build a world with 1000 subjects and boxes
|
||||
const world = createEmptyWorld('bench', 'snap')
|
||||
const n = 1000
|
||||
world.subjects.ids = new Uint32Array(Array.from({ length: n }, (_, i) => i + 1))
|
||||
world.subjects.domNodeId = new Uint32Array(Array.from({ length: n }, (_, i) => i + 10))
|
||||
world.subjects.subjectKind = new Uint16Array(Array.from({ length: n }, () => 1))
|
||||
world.subjects.primaryBoxId = new Uint32Array(Array.from({ length: n }, (_, i) => i + 100))
|
||||
world.boxes.boxId = new Uint32Array(Array.from({ length: n }, (_, i) => i + 100))
|
||||
world.boxes.subjectId = new Uint32Array(Array.from({ length: n }, (_, i) => i + 1))
|
||||
world.boxes.frameId = new Uint32Array(Array.from({ length: n }, () => 1))
|
||||
world.boxes.borderLeft = new Float64Array(Array.from({ length: n }, (_, i) => i * 10))
|
||||
world.boxes.borderTop = new Float64Array(Array.from({ length: n }, () => 0))
|
||||
world.boxes.borderRight = new Float64Array(Array.from({ length: n }, (_, i) => i * 10 + 100))
|
||||
world.boxes.borderBottom = new Float64Array(Array.from({ length: n }, () => 50))
|
||||
|
||||
const selectorToIds = new Map<string, number[]>([['.item', Array.from({ length: n }, (_, i) => i + 1)]])
|
||||
const errors: any[] = []
|
||||
|
||||
await writeCachedExtractionResult(cacheDir, 'bench-extract', world, selectorToIds, errors)
|
||||
|
||||
const start = performance.now()
|
||||
const cached = await readCachedExtractionResult(cacheDir, 'bench-extract')
|
||||
const duration = performance.now() - start
|
||||
|
||||
assert.ok(cached, 'should read cached large world')
|
||||
assert.strictEqual(cached.world.subjects.ids.length, n)
|
||||
assert.ok(duration < 100, `cache read should be under 100ms, took ${duration.toFixed(2)}ms`)
|
||||
} finally {
|
||||
rmSync(cacheDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Geometry snapshot caching for Imhotep.
|
||||
*
|
||||
* Serializes and deserializes GeometryWorld instances so extracted
|
||||
* browser geometry can be cached to disk and reused across assertion
|
||||
* batches without re-extraction.
|
||||
*/
|
||||
|
||||
import type { GeometryWorld, WorldEnvironment, WorldSource } from './world.js'
|
||||
import type { ImhotepId } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SerializedTypedArray {
|
||||
type: 'Uint32Array' | 'Uint16Array' | 'Uint8Array' | 'Int32Array' | 'Int16Array' | 'Float64Array' | 'Float32Array'
|
||||
data: number[]
|
||||
}
|
||||
|
||||
interface SerializedTable {
|
||||
[column: string]: SerializedTypedArray | string[] | number[]
|
||||
}
|
||||
|
||||
interface SerializedWorld {
|
||||
version: number
|
||||
sceneId: ImhotepId
|
||||
snapshotId: ImhotepId
|
||||
env: WorldEnvironment
|
||||
source: WorldSource
|
||||
strings: { values: string[] }
|
||||
subjects: SerializedTable
|
||||
dom: SerializedTable
|
||||
frames: SerializedTable
|
||||
matrices: SerializedTable
|
||||
rects: SerializedTable
|
||||
boxes: SerializedTable
|
||||
visualBoxes: SerializedTable
|
||||
fragments: SerializedTable
|
||||
transforms: SerializedTable
|
||||
styles: SerializedTable
|
||||
text: SerializedTable
|
||||
topology: SerializedTable
|
||||
scroll: SerializedTable
|
||||
clipping: SerializedTable
|
||||
paint: SerializedTable
|
||||
visibility: SerializedTable
|
||||
provenance: SerializedTable
|
||||
confidence: SerializedTable
|
||||
}
|
||||
|
||||
function serializeTypedArray(arr: TypedArray): SerializedTypedArray {
|
||||
const ctor = arr.constructor.name as SerializedTypedArray['type']
|
||||
return { type: ctor, data: Array.from(arr) }
|
||||
}
|
||||
|
||||
function deserializeTypedArray(serialized: SerializedTypedArray): TypedArray {
|
||||
const ctor = globalThis[serialized.type] as new (data: number[]) => TypedArray
|
||||
return new ctor(serialized.data)
|
||||
}
|
||||
|
||||
type TypedArray =
|
||||
| Uint32Array
|
||||
| Uint16Array
|
||||
| Uint8Array
|
||||
| Int32Array
|
||||
| Int16Array
|
||||
| Float64Array
|
||||
| Float32Array
|
||||
|
||||
function isTypedArray(value: unknown): value is TypedArray {
|
||||
return (
|
||||
value instanceof Uint32Array ||
|
||||
value instanceof Uint16Array ||
|
||||
value instanceof Uint8Array ||
|
||||
value instanceof Int32Array ||
|
||||
value instanceof Int16Array ||
|
||||
value instanceof Float64Array ||
|
||||
value instanceof Float32Array
|
||||
)
|
||||
}
|
||||
|
||||
function serializeTable(table: Record<string, unknown>): SerializedTable {
|
||||
const result: SerializedTable = {}
|
||||
if (!table) return result
|
||||
for (const [key, value] of Object.entries(table)) {
|
||||
if (isTypedArray(value)) {
|
||||
result[key] = serializeTypedArray(value)
|
||||
} else if (Array.isArray(value)) {
|
||||
result[key] = value as number[] | string[]
|
||||
} else if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
|
||||
result[key] = [value] as number[] | string[]
|
||||
} else {
|
||||
result[key] = value as SerializedTable[string]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function deserializeTable(serialized: SerializedTable): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(serialized)) {
|
||||
if (value && typeof value === 'object' && 'type' in value && 'data' in value) {
|
||||
result[key] = deserializeTypedArray(value as SerializedTypedArray)
|
||||
} else if (Array.isArray(value)) {
|
||||
result[key] = value
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a GeometryWorld to a JSON string.
|
||||
*/
|
||||
export function serializeGeometryWorld(world: GeometryWorld): string {
|
||||
const serialized: SerializedWorld = {
|
||||
version: 1,
|
||||
sceneId: world.sceneId,
|
||||
snapshotId: world.snapshotId,
|
||||
env: world.env,
|
||||
source: world.source,
|
||||
strings: world.strings,
|
||||
subjects: serializeTable(world.subjects as unknown as Record<string, unknown>),
|
||||
dom: serializeTable(world.dom as unknown as Record<string, unknown>),
|
||||
frames: serializeTable(world.frames as unknown as Record<string, unknown>),
|
||||
matrices: serializeTable(world.matrices as unknown as Record<string, unknown>),
|
||||
rects: serializeTable(world.rects as unknown as Record<string, unknown>),
|
||||
boxes: serializeTable(world.boxes as unknown as Record<string, unknown>),
|
||||
visualBoxes: serializeTable(world.visualBoxes as unknown as Record<string, unknown>),
|
||||
fragments: serializeTable(world.fragments as unknown as Record<string, unknown>),
|
||||
transforms: serializeTable(world.transforms as unknown as Record<string, unknown>),
|
||||
styles: serializeTable(world.styles as unknown as Record<string, unknown>),
|
||||
text: serializeTable(world.text as unknown as Record<string, unknown>),
|
||||
topology: serializeTable(world.topology as unknown as Record<string, unknown>),
|
||||
scroll: serializeTable(world.scroll as unknown as Record<string, unknown>),
|
||||
clipping: serializeTable(world.clipping as unknown as Record<string, unknown>),
|
||||
paint: serializeTable(world.paint as unknown as Record<string, unknown>),
|
||||
visibility: serializeTable(world.visibility as unknown as Record<string, unknown>),
|
||||
provenance: serializeTable(world.provenance as unknown as Record<string, unknown>),
|
||||
confidence: serializeTable(world.confidence as unknown as Record<string, unknown>),
|
||||
}
|
||||
return JSON.stringify(serialized)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a JSON string back into a GeometryWorld.
|
||||
*/
|
||||
export function deserializeGeometryWorld(json: string): GeometryWorld {
|
||||
const serialized = JSON.parse(json) as SerializedWorld
|
||||
|
||||
if (serialized.version !== 1) {
|
||||
throw new Error(`Unsupported geometry cache version: ${serialized.version}`)
|
||||
}
|
||||
|
||||
return {
|
||||
sceneId: serialized.sceneId,
|
||||
snapshotId: serialized.snapshotId,
|
||||
env: serialized.env,
|
||||
source: serialized.source,
|
||||
strings: serialized.strings,
|
||||
subjects: deserializeTable(serialized.subjects) as unknown as GeometryWorld['subjects'],
|
||||
dom: deserializeTable(serialized.dom) as unknown as GeometryWorld['dom'],
|
||||
frames: deserializeTable(serialized.frames) as unknown as GeometryWorld['frames'],
|
||||
matrices: deserializeTable(serialized.matrices) as unknown as GeometryWorld['matrices'],
|
||||
rects: deserializeTable(serialized.rects) as unknown as GeometryWorld['rects'],
|
||||
boxes: deserializeTable(serialized.boxes) as unknown as GeometryWorld['boxes'],
|
||||
visualBoxes: deserializeTable(serialized.visualBoxes) as unknown as GeometryWorld['visualBoxes'],
|
||||
fragments: deserializeTable(serialized.fragments) as unknown as GeometryWorld['fragments'],
|
||||
transforms: deserializeTable(serialized.transforms) as unknown as GeometryWorld['transforms'],
|
||||
styles: deserializeTable(serialized.styles) as unknown as GeometryWorld['styles'],
|
||||
text: deserializeTable(serialized.text) as unknown as GeometryWorld['text'],
|
||||
topology: deserializeTable(serialized.topology) as unknown as GeometryWorld['topology'],
|
||||
scroll: deserializeTable(serialized.scroll) as unknown as GeometryWorld['scroll'],
|
||||
clipping: deserializeTable(serialized.clipping) as unknown as GeometryWorld['clipping'],
|
||||
paint: deserializeTable(serialized.paint) as unknown as GeometryWorld['paint'],
|
||||
visibility: deserializeTable(serialized.visibility) as unknown as GeometryWorld['visibility'],
|
||||
provenance: deserializeTable(serialized.provenance) as unknown as GeometryWorld['provenance'],
|
||||
confidence: deserializeTable(serialized.confidence) as unknown as GeometryWorld['confidence'],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache key computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function djb2Hash(str: string): string {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash + str.charCodeAt(i)) & 0xffffffff
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a stable cache key for a geometry extraction.
|
||||
*
|
||||
* The key incorporates:
|
||||
* - page URL
|
||||
* - sorted selectors (so order doesn't matter)
|
||||
* - environment (viewport dimensions, color scheme, etc.)
|
||||
*/
|
||||
export function computeGeometryCacheKey(
|
||||
pageUrl: string,
|
||||
selectors: string[],
|
||||
env: Partial<WorldEnvironment>,
|
||||
): string {
|
||||
const selectorsHash = djb2Hash([...selectors].sort().join('\x00'))
|
||||
const envHash = djb2Hash(
|
||||
JSON.stringify({
|
||||
viewportWidth: env.viewportWidth ?? 0,
|
||||
viewportHeight: env.viewportHeight ?? 0,
|
||||
deviceScaleFactor: env.deviceScaleFactor ?? 1,
|
||||
colorScheme: env.colorScheme ?? 'light',
|
||||
pointer: env.pointer ?? 'fine',
|
||||
hover: env.hover ?? false,
|
||||
reducedMotion: env.reducedMotion ?? 'no-preference',
|
||||
locale: env.locale ?? 'en',
|
||||
writingMode: env.writingMode ?? 'horizontal-tb',
|
||||
}),
|
||||
)
|
||||
const urlHash = djb2Hash(pageUrl)
|
||||
return `${urlHash}-${selectorsHash}-${envHash}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File-based cache storage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs'
|
||||
import { readFile, writeFile, unlink } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const DEFAULT_CACHE_DIR = join(process.cwd(), '.imhotep-cache')
|
||||
const DEFAULT_MAX_CACHE_ENTRIES = 100
|
||||
|
||||
function cacheFilePath(cacheDir: string, cacheKey: string): string {
|
||||
return join(cacheDir, `${cacheKey}.json`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict oldest cache entries when exceeding maxEntries.
|
||||
*/
|
||||
function evictOldestEntries(cacheDir: string, maxEntries: number): void {
|
||||
if (!existsSync(cacheDir)) return
|
||||
const files = readdirSync(cacheDir)
|
||||
.filter((f) => f.endsWith('.json'))
|
||||
.map((f) => {
|
||||
const fullPath = join(cacheDir, f)
|
||||
try {
|
||||
return { name: f, path: fullPath, mtime: statSync(fullPath).mtimeMs }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((f): f is NonNullable<typeof f> => f !== null)
|
||||
.sort((a, b) => a.mtime - b.mtime)
|
||||
|
||||
if (files.length > maxEntries) {
|
||||
const toRemove = files.length - maxEntries
|
||||
for (let i = 0; i < toRemove; i++) {
|
||||
try {
|
||||
rmSync(files[i].path)
|
||||
} catch {
|
||||
// ignore deletion errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a cached GeometryWorld from disk (async, non-blocking).
|
||||
*/
|
||||
export async function readCachedWorld(cacheDir: string, cacheKey: string): Promise<GeometryWorld | null> {
|
||||
const path = cacheFilePath(cacheDir, cacheKey)
|
||||
try {
|
||||
const json = await readFile(path, 'utf-8')
|
||||
return deserializeGeometryWorld(json)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a GeometryWorld to the cache on disk (async, non-blocking).
|
||||
*/
|
||||
export async function writeCachedWorld(cacheDir: string, cacheKey: string, world: GeometryWorld): Promise<void> {
|
||||
mkdirSync(cacheDir, { recursive: true })
|
||||
evictOldestEntries(cacheDir, DEFAULT_MAX_CACHE_ENTRIES)
|
||||
const path = cacheFilePath(cacheDir, cacheKey)
|
||||
await writeFile(path, serializeGeometryWorld(world))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached geometry snapshots.
|
||||
*/
|
||||
export function clearGeometryCache(cacheDir: string = DEFAULT_CACHE_DIR): void {
|
||||
if (!existsSync(cacheDir)) {
|
||||
return
|
||||
}
|
||||
for (const file of readdirSync(cacheDir)) {
|
||||
if (file.endsWith('.json')) {
|
||||
try {
|
||||
rmSync(join(cacheDir, file))
|
||||
} catch {
|
||||
// ignore deletion errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full extraction result caching (world + selector mapping + errors)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CachedExtractionResult {
|
||||
version: number
|
||||
world: string
|
||||
selectorToIds: [string, number[]][]
|
||||
errors: Array<{
|
||||
code: string
|
||||
severity: string
|
||||
category: string
|
||||
message: string
|
||||
source: string
|
||||
related: string[]
|
||||
fixHints: string[]
|
||||
metrics: Record<string, unknown>
|
||||
sourceRef: Record<string, unknown>
|
||||
clauseLabel?: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a full extraction result (world + selector mappings + errors).
|
||||
*/
|
||||
export function serializeExtractionResult(
|
||||
world: GeometryWorld,
|
||||
selectorToIds: Map<string, number[]>,
|
||||
errors: Array<{
|
||||
code: string
|
||||
severity: string
|
||||
category: string
|
||||
message: string
|
||||
source: string
|
||||
related: string[]
|
||||
fixHints: string[]
|
||||
metrics: Record<string, unknown>
|
||||
sourceRef: Record<string, unknown>
|
||||
clauseLabel?: string
|
||||
}>,
|
||||
): string {
|
||||
const cached: CachedExtractionResult = {
|
||||
version: 1,
|
||||
world: serializeGeometryWorld(world),
|
||||
selectorToIds: Array.from(selectorToIds.entries()),
|
||||
errors,
|
||||
}
|
||||
return JSON.stringify(cached)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a full extraction result from JSON.
|
||||
*/
|
||||
export function deserializeExtractionResult(json: string): {
|
||||
world: GeometryWorld
|
||||
selectorToIds: Map<string, number[]>
|
||||
errors: CachedExtractionResult['errors']
|
||||
} {
|
||||
const cached = JSON.parse(json) as CachedExtractionResult
|
||||
if (cached.version !== 1) {
|
||||
throw new Error(`Unsupported extraction cache version: ${cached.version}`)
|
||||
}
|
||||
return {
|
||||
world: deserializeGeometryWorld(cached.world),
|
||||
selectorToIds: new Map(cached.selectorToIds),
|
||||
errors: cached.errors,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a cached extraction result from disk (async, non-blocking).
|
||||
*/
|
||||
export async function readCachedExtractionResult(
|
||||
cacheDir: string,
|
||||
cacheKey: string,
|
||||
): Promise<{ world: GeometryWorld; selectorToIds: Map<string, number[]>; errors: CachedExtractionResult['errors'] } | null> {
|
||||
const path = cacheFilePath(cacheDir, cacheKey)
|
||||
try {
|
||||
const json = await readFile(path, 'utf-8')
|
||||
return deserializeExtractionResult(json)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an extraction result to the cache on disk (async, non-blocking).
|
||||
*/
|
||||
export async function writeCachedExtractionResult(
|
||||
cacheDir: string,
|
||||
cacheKey: string,
|
||||
world: GeometryWorld,
|
||||
selectorToIds: Map<string, number[]>,
|
||||
errors: CachedExtractionResult['errors'],
|
||||
): Promise<void> {
|
||||
mkdirSync(cacheDir, { recursive: true })
|
||||
evictOldestEntries(cacheDir, DEFAULT_MAX_CACHE_ENTRIES)
|
||||
const path = cacheFilePath(cacheDir, cacheKey)
|
||||
await writeFile(path, serializeExtractionResult(world, selectorToIds, errors))
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the default cache directory path.
|
||||
*/
|
||||
export function getDefaultCacheDir(): string {
|
||||
return DEFAULT_CACHE_DIR
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Imhotep Core — foundational contracts and type system.
|
||||
*
|
||||
* Exported modules:
|
||||
* types — Core type definitions (positions, IDs, results, proofs)
|
||||
* ast — Unist-style AST node definitions
|
||||
* ir — Semantic IR and Execution IR definitions
|
||||
* world — Geometry world schema types
|
||||
* contracts — Extractor, solver, compiler, reporter interfaces
|
||||
* diagnostics — Diagnostic categories, error codes, traces, shrinking
|
||||
*/
|
||||
|
||||
export * from './types.js'
|
||||
export * from './ast.js'
|
||||
export * from './ir.js'
|
||||
export { FragmentKind, createEmptyWorld } from './world.js'
|
||||
export type {
|
||||
GeometryWorld,
|
||||
WorldEnvironment,
|
||||
WorldSource,
|
||||
StringTable,
|
||||
SubjectsTable,
|
||||
DomTable,
|
||||
FramesTable,
|
||||
MatricesTable,
|
||||
RectsTable,
|
||||
BoxesTable,
|
||||
FragmentsTable,
|
||||
TransformsTable,
|
||||
StylesTable,
|
||||
TextTable,
|
||||
TopologyTable,
|
||||
ScrollTable,
|
||||
ClippingTable,
|
||||
PaintTable,
|
||||
VisibilityTable,
|
||||
ProvenanceTable,
|
||||
ConfidenceTable,
|
||||
FactRequestPlan,
|
||||
} from './world.js'
|
||||
export * from './contracts.js'
|
||||
export * from './diagnostics.js'
|
||||
export * from './pipeline.js'
|
||||
export * from './logic-ast.js'
|
||||
export * from './logic-ir.js'
|
||||
export * from './domains.js'
|
||||
export * from './scene-target.js'
|
||||
export * from './property-contracts.js'
|
||||
export * from './property-results.js'
|
||||
export * from './canonical.js'
|
||||
export * from './context.js'
|
||||
export {
|
||||
serializeGeometryWorld,
|
||||
deserializeGeometryWorld,
|
||||
computeGeometryCacheKey,
|
||||
readCachedWorld,
|
||||
writeCachedWorld,
|
||||
clearGeometryCache,
|
||||
getDefaultCacheDir,
|
||||
readCachedExtractionResult,
|
||||
writeCachedExtractionResult,
|
||||
} from './geometry-cache.js'
|
||||
|
||||
// Public API type aliases for high-level evaluation results
|
||||
import type { ClauseResult, ImhotepId } from './types.js'
|
||||
import type { Diagnostic } from './diagnostics.js'
|
||||
|
||||
export interface NormalizedContract {
|
||||
clauseId: ImhotepId
|
||||
clauseLabel: string
|
||||
relation: string
|
||||
subject: string
|
||||
reference: string
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ImhotepResult = {
|
||||
schemaVersion: string
|
||||
passed: boolean
|
||||
summary: string
|
||||
clauseResults: ClauseResult[]
|
||||
diagnostics: ImhotepDiagnostic[]
|
||||
normalizedContracts?: NormalizedContract[]
|
||||
}
|
||||
|
||||
export type ImhotepDiagnostic = Diagnostic
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* DSL -> Geometry integration tests.
|
||||
*
|
||||
* Verifies that fluent assertions compile to IR that geometry worlds can satisfy,
|
||||
* that frame references resolve correctly, and that tolerance values propagate.
|
||||
*/
|
||||
|
||||
import { describe, it, before } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { expect, frame, compile as dslCompile } from 'imhotep-dsl'
|
||||
import { evaluate, registerDefaultClauses } from 'imhotep-solver'
|
||||
import { createEmptySemanticIr } from './ir.js'
|
||||
import type { ExecutionIr } from './ir.js'
|
||||
import type { ExecutionContext } from 'imhotep-extractor'
|
||||
import { ExtractionPlanner, FactRequirementRegistry, FACT_FAMILY } from 'imhotep-extractor'
|
||||
import {
|
||||
buildMockGeometryWorld,
|
||||
mapSemanticIrToClauses,
|
||||
} from './integration-mocks.js'
|
||||
|
||||
describe('DSL -> Geometry', () => {
|
||||
before(() => {
|
||||
registerDefaultClauses()
|
||||
})
|
||||
|
||||
it('fluent assertions compile to IR that geometry world can satisfy', () => {
|
||||
// Arrange: build DSL assertion via fluent API
|
||||
const fluent = expect('.tooltip').to.be.above('.trigger')
|
||||
const ast = fluent.toAst()
|
||||
|
||||
// Arrange: compile to semantic + execution IR with deterministic ids
|
||||
let idCounter = 0
|
||||
const result = dslCompile([ast], {
|
||||
generateId: (prefix) => `${prefix}_${++idCounter}`,
|
||||
})
|
||||
|
||||
// Assert compilation succeeded
|
||||
assert.strictEqual(result.diagnostics.length, 0)
|
||||
assert.strictEqual(result.executionIr.clauseCount, 1)
|
||||
|
||||
// Arrange: build geometry world with matching subjects in compiler order
|
||||
const world = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.tooltip',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 50 },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
selector: '.trigger',
|
||||
box: { top: 20, bottom: 30, left: 0, right: 50 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Map semantic IR subject ids to world subject ids (compiler order)
|
||||
const subjectIdMap = new Map<string, number>()
|
||||
let worldIndex = 0
|
||||
for (const [id] of result.semanticIr.subjects) {
|
||||
subjectIdMap.set(id, worldIndex++)
|
||||
}
|
||||
|
||||
// Act: build clause descriptors and evaluate
|
||||
const clauses = mapSemanticIrToClauses(result.semanticIr, subjectIdMap)
|
||||
const evalResult = evaluate(world, clauses)
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(evalResult.clauseResults.length, 1)
|
||||
assert.strictEqual(evalResult.clauseResults[0].status, 'pass')
|
||||
assert.strictEqual(evalResult.clauseResults[0].truth, 'determinate')
|
||||
})
|
||||
|
||||
it('frame references in DSL resolve correctly in geometry world', () => {
|
||||
// Arrange: build DSL assertion with nearestPositionedAncestor frame
|
||||
const fluent = expect('.tooltip')
|
||||
.in(frame.nearestPositionedAncestor('.trigger'))
|
||||
.to.be.above('.trigger')
|
||||
const ast = fluent.toAst()
|
||||
|
||||
// Arrange: compile
|
||||
let idCounter = 0
|
||||
const result = dslCompile([ast], {
|
||||
generateId: (prefix) => `${prefix}_${++idCounter}`,
|
||||
})
|
||||
|
||||
// Assert: semantic IR has a non-default frame
|
||||
const nonDefaultFrames: Array<[string, { kind: string }]> = []
|
||||
for (const [, frame] of result.semanticIr.frames.entries()) {
|
||||
if (frame.kind !== 'viewport') {
|
||||
nonDefaultFrames.push(['', frame])
|
||||
}
|
||||
}
|
||||
assert.strictEqual(nonDefaultFrames.length, 1)
|
||||
assert.strictEqual(nonDefaultFrames[0][1].kind, 'nearestPositionedAncestor')
|
||||
|
||||
// Arrange: build world with frame topology
|
||||
const world = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.tooltip',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 50 },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
selector: '.trigger',
|
||||
box: { top: 20, bottom: 30, left: 0, right: 50 },
|
||||
},
|
||||
],
|
||||
topology: {
|
||||
nearestPositionedAncestorOf: [1],
|
||||
},
|
||||
})
|
||||
|
||||
// Map subjects
|
||||
const subjectIdMap = new Map<string, number>()
|
||||
let worldIndex = 0
|
||||
for (const [id] of result.semanticIr.subjects) {
|
||||
subjectIdMap.set(id, worldIndex++)
|
||||
}
|
||||
|
||||
// Act
|
||||
const clauses = mapSemanticIrToClauses(result.semanticIr, subjectIdMap)
|
||||
const evalResult = evaluate(world, clauses)
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(evalResult.clauseResults.length, 1)
|
||||
assert.strictEqual(evalResult.clauseResults[0].status, 'pass')
|
||||
assert.strictEqual(evalResult.clauseResults[0].truth, 'determinate')
|
||||
})
|
||||
|
||||
it('tolerance values propagate to solver correctly', () => {
|
||||
// Arrange: build DSL assertion with tolerance
|
||||
const fluent = expect('.tooltip').to.be.inside('.container', {
|
||||
tolerance: 5,
|
||||
})
|
||||
const ast = fluent.toAst()
|
||||
|
||||
// Arrange: compile
|
||||
let idCounter = 0
|
||||
const result = dslCompile([ast], {
|
||||
generateId: (prefix) => `${prefix}_${++idCounter}`,
|
||||
})
|
||||
|
||||
// Assert: compiler produced a tolerance entry
|
||||
const nonDefaultTolerances: Array<[string, { value: number; unit: string }]> = []
|
||||
for (const [, tol] of result.semanticIr.tolerances.entries()) {
|
||||
if (!(tol.value === 0 && tol.unit === 'px')) {
|
||||
nonDefaultTolerances.push(['', tol])
|
||||
}
|
||||
}
|
||||
assert.strictEqual(nonDefaultTolerances.length, 1)
|
||||
assert.strictEqual(nonDefaultTolerances[0][1].value, 5)
|
||||
assert.strictEqual(nonDefaultTolerances[0][1].unit, 'px')
|
||||
|
||||
// Arrange: build world where tooltip is almost inside container
|
||||
const world = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.tooltip',
|
||||
box: { top: 2, bottom: 8, left: 2, right: 48 },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
selector: '.container',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 50 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Map subjects
|
||||
const subjectIdMap = new Map<string, number>()
|
||||
let worldIndex = 0
|
||||
for (const [id] of result.semanticIr.subjects) {
|
||||
subjectIdMap.set(id, worldIndex++)
|
||||
}
|
||||
|
||||
// Act
|
||||
const clauses = mapSemanticIrToClauses(result.semanticIr, subjectIdMap)
|
||||
const evalResult = evaluate(world, clauses)
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(evalResult.clauseResults.length, 1)
|
||||
assert.strictEqual(evalResult.clauseResults[0].status, 'pass')
|
||||
assert.ok(evalResult.clauseResults[0].metrics)
|
||||
})
|
||||
|
||||
it('execution IR produces extractable fact requirements', () => {
|
||||
// Arrange: build DSL assertion
|
||||
const fluent = expect('.a').to.be.leftOf('.b')
|
||||
const ast = fluent.toAst()
|
||||
|
||||
// Arrange: compile
|
||||
let idCounter = 0
|
||||
const result = dslCompile([ast], {
|
||||
generateId: (prefix) => `${prefix}_${++idCounter}`,
|
||||
})
|
||||
|
||||
// Arrange: build execution context for planner
|
||||
const semanticIr = result.semanticIr
|
||||
const executionIr = result.executionIr
|
||||
|
||||
const subjectIds: string[] = []
|
||||
for (const [id] of semanticIr.subjects) {
|
||||
subjectIds.push(id)
|
||||
}
|
||||
const frameIds: string[] = []
|
||||
for (const [id] of semanticIr.frames) {
|
||||
frameIds.push(id)
|
||||
}
|
||||
const stateIds: string[] = []
|
||||
for (const [id] of semanticIr.states) {
|
||||
stateIds.push(id)
|
||||
}
|
||||
const timelineIds: string[] = []
|
||||
for (const [id] of semanticIr.timelines) {
|
||||
timelineIds.push(id)
|
||||
}
|
||||
const envGuardIds: string[] = []
|
||||
for (const [id] of semanticIr.envGuards) {
|
||||
envGuardIds.push(id)
|
||||
}
|
||||
const toleranceIds: string[] = []
|
||||
for (const [id] of semanticIr.tolerances) {
|
||||
toleranceIds.push(id)
|
||||
}
|
||||
|
||||
const context: ExecutionContext = {
|
||||
executionIr,
|
||||
semanticIr,
|
||||
subjectIds,
|
||||
frameIds,
|
||||
stateIds,
|
||||
timelineIds,
|
||||
envGuardIds,
|
||||
toleranceIds,
|
||||
}
|
||||
|
||||
// Arrange: register fact requirements for leftOf
|
||||
const registry = new FactRequirementRegistry()
|
||||
registry.register(1, [{ family: FACT_FAMILY.GEOMETRY }])
|
||||
|
||||
// Act: create extraction plan
|
||||
const planner = new ExtractionPlanner({ factRegistry: registry })
|
||||
const plan = planner.createPlan(context)
|
||||
|
||||
// Assert: plan requests geometry facts
|
||||
assert.strictEqual(plan.requests.length >= 1, true)
|
||||
assert.strictEqual(plan.requests[0].requiredFacts.geometry, true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Extractor -> Solver integration tests.
|
||||
*
|
||||
* Verifies that raw extraction results normalize into worlds solvers can
|
||||
* evaluate, that topology facts enable topology assertions, and that missing
|
||||
* facts produce indeterminate results.
|
||||
*/
|
||||
|
||||
import { describe, it, before } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
ExtractionPlanner,
|
||||
FactRequirementRegistry,
|
||||
FACT_FAMILY,
|
||||
} from 'imhotep-extractor'
|
||||
import type { ExecutionContext } from 'imhotep-extractor'
|
||||
import { evaluate, registerDefaultClauses } from 'imhotep-solver'
|
||||
import { createEmptySemanticIr } from './ir.js'
|
||||
import type { ExecutionIr } from './ir.js'
|
||||
import {
|
||||
buildMockGeometryWorld,
|
||||
buildClauseDescriptor,
|
||||
} from './integration-mocks.js'
|
||||
|
||||
describe('Extractor -> Solver', () => {
|
||||
before(() => {
|
||||
registerDefaultClauses()
|
||||
})
|
||||
|
||||
it('raw extraction results normalize into worlds solvers can evaluate', () => {
|
||||
// Arrange: create semantic IR with two subjects and an above relation
|
||||
const semanticIr = createEmptySemanticIr()
|
||||
const subjectId = 'subject_1'
|
||||
const refId = 'subject_2'
|
||||
semanticIr.subjects.set(subjectId, {
|
||||
id: subjectId,
|
||||
selector: '.a',
|
||||
kind: 'element',
|
||||
origin: { astNodeIds: [], positions: [] },
|
||||
})
|
||||
semanticIr.subjects.set(refId, {
|
||||
id: refId,
|
||||
selector: '.b',
|
||||
kind: 'element',
|
||||
origin: { astNodeIds: [], positions: [] },
|
||||
})
|
||||
|
||||
const clauseId = 'clause_1'
|
||||
semanticIr.clauses.set(clauseId, {
|
||||
id: clauseId,
|
||||
origin: { astNodeIds: [], positions: [] },
|
||||
subjectRef: subjectId,
|
||||
referenceRef: refId,
|
||||
relation: 'above',
|
||||
frameRef: 'frame_default',
|
||||
stateRef: 'state_default',
|
||||
timelineRef: 'timeline_default',
|
||||
envGuardRef: 'guard_default',
|
||||
toleranceRef: 'tolerance_default',
|
||||
bounds: {},
|
||||
})
|
||||
|
||||
const executionIr: ExecutionIr = {
|
||||
clauseCount: 1,
|
||||
clauseType: new Uint16Array([3]),
|
||||
clauseSubject: new Uint32Array([0]),
|
||||
clauseReference: new Uint32Array([1]),
|
||||
clauseFrame: new Uint32Array([0]),
|
||||
clauseState: new Uint32Array([0]),
|
||||
clauseTimeline: new Uint32Array([0]),
|
||||
clauseTolerance: new Uint32Array([0]),
|
||||
clauseEnvGuard: new Uint32Array([0]),
|
||||
clauseArg0: new Float64Array([Number.NEGATIVE_INFINITY]),
|
||||
clauseArg1: new Float64Array([Number.POSITIVE_INFINITY]),
|
||||
clauseFlags: new Uint32Array([0]),
|
||||
clauseOrigin: new Uint32Array([0]),
|
||||
}
|
||||
|
||||
const context: ExecutionContext = {
|
||||
executionIr,
|
||||
semanticIr,
|
||||
subjectIds: [subjectId, refId],
|
||||
frameIds: ['frame_default'],
|
||||
stateIds: ['state_default'],
|
||||
timelineIds: ['timeline_default'],
|
||||
envGuardIds: ['guard_default'],
|
||||
toleranceIds: ['tolerance_default'],
|
||||
}
|
||||
|
||||
// Arrange: create fact registry and planner
|
||||
const registry = new FactRequirementRegistry()
|
||||
registry.register(3, [{ family: FACT_FAMILY.GEOMETRY }])
|
||||
|
||||
const planner = new ExtractionPlanner({ factRegistry: registry })
|
||||
|
||||
// Act: create extraction plan
|
||||
const plan = planner.createPlan(context)
|
||||
|
||||
// Assert: plan has one request requiring geometry
|
||||
assert.strictEqual(plan.requests.length, 1)
|
||||
assert.strictEqual(plan.requests[0].requiredFacts.geometry, true)
|
||||
|
||||
// Act: build mock extracted world and evaluate
|
||||
const world = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.a',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 50 },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
selector: '.b',
|
||||
box: { top: 20, bottom: 30, left: 0, right: 50 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const clauses = [
|
||||
buildClauseDescriptor({
|
||||
clauseId: 'c1',
|
||||
clauseKind: 'relation.above',
|
||||
subjectRef: 0,
|
||||
referenceRef: 1,
|
||||
}),
|
||||
]
|
||||
|
||||
const result = evaluate(world, clauses)
|
||||
assert.strictEqual(result.clauseResults[0].status, 'pass')
|
||||
})
|
||||
|
||||
it('topology facts from extractor enable topology assertions', () => {
|
||||
// Arrange: build world with topology data
|
||||
const world = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.child',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 10 },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
selector: '.scroll',
|
||||
box: { top: 0, bottom: 100, left: 0, right: 100 },
|
||||
},
|
||||
],
|
||||
topology: {
|
||||
scrollContainerOf: [1],
|
||||
},
|
||||
})
|
||||
|
||||
// Act: evaluate topology clause
|
||||
const clauses = [
|
||||
buildClauseDescriptor({
|
||||
clauseId: 'c1',
|
||||
clauseKind: 'topology.attachedToScrollContainer',
|
||||
subjectRef: 0,
|
||||
referenceRef: 1,
|
||||
}),
|
||||
]
|
||||
|
||||
const result = evaluate(world, clauses)
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(result.clauseResults[0].status, 'pass')
|
||||
assert.strictEqual(result.clauseResults[0].truth, 'determinate')
|
||||
assert.strictEqual(
|
||||
result.clauseResults[0].metrics?.attached,
|
||||
1,
|
||||
)
|
||||
})
|
||||
|
||||
it('stacking context topology facts enable stacking assertions', () => {
|
||||
// Arrange: build world with shared stacking context
|
||||
const world = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.layer-a',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 10 },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
selector: '.layer-b',
|
||||
box: { top: 5, bottom: 15, left: 5, right: 15 },
|
||||
},
|
||||
],
|
||||
topology: {
|
||||
stackingContextOf: [42, 42],
|
||||
},
|
||||
})
|
||||
|
||||
// Act: evaluate stacking context clause
|
||||
const clauses = [
|
||||
buildClauseDescriptor({
|
||||
clauseId: 'c1',
|
||||
clauseKind: 'topology.inStackingContext',
|
||||
subjectRef: 0,
|
||||
referenceRef: 1,
|
||||
}),
|
||||
]
|
||||
|
||||
const result = evaluate(world, clauses)
|
||||
|
||||
// Assert: same stacking context id means pass
|
||||
assert.strictEqual(result.clauseResults[0].status, 'pass')
|
||||
assert.strictEqual(result.clauseResults[0].truth, 'determinate')
|
||||
})
|
||||
|
||||
it('missing facts produce indeterminate results', () => {
|
||||
// Arrange: build world with only one subject
|
||||
const world = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.a',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 10 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Act: evaluate clause referencing non-existent subject
|
||||
const clauses = [
|
||||
buildClauseDescriptor({
|
||||
clauseId: 'c1',
|
||||
clauseKind: 'relation.above',
|
||||
subjectRef: 0,
|
||||
referenceRef: 99,
|
||||
}),
|
||||
]
|
||||
|
||||
const result = evaluate(world, clauses)
|
||||
|
||||
// Assert: indeterminate because reference box is missing
|
||||
assert.strictEqual(result.clauseResults[0].status, 'error')
|
||||
assert.strictEqual(result.clauseResults[0].truth, 'indeterminate')
|
||||
})
|
||||
|
||||
it('unsupported clause types emit extraction diagnostics', () => {
|
||||
// Arrange: create execution IR with unregistered clause type
|
||||
const semanticIr = createEmptySemanticIr()
|
||||
const executionIr: ExecutionIr = {
|
||||
clauseCount: 1,
|
||||
clauseType: new Uint16Array([999]),
|
||||
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]),
|
||||
}
|
||||
|
||||
const context: ExecutionContext = {
|
||||
executionIr,
|
||||
semanticIr,
|
||||
subjectIds: [],
|
||||
frameIds: [],
|
||||
stateIds: [],
|
||||
timelineIds: [],
|
||||
envGuardIds: [],
|
||||
toleranceIds: [],
|
||||
}
|
||||
|
||||
const registry = new FactRequirementRegistry()
|
||||
const planner = new ExtractionPlanner({ factRegistry: registry })
|
||||
|
||||
// Act: create plan
|
||||
const plan = planner.createPlan(context)
|
||||
|
||||
// Assert: diagnostic emitted for unsupported clause
|
||||
assert.ok(
|
||||
plan.diagnostics.some(
|
||||
(d) => d.code === 'IMH_EXTRACTOR_UNSUPPORTED_CLAUSE_TYPE',
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Mock helpers for cross-package integration tests.
|
||||
*
|
||||
* Provides dependency-injected builders for geometry worlds,
|
||||
* extraction results, solver results, and complete mock scenes.
|
||||
*/
|
||||
|
||||
import type {
|
||||
GeometryWorld,
|
||||
ClauseDescriptor,
|
||||
ClauseResult,
|
||||
} from 'imhotep-solver'
|
||||
import type { EvaluationOutput } from 'imhotep-solver'
|
||||
import type { GeometrySnapshot, SnapshotMetadata } from 'imhotep-state'
|
||||
import type { Diagnostic as CoreDiagnostic } from './diagnostics.js'
|
||||
import type { ImhotepId } from './types.js'
|
||||
import type { SemanticIr } from './ir.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Geometry World Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MockSubject {
|
||||
id: number
|
||||
selector: string
|
||||
box: {
|
||||
top: number
|
||||
bottom: number
|
||||
left: number
|
||||
right: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MockFrame {
|
||||
id: number
|
||||
kind: string
|
||||
subjectId: number
|
||||
}
|
||||
|
||||
export interface MockWorldOptions {
|
||||
sceneId?: string
|
||||
snapshotId?: string
|
||||
subjects: MockSubject[]
|
||||
frames?: MockFrame[]
|
||||
topology?: {
|
||||
nearestPositionedAncestorOf?: number[]
|
||||
scrollContainerOf?: number[]
|
||||
stackingContextOf?: number[]
|
||||
containingBlockOf?: number[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a solver-compatible geometry world from a declarative description.
|
||||
*/
|
||||
export function buildMockGeometryWorld(opts: MockWorldOptions): GeometryWorld {
|
||||
const subjectCount = opts.subjects.length
|
||||
|
||||
const world: GeometryWorld = {
|
||||
sceneId: opts.sceneId ?? 'scene_test',
|
||||
snapshotId: opts.snapshotId ?? 'snap_test',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: opts.subjects.map((s) => s.id),
|
||||
domNodeId: new Array(subjectCount).fill(0),
|
||||
subjectKind: new Array(subjectCount).fill(0),
|
||||
primaryBoxId: opts.subjects.map((_, i) => i),
|
||||
firstFragmentId: new Array(subjectCount).fill(0),
|
||||
fragmentCount: new Array(subjectCount).fill(0),
|
||||
},
|
||||
dom: {
|
||||
nodeId: opts.subjects.map((s) => s.id),
|
||||
parentNodeId: new Array(subjectCount).fill(0),
|
||||
childCount: new Array(subjectCount).fill(0),
|
||||
tagNameStringId: new Array(subjectCount).fill(0),
|
||||
},
|
||||
boxes: {
|
||||
boxId: opts.subjects.map((_, i) => i),
|
||||
subjectId: opts.subjects.map((s) => s.id),
|
||||
frameId: new Array(subjectCount).fill(0),
|
||||
borderLeft: opts.subjects.map((s) => s.box.left),
|
||||
borderTop: opts.subjects.map((s) => s.box.top),
|
||||
borderRight: opts.subjects.map((s) => s.box.right),
|
||||
borderBottom: opts.subjects.map((s) => s.box.bottom),
|
||||
paddingLeft: opts.subjects.map((s) => s.box.left),
|
||||
paddingTop: opts.subjects.map((s) => s.box.top),
|
||||
paddingRight: opts.subjects.map((s) => s.box.right),
|
||||
paddingBottom: opts.subjects.map((s) => s.box.bottom),
|
||||
contentLeft: opts.subjects.map((s) => s.box.left),
|
||||
contentTop: opts.subjects.map((s) => s.box.top),
|
||||
contentRight: opts.subjects.map((s) => s.box.right),
|
||||
contentBottom: opts.subjects.map((s) => s.box.bottom),
|
||||
},
|
||||
rects: {
|
||||
rectId: opts.subjects.map((_, i) => i),
|
||||
left: opts.subjects.map((s) => s.box.left),
|
||||
top: opts.subjects.map((s) => s.box.top),
|
||||
right: opts.subjects.map((s) => s.box.right),
|
||||
bottom: opts.subjects.map((s) => s.box.bottom),
|
||||
},
|
||||
topology: {
|
||||
containingBlockOf: opts.topology?.containingBlockOf ?? [],
|
||||
nearestPositionedAncestorOf:
|
||||
opts.topology?.nearestPositionedAncestorOf ?? [],
|
||||
scrollContainerOf: opts.topology?.scrollContainerOf ?? [],
|
||||
stackingContextOf: opts.topology?.stackingContextOf ?? [],
|
||||
formattingContextOf: [],
|
||||
clippingRootOf: [],
|
||||
paintOrderBucket: [],
|
||||
paintOrderIndex: [],
|
||||
},
|
||||
scroll: {
|
||||
containerId: [],
|
||||
scrollLeft: [],
|
||||
scrollTop: [],
|
||||
scrollWidth: [],
|
||||
scrollHeight: [],
|
||||
clientWidth: [],
|
||||
clientHeight: [],
|
||||
},
|
||||
visualBoxes: {
|
||||
boxId: opts.subjects.map((_, i) => i),
|
||||
subjectId: opts.subjects.map((s) => s.id),
|
||||
frameId: new Array(subjectCount).fill(0),
|
||||
borderLeft: opts.subjects.map((s) => s.box.left),
|
||||
borderTop: opts.subjects.map((s) => s.box.top),
|
||||
borderRight: opts.subjects.map((s) => s.box.right),
|
||||
borderBottom: opts.subjects.map((s) => s.box.bottom),
|
||||
paddingLeft: opts.subjects.map((s) => s.box.left),
|
||||
paddingTop: opts.subjects.map((s) => s.box.top),
|
||||
paddingRight: opts.subjects.map((s) => s.box.right),
|
||||
paddingBottom: opts.subjects.map((s) => s.box.bottom),
|
||||
contentLeft: opts.subjects.map((s) => s.box.left),
|
||||
contentTop: opts.subjects.map((s) => s.box.top),
|
||||
contentRight: opts.subjects.map((s) => s.box.right),
|
||||
contentBottom: opts.subjects.map((s) => s.box.bottom),
|
||||
},
|
||||
transforms: {
|
||||
transformId: [],
|
||||
subjectId: [],
|
||||
matrixStart: [],
|
||||
matrixLength: [],
|
||||
originX: [],
|
||||
originY: [],
|
||||
},
|
||||
matrices: {
|
||||
values: [],
|
||||
},
|
||||
clipping: {
|
||||
clipNodeId: [],
|
||||
subjectId: [],
|
||||
clipKind: [],
|
||||
clipLeft: [],
|
||||
clipTop: [],
|
||||
clipRight: [],
|
||||
clipBottom: [],
|
||||
parentClipNodeId: [],
|
||||
},
|
||||
visibility: {
|
||||
subjectId: [],
|
||||
isRendered: [],
|
||||
isVisible: [],
|
||||
visibleArea: [],
|
||||
clippedArea: [],
|
||||
},
|
||||
}
|
||||
|
||||
return world
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clause Descriptor Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildClauseDescriptor(opts: {
|
||||
clauseId: string
|
||||
clauseKind: string
|
||||
subjectRef: number
|
||||
referenceRef?: number
|
||||
frameRef?: number
|
||||
bounds?: { minGap?: number; maxGap?: number; tolerance?: number; min?: number; max?: number }
|
||||
options?: Record<string, unknown>
|
||||
}): ClauseDescriptor {
|
||||
return {
|
||||
clauseId: opts.clauseId,
|
||||
clauseKind: opts.clauseKind,
|
||||
version: 1,
|
||||
subjectRef: opts.subjectRef,
|
||||
referenceRef: opts.referenceRef,
|
||||
frameRef: opts.frameRef,
|
||||
bounds: opts.bounds ?? {},
|
||||
options: opts.options ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Solver Result Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildMockSolverResult(opts: {
|
||||
clauseResults?: ClauseResult[]
|
||||
diagnostics?: Array<{
|
||||
code: string
|
||||
severity: 'error' | 'warning' | 'info'
|
||||
category: string
|
||||
message: string
|
||||
clauseId?: string
|
||||
}>
|
||||
}): EvaluationOutput {
|
||||
return {
|
||||
clauseResults: opts.clauseResults ?? [],
|
||||
groupResults: [],
|
||||
proofs: [],
|
||||
diagnostics: opts.diagnostics ?? [],
|
||||
trace: [],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extraction Result Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildMockExtractionResult(opts: {
|
||||
requestId?: string
|
||||
worlds: GeometryWorld[]
|
||||
diagnostics?: CoreDiagnostic[]
|
||||
}): {
|
||||
requestId: string
|
||||
status: 'ok' | 'partial' | 'error'
|
||||
worlds: GeometryWorld[]
|
||||
diagnostics: CoreDiagnostic[]
|
||||
} {
|
||||
return {
|
||||
requestId: opts.requestId ?? 'req_1',
|
||||
status: 'ok',
|
||||
worlds: opts.worlds,
|
||||
diagnostics: opts.diagnostics ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MockScene {
|
||||
world: GeometryWorld
|
||||
clauses: ClauseDescriptor[]
|
||||
}
|
||||
|
||||
export function createMockScene(opts: {
|
||||
subjects: MockSubject[]
|
||||
relations: Array<{
|
||||
kind: string
|
||||
subjectId: number
|
||||
referenceId: number
|
||||
bounds?: { minGap?: number; maxGap?: number }
|
||||
}>
|
||||
}): MockScene {
|
||||
const world = buildMockGeometryWorld({ subjects: opts.subjects })
|
||||
const clauses = opts.relations.map((r, i) =>
|
||||
buildClauseDescriptor({
|
||||
clauseId: `clause_${i + 1}`,
|
||||
clauseKind: r.kind,
|
||||
subjectRef: r.subjectId,
|
||||
referenceRef: r.referenceId,
|
||||
bounds: r.bounds,
|
||||
}),
|
||||
)
|
||||
return { world, clauses }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Semantic IR to Solver Clause Mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map a compiled Semantic IR to solver ClauseDescriptors.
|
||||
*
|
||||
* This bridges the DSL compiler output to the solver evaluation input.
|
||||
* The `subjectIdMap` translates semantic IR subject ids to world subject ids.
|
||||
*/
|
||||
export function mapSemanticIrToClauses(
|
||||
semanticIr: SemanticIr,
|
||||
subjectIdMap: Map<ImhotepId, number>,
|
||||
): ClauseDescriptor[] {
|
||||
const clauses: ClauseDescriptor[] = []
|
||||
|
||||
for (const [, clause] of semanticIr.clauses) {
|
||||
const subjectRef = subjectIdMap.get(clause.subjectRef)
|
||||
const referenceRef = clause.referenceRef
|
||||
? subjectIdMap.get(clause.referenceRef)
|
||||
: undefined
|
||||
|
||||
if (subjectRef === undefined) continue
|
||||
|
||||
const tolerance = semanticIr.tolerances.get(clause.toleranceRef)
|
||||
const bounds: Record<string, number> = {}
|
||||
|
||||
if (clause.bounds.minGap) {
|
||||
bounds.minGap = clause.bounds.minGap.value
|
||||
}
|
||||
if (clause.bounds.maxGap) {
|
||||
bounds.maxGap = clause.bounds.maxGap.value
|
||||
}
|
||||
if (tolerance) {
|
||||
bounds.tolerance = tolerance.value
|
||||
}
|
||||
|
||||
clauses.push({
|
||||
clauseId: clause.id,
|
||||
clauseKind: `relation.${clause.relation}`,
|
||||
version: 1,
|
||||
subjectRef,
|
||||
referenceRef,
|
||||
bounds,
|
||||
})
|
||||
}
|
||||
|
||||
return clauses
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshot Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildMockSnapshot(opts: {
|
||||
id?: string
|
||||
stateKind?: string
|
||||
stateSource?: string
|
||||
selector?: string
|
||||
world: GeometryWorld
|
||||
}): GeometrySnapshot {
|
||||
const metadata: SnapshotMetadata = {
|
||||
snapshotId: opts.id ?? 'snap_1',
|
||||
stateKind: (opts.stateKind as any) ?? 'default',
|
||||
stateSource: (opts.stateSource as any) ?? 'synthetic',
|
||||
selector: opts.selector,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
return {
|
||||
id: opts.id ?? 'snap_1',
|
||||
metadata,
|
||||
world: opts.world,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Reporter -> Diagnostics integration tests.
|
||||
*
|
||||
* Verifies that solver results produce correct diagnostics, that proof traces
|
||||
* map to human-readable output, and that witness shrinking works end-to-end.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
makeDiagnostic,
|
||||
diagnosticFromProof,
|
||||
renderHumanReport,
|
||||
renderJsonReport,
|
||||
buildJsonReport,
|
||||
shrinkWitness,
|
||||
createTraceBuilder,
|
||||
findClauseTraces,
|
||||
formatDiagnosticCompact,
|
||||
} from 'imhotep-reporter'
|
||||
import type {
|
||||
ProofLike,
|
||||
Witness,
|
||||
ShrinkResult,
|
||||
} from 'imhotep-reporter'
|
||||
import { buildMockSolverResult } from './integration-mocks.js'
|
||||
|
||||
describe('Reporter -> Diagnostics', () => {
|
||||
it('solver results produce correct diagnostics from failing proofs', () => {
|
||||
// Arrange: create a failing proof with predicate and witness
|
||||
const proof: ProofLike = {
|
||||
proofId: 'p1',
|
||||
clauseId: 'c1',
|
||||
outcome: 'fail',
|
||||
truth: 'determinate',
|
||||
failedPredicate: { op: '>=', left: 10, right: 24 },
|
||||
witness: {
|
||||
subjectId: 1,
|
||||
referenceId: 2,
|
||||
envCaseId: 'env_1',
|
||||
snapshotId: 'default',
|
||||
},
|
||||
}
|
||||
|
||||
// Act: convert proof to diagnostic
|
||||
const diagnostic = diagnosticFromProof(proof, {
|
||||
idGen: () => 'd1',
|
||||
codeForClause: () => 'IMH_RELATION_ABOVE_FAILED',
|
||||
messageForClause: () => 'Expected .tooltip to be above .trigger',
|
||||
fixHintsForClause: () => ['Increase vertical gap to at least 24px'],
|
||||
})
|
||||
|
||||
// Assert
|
||||
assert.ok(diagnostic)
|
||||
assert.strictEqual(diagnostic!.code, 'IMH_RELATION_ABOVE_FAILED')
|
||||
assert.strictEqual(diagnostic!.category, 'contract-failure')
|
||||
assert.strictEqual(diagnostic!.severity, 'error')
|
||||
assert.ok(
|
||||
diagnostic!.related.some((rel) =>
|
||||
rel.message.includes('Predicate'),
|
||||
),
|
||||
)
|
||||
assert.ok(
|
||||
diagnostic!.fixHints.some((hint) =>
|
||||
hint.includes('gap'),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('indeterminate proofs produce indeterminate-result diagnostics', () => {
|
||||
// Arrange: create an indeterminate failing proof
|
||||
const proof: ProofLike = {
|
||||
proofId: 'p1',
|
||||
clauseId: 'c1',
|
||||
outcome: 'fail',
|
||||
truth: 'indeterminate',
|
||||
witness: { envCaseId: 'env_1' },
|
||||
}
|
||||
|
||||
// Act
|
||||
const diagnostic = diagnosticFromProof(proof, {
|
||||
idGen: () => 'd1',
|
||||
codeForClause: () => 'IMH_RELATION_ABOVE_FAILED',
|
||||
messageForClause: () => 'Could not determine relation',
|
||||
fixHintsForClause: () => [],
|
||||
})
|
||||
|
||||
// Assert
|
||||
assert.ok(diagnostic)
|
||||
assert.strictEqual(diagnostic!.category, 'indeterminate-result')
|
||||
})
|
||||
|
||||
it('passing proofs produce no diagnostics', () => {
|
||||
// Arrange: create a passing proof
|
||||
const proof: ProofLike = {
|
||||
proofId: 'p1',
|
||||
clauseId: 'c1',
|
||||
outcome: 'pass',
|
||||
truth: 'determinate',
|
||||
}
|
||||
|
||||
// Act
|
||||
const diagnostic = diagnosticFromProof(proof, {
|
||||
idGen: () => 'd1',
|
||||
codeForClause: () => 'IMH_RELATION_ABOVE_FAILED',
|
||||
messageForClause: () => 'Should not be called',
|
||||
fixHintsForClause: () => [],
|
||||
})
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(diagnostic, null)
|
||||
})
|
||||
|
||||
it('proof traces map to human-readable output', () => {
|
||||
// Arrange: create diagnostic linked to a trace
|
||||
const diagnostic = makeDiagnostic(
|
||||
{
|
||||
code: 'IMH_RELATION_LEFT_OF_FAILED',
|
||||
category: 'contract-failure',
|
||||
message: 'Button is not left of field',
|
||||
traceRef: 't1',
|
||||
position: {
|
||||
start: { line: 3, column: 5, offset: 20 },
|
||||
end: { line: 3, column: 40, offset: 55 },
|
||||
},
|
||||
},
|
||||
{ idGen: () => 'd1' },
|
||||
)
|
||||
|
||||
const traces = [
|
||||
{
|
||||
traceEventId: 't1',
|
||||
phase: 'clause-evaluated' as const,
|
||||
at: 1000,
|
||||
refs: { clauseId: 'c1', diagnosticId: 'd1' },
|
||||
payload: { status: 'fail' },
|
||||
},
|
||||
{
|
||||
traceEventId: 't2',
|
||||
phase: 'proof-created' as const,
|
||||
at: 1001,
|
||||
refs: { clauseId: 'c1', proofId: 'p1' },
|
||||
payload: {},
|
||||
},
|
||||
]
|
||||
|
||||
// Act: render human report with traces
|
||||
const report = renderHumanReport(
|
||||
[diagnostic],
|
||||
traces,
|
||||
new Map(),
|
||||
{ showTraces: true },
|
||||
)
|
||||
|
||||
// Assert
|
||||
assert.ok(report.includes('Button is not left of field'))
|
||||
assert.ok(report.includes('clause-evaluated'))
|
||||
assert.ok(report.includes('IMH_RELATION_LEFT_OF_FAILED'))
|
||||
assert.ok(report.includes('line 3'))
|
||||
})
|
||||
|
||||
it('trace builder emits events findable by clause id', () => {
|
||||
// Arrange: create trace builder
|
||||
const builder = createTraceBuilder({
|
||||
idGen: () => 't1',
|
||||
now: () => Date.now(),
|
||||
})
|
||||
|
||||
// Act: emit events
|
||||
builder.emit({
|
||||
phase: 'clause-evaluated',
|
||||
refs: { clauseId: 'c1' },
|
||||
payload: { status: 'pass' },
|
||||
})
|
||||
builder.emit({
|
||||
phase: 'clause-evaluated',
|
||||
refs: { clauseId: 'c2' },
|
||||
payload: { status: 'fail' },
|
||||
})
|
||||
builder.emit({
|
||||
phase: 'proof-created',
|
||||
refs: { clauseId: 'c1', proofId: 'p1' },
|
||||
payload: {},
|
||||
})
|
||||
|
||||
// Assert: findClauseTraces returns correct subset
|
||||
const found = findClauseTraces(builder.events(), 'c1')
|
||||
assert.strictEqual(found.length, 2)
|
||||
assert.ok(
|
||||
found.every((ev) => ev.refs.clauseId === 'c1'),
|
||||
)
|
||||
})
|
||||
|
||||
it('witness shrinking works end-to-end', () => {
|
||||
// Arrange: create a witness with redundant env cases
|
||||
const witness: Witness = {
|
||||
proof: {
|
||||
proofId: 'p1',
|
||||
clauseId: 'c1',
|
||||
outcome: 'fail',
|
||||
truth: 'determinate',
|
||||
},
|
||||
envCases: ['env_1', 'env_2', 'env_3'],
|
||||
snapshots: ['default', 'hover'],
|
||||
subjects: [1, 2, 3],
|
||||
facts: [100, 101, 102],
|
||||
}
|
||||
|
||||
// Act: shrink witness — only env_2 and default snapshot are needed
|
||||
const stillFails = (w: Witness) =>
|
||||
w.envCases.includes('env_2') && w.snapshots.includes('default')
|
||||
|
||||
const result = shrinkWitness(witness, stillFails)
|
||||
|
||||
// Assert: witness was reduced
|
||||
assert.strictEqual(result.reduced, true)
|
||||
assert.deepStrictEqual(result.witness.envCases, ['env_2'])
|
||||
assert.deepStrictEqual(result.witness.snapshots, ['default'])
|
||||
assert.ok(result.axes.includes('env-case'))
|
||||
assert.ok(result.axes.includes('snapshot'))
|
||||
assert.ok(result.steps > 0)
|
||||
})
|
||||
|
||||
it('shrink results render in JSON report', () => {
|
||||
// Arrange: create a shrunk witness
|
||||
const witness: Witness = {
|
||||
proof: {
|
||||
proofId: 'p1',
|
||||
clauseId: 'c1',
|
||||
outcome: 'fail',
|
||||
truth: 'determinate',
|
||||
},
|
||||
envCases: ['env_1'],
|
||||
snapshots: ['default'],
|
||||
subjects: [1],
|
||||
facts: [100],
|
||||
}
|
||||
|
||||
const shrinkResult: ShrinkResult = {
|
||||
reduced: true,
|
||||
witness,
|
||||
axes: ['env-case', 'subject'],
|
||||
steps: 3,
|
||||
}
|
||||
|
||||
const shrinkMap = new Map<string, ShrinkResult>()
|
||||
shrinkMap.set('c1', shrinkResult)
|
||||
|
||||
const diagnostic = makeDiagnostic(
|
||||
{
|
||||
code: 'IMH_TEST',
|
||||
category: 'contract-failure',
|
||||
message: 'Fail',
|
||||
clauseId: 'c1',
|
||||
},
|
||||
{ idGen: () => 'd1' },
|
||||
)
|
||||
|
||||
// Act: build JSON report with shrink
|
||||
const report = buildJsonReport(
|
||||
[diagnostic],
|
||||
[],
|
||||
shrinkMap,
|
||||
{ includeShrink: true },
|
||||
)
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(report.summary.errorCount, 1)
|
||||
assert.ok(report.shrinkResults)
|
||||
assert.ok(report.shrinkResults!.c1)
|
||||
assert.strictEqual(report.shrinkResults!.c1.reduced, true)
|
||||
assert.ok(report.shrinkResults!.c1.axes.includes('env-case'))
|
||||
|
||||
// Act: stringify
|
||||
const json = renderJsonReport(
|
||||
[diagnostic],
|
||||
[],
|
||||
shrinkMap,
|
||||
{ includeShrink: true, indent: 2 },
|
||||
)
|
||||
assert.ok(json.includes('shrinkResults'))
|
||||
assert.ok(json.includes('env-case'))
|
||||
})
|
||||
|
||||
it('solver diagnostics flow through to reporter formatting', () => {
|
||||
// Arrange: create solver result with diagnostics
|
||||
const solverResult = buildMockSolverResult({
|
||||
clauseResults: [
|
||||
{
|
||||
clauseId: 'c1',
|
||||
status: 'fail',
|
||||
truth: 'determinate',
|
||||
metrics: { observedGap: 4, minGap: 16 },
|
||||
witness: { subjectId: 1, referenceId: 2 },
|
||||
},
|
||||
],
|
||||
diagnostics: [
|
||||
{
|
||||
code: 'IMH_INTERNAL_UNKNOWN_CLAUSE_KIND',
|
||||
severity: 'error',
|
||||
category: 'internal-error',
|
||||
message: 'Evaluation error',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Act: convert solver diagnostics to reporter diagnostics
|
||||
const diagnostics = solverResult.diagnostics.map((d) =>
|
||||
makeDiagnostic(
|
||||
{
|
||||
code: d.code,
|
||||
category: d.category as any,
|
||||
message: d.message,
|
||||
severity: d.severity as any,
|
||||
},
|
||||
{ idGen: () => 'd1' },
|
||||
),
|
||||
)
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(diagnostics.length, 1)
|
||||
assert.strictEqual(diagnostics[0].code, 'IMH_INTERNAL_UNKNOWN_CLAUSE_KIND')
|
||||
|
||||
// Act: render compact
|
||||
const compact = formatDiagnosticCompact(diagnostics[0])
|
||||
assert.ok(compact.includes('IMH_INTERNAL_UNKNOWN_CLAUSE_KIND'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* State -> Solver integration tests.
|
||||
*
|
||||
* Verifies that state snapshots create distinct geometry worlds,
|
||||
* that state comparison assertions evaluate correctly, and that
|
||||
* transition sampling produces valid worlds.
|
||||
*/
|
||||
|
||||
import { describe, it, before } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { evaluate, registerDefaultClauses } from 'imhotep-solver'
|
||||
import type { GeometryWorld } from 'imhotep-solver'
|
||||
import {
|
||||
compareSnapshots,
|
||||
createSnapshotStore,
|
||||
} from 'imhotep-state'
|
||||
import type { GeometrySnapshot } from 'imhotep-state'
|
||||
import {
|
||||
buildMockGeometryWorld,
|
||||
buildMockSnapshot,
|
||||
buildClauseDescriptor,
|
||||
} from './integration-mocks.js'
|
||||
|
||||
describe('State -> Solver', () => {
|
||||
before(() => {
|
||||
registerDefaultClauses()
|
||||
})
|
||||
|
||||
it('state snapshots create distinct geometry worlds', () => {
|
||||
// Arrange: build two worlds that differ only in box height
|
||||
const defaultWorld = buildMockGeometryWorld({
|
||||
snapshotId: 'snap_default',
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.btn',
|
||||
box: { top: 0, bottom: 30, left: 0, right: 100 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const hoverWorld = buildMockGeometryWorld({
|
||||
snapshotId: 'snap_hover',
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.btn',
|
||||
box: { top: 0, bottom: 32, left: 0, right: 100 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Act: wrap as snapshots
|
||||
const defaultSnap = buildMockSnapshot({
|
||||
id: 'snap_default',
|
||||
stateKind: 'default',
|
||||
world: defaultWorld,
|
||||
})
|
||||
|
||||
const hoverSnap = buildMockSnapshot({
|
||||
id: 'snap_hover',
|
||||
stateKind: 'hover',
|
||||
world: hoverWorld,
|
||||
})
|
||||
|
||||
// Assert: snapshots are distinct
|
||||
const diff = compareSnapshots(defaultSnap, hoverSnap)
|
||||
assert.strictEqual(diff.identical, false)
|
||||
assert.ok(
|
||||
diff.differences.some((d) => d.includes('world content differs')),
|
||||
)
|
||||
})
|
||||
|
||||
it('state comparison assertions evaluate correctly across snapshots', () => {
|
||||
// Arrange: default and hover worlds with different sizes
|
||||
const defaultWorld = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.btn',
|
||||
box: { top: 0, bottom: 30, left: 0, right: 100 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const hoverWorld = buildMockGeometryWorld({
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.btn',
|
||||
box: { top: 0, bottom: 32, left: 0, right: 100 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Act: evaluate size assertion (height >= 31) on both worlds
|
||||
const clauses = [
|
||||
buildClauseDescriptor({
|
||||
clauseId: 'c1',
|
||||
clauseKind: 'size.atLeast',
|
||||
subjectRef: 0,
|
||||
bounds: { min: 31 },
|
||||
options: { dimension: 'height' },
|
||||
}),
|
||||
]
|
||||
|
||||
const defaultResult = evaluate(defaultWorld, clauses)
|
||||
const hoverResult = evaluate(hoverWorld, clauses)
|
||||
|
||||
// Assert: default fails (30 < 31), hover passes (32 >= 31)
|
||||
assert.strictEqual(defaultResult.clauseResults[0].status, 'fail')
|
||||
assert.strictEqual(hoverResult.clauseResults[0].status, 'pass')
|
||||
})
|
||||
|
||||
it('snapshot store maintains distinct worlds per state', () => {
|
||||
// Arrange: create store and add multiple state snapshots
|
||||
const store = createSnapshotStore()
|
||||
|
||||
const defaultWorld = buildMockGeometryWorld({
|
||||
snapshotId: 'snap_default',
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.input',
|
||||
box: { top: 0, bottom: 20, left: 0, right: 200 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const focusWorld = buildMockGeometryWorld({
|
||||
snapshotId: 'snap_focus',
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.input',
|
||||
box: { top: 0, bottom: 22, left: 0, right: 200 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
store.add(
|
||||
buildMockSnapshot({
|
||||
id: 'snap_default',
|
||||
stateKind: 'default',
|
||||
world: defaultWorld,
|
||||
}),
|
||||
)
|
||||
store.add(
|
||||
buildMockSnapshot({
|
||||
id: 'snap_focus',
|
||||
stateKind: 'focus',
|
||||
world: focusWorld,
|
||||
}),
|
||||
)
|
||||
|
||||
// Act: retrieve snapshots
|
||||
const snapshots = store.list()
|
||||
|
||||
// Assert
|
||||
assert.strictEqual(snapshots.length, 2)
|
||||
assert.strictEqual(snapshots[0].metadata.stateKind, 'default')
|
||||
assert.strictEqual(snapshots[1].metadata.stateKind, 'focus')
|
||||
|
||||
// Assert: worlds are distinct
|
||||
const diff = compareSnapshots(snapshots[0], snapshots[1])
|
||||
assert.strictEqual(diff.identical, false)
|
||||
})
|
||||
|
||||
it('transition sampling produces valid worlds for solver', () => {
|
||||
// Arrange: create a store to hold transition samples
|
||||
const store = createSnapshotStore()
|
||||
|
||||
// Simulate 3 transition samples with increasing vertical offset
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const world = buildMockGeometryWorld({
|
||||
snapshotId: `snap_transition_${i}`,
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.box',
|
||||
box: {
|
||||
top: i * 10,
|
||||
bottom: i * 10 + 20,
|
||||
left: 0,
|
||||
right: 50,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const snap = buildMockSnapshot({
|
||||
id: `snap_transition_${i}`,
|
||||
stateKind: 'default',
|
||||
stateSource: 'synthetic',
|
||||
world,
|
||||
})
|
||||
store.add(snap)
|
||||
}
|
||||
|
||||
// Act: retrieve and evaluate each snapshot
|
||||
const snapshots = store.list()
|
||||
assert.strictEqual(snapshots.length, 3)
|
||||
|
||||
for (const snap of snapshots) {
|
||||
const world = snap.world as GeometryWorld
|
||||
const clauses = [
|
||||
buildClauseDescriptor({
|
||||
clauseId: 'c1',
|
||||
clauseKind: 'size.atLeast',
|
||||
subjectRef: 0,
|
||||
bounds: { min: 20 },
|
||||
options: { dimension: 'height' },
|
||||
}),
|
||||
]
|
||||
const result = evaluate(world, clauses)
|
||||
assert.strictEqual(
|
||||
result.clauseResults[0].status,
|
||||
'pass',
|
||||
`Sample ${snap.id} should pass height >= 20`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('state worlds carry correct metadata for diagnostics', () => {
|
||||
// Arrange: build snapshot with full metadata
|
||||
const world = buildMockGeometryWorld({
|
||||
snapshotId: 'snap_hover',
|
||||
subjects: [
|
||||
{
|
||||
id: 0,
|
||||
selector: '.tooltip',
|
||||
box: { top: 0, bottom: 10, left: 0, right: 50 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const snap = buildMockSnapshot({
|
||||
id: 'snap_hover',
|
||||
stateKind: 'hover',
|
||||
stateSource: 'interaction',
|
||||
selector: '.tooltip',
|
||||
world,
|
||||
})
|
||||
|
||||
// Assert: metadata is preserved
|
||||
assert.strictEqual(snap.metadata.stateKind, 'hover')
|
||||
assert.strictEqual(snap.metadata.stateSource, 'interaction')
|
||||
assert.strictEqual(snap.metadata.selector, '.tooltip')
|
||||
assert.strictEqual(snap.id, 'snap_hover')
|
||||
|
||||
// Act: evaluate a relation on the state world
|
||||
const clauses = [
|
||||
buildClauseDescriptor({
|
||||
clauseId: 'c1',
|
||||
clauseKind: 'size.atLeast',
|
||||
subjectRef: 0,
|
||||
bounds: { min: 5 },
|
||||
options: { dimension: 'height' },
|
||||
}),
|
||||
]
|
||||
|
||||
const result = evaluate(snap.world as GeometryWorld, clauses)
|
||||
assert.strictEqual(result.clauseResults[0].status, 'pass')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Semantic IR and Execution IR definitions for Imhotep.
|
||||
*
|
||||
* Semantic IR:
|
||||
* Normalized graph of explicit obligations, frames, selectors, states,
|
||||
* environments, and tolerances. Stored as tables keyed by stable id.
|
||||
*
|
||||
* Execution IR:
|
||||
* Flattened, data-oriented arrays optimized for bulk evaluation against
|
||||
* a geometry world. This is the hot-path representation.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ImhotepId,
|
||||
SourceOrigin,
|
||||
LengthLiteral,
|
||||
Environment,
|
||||
StateSnapshot,
|
||||
TimelineSnapshot,
|
||||
Frame,
|
||||
Subject,
|
||||
} from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Semantic IR Tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SemanticIr {
|
||||
subjects: Map<ImhotepId, SemanticSubject>
|
||||
frames: Map<ImhotepId, SemanticFrame>
|
||||
states: Map<ImhotepId, SemanticState>
|
||||
timelines: Map<ImhotepId, SemanticTimeline>
|
||||
tolerances: Map<ImhotepId, SemanticTolerance>
|
||||
envGuards: Map<ImhotepId, SemanticEnvGuard>
|
||||
clauses: Map<ImhotepId, SemanticClause>
|
||||
groups: Map<ImhotepId, SemanticGroup>
|
||||
diagnosticMetadata: Map<ImhotepId, SemanticDiagnosticMeta>
|
||||
}
|
||||
|
||||
export interface SemanticSubject {
|
||||
id: ImhotepId
|
||||
selector: string
|
||||
kind: Subject['kind']
|
||||
origin: SourceOrigin
|
||||
}
|
||||
|
||||
export interface SemanticFrame {
|
||||
id: ImhotepId
|
||||
kind: Frame['kind']
|
||||
selector?: string
|
||||
name?: string
|
||||
originX: number
|
||||
originY: number
|
||||
writingMode: Frame['writingMode']
|
||||
origin: SourceOrigin
|
||||
}
|
||||
|
||||
export interface SemanticState {
|
||||
id: ImhotepId
|
||||
kind: StateSnapshot['kind']
|
||||
name?: string
|
||||
origin: SourceOrigin
|
||||
}
|
||||
|
||||
export interface SemanticTimeline {
|
||||
id: ImhotepId
|
||||
mode: TimelineSnapshot['mode']
|
||||
samples?: number[]
|
||||
origin: SourceOrigin
|
||||
}
|
||||
|
||||
export interface SemanticTolerance {
|
||||
id: ImhotepId
|
||||
value: number
|
||||
unit: 'jnd' | 'px'
|
||||
origin: SourceOrigin
|
||||
}
|
||||
|
||||
export interface SemanticEnvGuard {
|
||||
id: ImhotepId
|
||||
expression: string
|
||||
normalizedCases: Environment[]
|
||||
origin: SourceOrigin
|
||||
}
|
||||
|
||||
export interface SemanticClause {
|
||||
id: ImhotepId
|
||||
origin: SourceOrigin
|
||||
subjectRef: ImhotepId
|
||||
referenceRef?: ImhotepId
|
||||
relation: string
|
||||
frameRef: ImhotepId
|
||||
stateRef: ImhotepId
|
||||
timelineRef: ImhotepId
|
||||
envGuardRef: ImhotepId
|
||||
toleranceRef: ImhotepId
|
||||
bounds: {
|
||||
minGap?: LengthLiteral
|
||||
maxGap?: LengthLiteral
|
||||
}
|
||||
}
|
||||
|
||||
export interface SemanticGroup {
|
||||
id: ImhotepId
|
||||
origin: SourceOrigin
|
||||
operator: 'and' | 'or' | 'not' | 'quantifier'
|
||||
quantifier?: {
|
||||
kind: string
|
||||
count?: number
|
||||
}
|
||||
clauseRefs: ImhotepId[]
|
||||
groupRefs?: ImhotepId[]
|
||||
}
|
||||
|
||||
export interface SemanticDiagnosticMeta {
|
||||
id: ImhotepId
|
||||
clauseRef?: ImhotepId
|
||||
messageTemplate: string
|
||||
origin: SourceOrigin
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution IR Tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Execution IR stores clause data in parallel typed arrays for bulk
|
||||
* evaluation. Every array is indexed by clauseIndex.
|
||||
*/
|
||||
export interface ExecutionIr {
|
||||
clauseCount: number
|
||||
|
||||
// Clause classification and cross-references (all Uint32Array)
|
||||
clauseType: Uint16Array
|
||||
clauseSubject: Uint32Array
|
||||
clauseReference: Uint32Array
|
||||
clauseFrame: Uint32Array
|
||||
clauseState: Uint32Array
|
||||
clauseTimeline: Uint32Array
|
||||
clauseTolerance: Uint32Array
|
||||
clauseEnvGuard: Uint32Array
|
||||
|
||||
// Numeric bounds (Float64Array)
|
||||
clauseArg0: Float64Array
|
||||
clauseArg1: Float64Array
|
||||
|
||||
// Flags and origin (Uint32Array)
|
||||
clauseFlags: Uint32Array
|
||||
clauseOrigin: Uint32Array
|
||||
}
|
||||
|
||||
export interface ExecutionIrBuilder {
|
||||
addClause(descriptor: ExecutionClauseDescriptor): number
|
||||
build(): ExecutionIr
|
||||
}
|
||||
|
||||
export interface ExecutionClauseDescriptor {
|
||||
clauseType: number
|
||||
subjectIndex: number
|
||||
referenceIndex: number
|
||||
frameIndex: number
|
||||
stateIndex: number
|
||||
timelineIndex: number
|
||||
toleranceIndex: number
|
||||
envGuardIndex: number
|
||||
arg0: number
|
||||
arg1: number
|
||||
flags: number
|
||||
originIndex: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IR Lowering Pipeline Stages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type LoweringStage =
|
||||
| 'parse'
|
||||
| 'validate'
|
||||
| 'normalize'
|
||||
| 'resolve-defaults'
|
||||
| 'compile'
|
||||
| 'derive-facts'
|
||||
|
||||
export interface LoweringPipeline {
|
||||
stages: LoweringStage[]
|
||||
currentStage: LoweringStage
|
||||
diagnostics: unknown[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clause Family Registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ClauseFamilyDescriptor {
|
||||
clauseKind: string
|
||||
version: number
|
||||
requiredFacts: string[]
|
||||
}
|
||||
|
||||
export interface ClauseFamilyRegistry {
|
||||
register(descriptor: ClauseFamilyDescriptor): void
|
||||
lookup(clauseKind: string): ClauseFamilyDescriptor | undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Create empty Semantic IR
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createEmptySemanticIr(): SemanticIr {
|
||||
return {
|
||||
subjects: new Map(),
|
||||
frames: new Map(),
|
||||
states: new Map(),
|
||||
timelines: new Map(),
|
||||
tolerances: new Map(),
|
||||
envGuards: new Map(),
|
||||
clauses: new Map(),
|
||||
groups: new Map(),
|
||||
diagnosticMetadata: new Map(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Pure first-order logic AST types for Imhotep V1.1.
|
||||
*
|
||||
* These nodes represent the deterministic scene logic layer:
|
||||
* quantifiers, boolean connectives, predicate calls, and terms.
|
||||
*
|
||||
* All nodes follow the unist-style shape with source spans.
|
||||
*/
|
||||
|
||||
import type { AstNode } from './ast.js'
|
||||
import type { Position } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formula Union
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FormulaNode =
|
||||
| ForAllFormula
|
||||
| ExistsFormula
|
||||
| AndFormula
|
||||
| OrFormula
|
||||
| NotFormula
|
||||
| ImpliesFormula
|
||||
| PredicateCall
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quantifier Formulas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ForAllFormula extends AstNode {
|
||||
type: 'FormulaNode'
|
||||
kind: 'forall'
|
||||
bindings: TupleBinding[]
|
||||
body: FormulaNode
|
||||
}
|
||||
|
||||
export interface ExistsFormula extends AstNode {
|
||||
type: 'FormulaNode'
|
||||
kind: 'exists'
|
||||
bindings: TupleBinding[]
|
||||
body: FormulaNode
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boolean Connective Formulas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AndFormula extends AstNode {
|
||||
type: 'FormulaNode'
|
||||
kind: 'and'
|
||||
left: FormulaNode
|
||||
right: FormulaNode
|
||||
}
|
||||
|
||||
export interface OrFormula extends AstNode {
|
||||
type: 'FormulaNode'
|
||||
kind: 'or'
|
||||
left: FormulaNode
|
||||
right: FormulaNode
|
||||
}
|
||||
|
||||
export interface NotFormula extends AstNode {
|
||||
type: 'FormulaNode'
|
||||
kind: 'not'
|
||||
operand: FormulaNode
|
||||
}
|
||||
|
||||
export interface ImpliesFormula extends AstNode {
|
||||
type: 'FormulaNode'
|
||||
kind: 'implies'
|
||||
antecedent: FormulaNode
|
||||
consequent: FormulaNode
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Atomic Formula: Predicate Call
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PredicateCall extends AstNode {
|
||||
type: 'FormulaNode'
|
||||
kind: 'predicate'
|
||||
predicate: string
|
||||
args: TermNode[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TermNode = VariableRef | DomainRef | AccessorTerm
|
||||
|
||||
export interface VariableRef extends AstNode {
|
||||
type: 'VariableRef'
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface DomainRef extends AstNode {
|
||||
type: 'DomainRef'
|
||||
domain: string
|
||||
selector?: string
|
||||
parentVar?: string
|
||||
}
|
||||
|
||||
export interface AccessorTerm extends AstNode {
|
||||
type: 'AccessorTerm'
|
||||
variable: string
|
||||
property: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tuple Binding (for multi-variable quantification)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TupleBinding extends AstNode {
|
||||
type: 'TupleBinding'
|
||||
variables: string[]
|
||||
domain: DomainRef
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property Run Blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PropertyRunBlock extends AstNode {
|
||||
type: 'PropertyRunBlock'
|
||||
mode: 'sampled' | 'enumerated'
|
||||
inputDomain: unknown
|
||||
body: AstNode[]
|
||||
}
|
||||
|
||||
export interface SampledRunBlock extends AstNode {
|
||||
type: 'SampledRunBlock'
|
||||
arbitrary: unknown
|
||||
numRuns?: number
|
||||
seed?: number
|
||||
body: AstNode[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type Guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isForAllFormula(node: FormulaNode): node is ForAllFormula {
|
||||
return node.kind === 'forall'
|
||||
}
|
||||
|
||||
export function isExistsFormula(node: FormulaNode): node is ExistsFormula {
|
||||
return node.kind === 'exists'
|
||||
}
|
||||
|
||||
export function isAndFormula(node: FormulaNode): node is AndFormula {
|
||||
return node.kind === 'and'
|
||||
}
|
||||
|
||||
export function isOrFormula(node: FormulaNode): node is OrFormula {
|
||||
return node.kind === 'or'
|
||||
}
|
||||
|
||||
export function isNotFormula(node: FormulaNode): node is NotFormula {
|
||||
return node.kind === 'not'
|
||||
}
|
||||
|
||||
export function isImpliesFormula(node: FormulaNode): node is ImpliesFormula {
|
||||
return node.kind === 'implies'
|
||||
}
|
||||
|
||||
export function isPredicateCall(node: FormulaNode): node is PredicateCall {
|
||||
return node.kind === 'predicate'
|
||||
}
|
||||
|
||||
export function isVariableRef(node: AstNode): node is VariableRef {
|
||||
return node.type === 'VariableRef'
|
||||
}
|
||||
|
||||
export function isDomainRef(node: AstNode): node is DomainRef {
|
||||
return node.type === 'DomainRef'
|
||||
}
|
||||
|
||||
export function isAccessorTerm(node: AstNode): node is AccessorTerm {
|
||||
return node.type === 'AccessorTerm'
|
||||
}
|
||||
|
||||
export function isTupleBinding(node: AstNode): node is TupleBinding {
|
||||
return node.type === 'TupleBinding'
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Flattened, data-oriented execution IR for first-order logic formulas.
|
||||
*
|
||||
* The hot-path representation stores formulas in parallel typed arrays
|
||||
* indexed by formulaId. No deep nesting exists at runtime; child
|
||||
* relationships are expressed through numeric offsets into the same
|
||||
* flat tables.
|
||||
*
|
||||
* Invariant: every formula referenced by a child pointer is stored in
|
||||
* the same LogicIr instance. The root formula always has id 0.
|
||||
*/
|
||||
|
||||
import type { ImhotepId, SourceOrigin } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formula Kinds (must fit in Uint8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FORMULA_KIND_FORALL = 1
|
||||
export const FORMULA_KIND_EXISTS = 2
|
||||
export const FORMULA_KIND_AND = 3
|
||||
export const FORMULA_KIND_OR = 4
|
||||
export const FORMULA_KIND_NOT = 5
|
||||
export const FORMULA_KIND_IMPLIES = 6
|
||||
export const FORMULA_KIND_PREDICATE = 7
|
||||
|
||||
export type FormulaKind =
|
||||
| typeof FORMULA_KIND_FORALL
|
||||
| typeof FORMULA_KIND_EXISTS
|
||||
| typeof FORMULA_KIND_AND
|
||||
| typeof FORMULA_KIND_OR
|
||||
| typeof FORMULA_KIND_NOT
|
||||
| typeof FORMULA_KIND_IMPLIES
|
||||
| typeof FORMULA_KIND_PREDICATE
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logic IR Tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LogicIr {
|
||||
/** Total number of formulas in this IR. */
|
||||
formulaCount: number
|
||||
|
||||
// Formula classification (all Uint8Array)
|
||||
formulaKind: Uint8Array
|
||||
|
||||
// Child pointers (Uint32Array, 0xFFFFFFFF means "none")
|
||||
formulaLeft: Uint32Array
|
||||
formulaRight: Uint32Array
|
||||
|
||||
// Binding region (for quantifiers)
|
||||
// Each quantifier formula references a slice [bindingStart, bindingStart + bindingCount)
|
||||
// in the flat binding table below.
|
||||
bindingStart: Uint32Array
|
||||
bindingCount: Uint8Array
|
||||
|
||||
// Predicate call region (for atomic formulas)
|
||||
// Each predicate formula references a slice [argStart, argStart + argCount)
|
||||
predicateId: Uint32Array
|
||||
argStart: Uint32Array
|
||||
argCount: Uint8Array
|
||||
|
||||
// Origin and proof tracking (Uint32Array)
|
||||
originIndex: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flat Binding Table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BindingTable {
|
||||
/** Number of bindings. */
|
||||
count: number
|
||||
|
||||
// Variable names (string table index)
|
||||
variableNameId: Uint32Array
|
||||
|
||||
// Domain descriptor index
|
||||
domainId: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flat Argument Table (for predicate calls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ArgTable {
|
||||
/** Number of argument terms. */
|
||||
count: number
|
||||
|
||||
// Term kind: 1 = variable, 2 = accessor, 3 = literal number
|
||||
termKind: Uint8Array
|
||||
|
||||
// For variables: binding table index
|
||||
// For accessors: variable binding index
|
||||
// For literals: 0xFFFFFFFF (value stored in literalValue)
|
||||
termRef: Uint32Array
|
||||
|
||||
// For accessors: string table index of property name
|
||||
// For literals: the literal numeric value (reinterpreted as Uint32)
|
||||
termAux: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Predicate Registry Index
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PredicateIndexEntry {
|
||||
predicateId: number
|
||||
name: string
|
||||
arity: number
|
||||
domainSignature: number[]
|
||||
requiredFacts: string[]
|
||||
}
|
||||
|
||||
export interface PredicateIndex {
|
||||
entries: PredicateIndexEntry[]
|
||||
byName: Map<string, number>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String Table (shared across IR tables)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IrStringTable {
|
||||
values: string[]
|
||||
byValue: Map<string, number>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Evaluation Request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LogicEvaluationRequest {
|
||||
logicIr: LogicIr
|
||||
bindings: BindingTable
|
||||
args: ArgTable
|
||||
predicates: PredicateIndex
|
||||
strings: IrStringTable
|
||||
rootFormulaId: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LogicIrBuilder {
|
||||
addFormula(descriptor: LogicFormulaDescriptor): number
|
||||
addBinding(descriptor: LogicBindingDescriptor): number
|
||||
addArg(descriptor: LogicArgDescriptor): number
|
||||
build(): LogicIr
|
||||
}
|
||||
|
||||
export interface LogicFormulaDescriptor {
|
||||
kind: FormulaKind
|
||||
left?: number
|
||||
right?: number
|
||||
bindingStart?: number
|
||||
bindingCount?: number
|
||||
predicateId?: number
|
||||
argStart?: number
|
||||
argCount?: number
|
||||
originIndex?: number
|
||||
}
|
||||
|
||||
export interface LogicBindingDescriptor {
|
||||
variableNameId: number
|
||||
domainId: number
|
||||
}
|
||||
|
||||
export interface LogicArgDescriptor {
|
||||
termKind: number
|
||||
termRef: number
|
||||
termAux: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Create empty Logic IR
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createEmptyLogicIr(): LogicIr {
|
||||
return {
|
||||
formulaCount: 0,
|
||||
formulaKind: new Uint8Array(0),
|
||||
formulaLeft: new Uint32Array(0),
|
||||
formulaRight: new Uint32Array(0),
|
||||
bindingStart: new Uint32Array(0),
|
||||
bindingCount: new Uint8Array(0),
|
||||
predicateId: new Uint32Array(0),
|
||||
argStart: new Uint32Array(0),
|
||||
argCount: new Uint8Array(0),
|
||||
originIndex: new Uint32Array(0),
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyBindingTable(): BindingTable {
|
||||
return {
|
||||
count: 0,
|
||||
variableNameId: new Uint32Array(0),
|
||||
domainId: new Uint32Array(0),
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyArgTable(): ArgTable {
|
||||
return {
|
||||
count: 0,
|
||||
termKind: new Uint8Array(0),
|
||||
termRef: new Uint32Array(0),
|
||||
termAux: new Uint32Array(0),
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyStringTable(): IrStringTable {
|
||||
return {
|
||||
values: [],
|
||||
byValue: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Intern a string into the string table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function internString(table: IrStringTable, value: string): number {
|
||||
const existing = table.byValue.get(value)
|
||||
if (existing !== undefined) {
|
||||
return existing
|
||||
}
|
||||
const id = table.values.length
|
||||
table.values.push(value)
|
||||
table.byValue.set(value, id)
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Unit tests for Imhotep logic AST nodes, IR structure, and domain
|
||||
* definitions.
|
||||
*
|
||||
* Covers Stream 1 contracts:
|
||||
* - Formula node construction and type guards
|
||||
* - Logic IR flat array layout
|
||||
* - Domain descriptor and value factories
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import type {
|
||||
FormulaNode,
|
||||
ForAllFormula,
|
||||
ExistsFormula,
|
||||
AndFormula,
|
||||
OrFormula,
|
||||
NotFormula,
|
||||
ImpliesFormula,
|
||||
PredicateCall,
|
||||
VariableRef,
|
||||
DomainRef,
|
||||
AccessorTerm,
|
||||
TupleBinding,
|
||||
PropertyRunBlock,
|
||||
SampledRunBlock,
|
||||
} from './logic-ast.js'
|
||||
|
||||
import {
|
||||
isForAllFormula,
|
||||
isExistsFormula,
|
||||
isAndFormula,
|
||||
isOrFormula,
|
||||
isNotFormula,
|
||||
isImpliesFormula,
|
||||
isPredicateCall,
|
||||
isVariableRef,
|
||||
isDomainRef,
|
||||
isAccessorTerm,
|
||||
isTupleBinding,
|
||||
} from './logic-ast.js'
|
||||
|
||||
import {
|
||||
createEmptyLogicIr,
|
||||
createEmptyBindingTable,
|
||||
createEmptyArgTable,
|
||||
createEmptyStringTable,
|
||||
internString,
|
||||
FORMULA_KIND_FORALL,
|
||||
FORMULA_KIND_PREDICATE,
|
||||
} from './logic-ir.js'
|
||||
|
||||
import {
|
||||
createElementsDomain,
|
||||
createDescendantsDomain,
|
||||
createLineBoxesDomain,
|
||||
createTextRunsDomain,
|
||||
createFragmentsDomain,
|
||||
createFramesDomain,
|
||||
createDomainValue,
|
||||
registerDomain,
|
||||
lookupDomain,
|
||||
listDomains,
|
||||
clearDomainRegistry,
|
||||
} from './domains.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formula AST
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Logic AST', () => {
|
||||
it('constructs a ForAllFormula', () => {
|
||||
const formula: ForAllFormula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [
|
||||
{
|
||||
type: 'TupleBinding',
|
||||
variables: ['btn'],
|
||||
domain: {
|
||||
type: 'DomainRef',
|
||||
domain: 'elements',
|
||||
selector: '.button',
|
||||
},
|
||||
},
|
||||
],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'atLeast',
|
||||
args: [
|
||||
{ type: 'VariableRef', name: 'btn' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
assert.strictEqual(formula.kind, 'forall')
|
||||
assert.strictEqual(formula.bindings.length, 1)
|
||||
assert.strictEqual(formula.bindings[0].variables[0], 'btn')
|
||||
assert.ok(isForAllFormula(formula))
|
||||
assert.strictEqual(isExistsFormula(formula), false)
|
||||
})
|
||||
|
||||
it('constructs an ExistsFormula', () => {
|
||||
const formula: ExistsFormula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'exists',
|
||||
bindings: [
|
||||
{
|
||||
type: 'TupleBinding',
|
||||
variables: ['icon'],
|
||||
domain: {
|
||||
type: 'DomainRef',
|
||||
domain: 'descendants',
|
||||
parentVar: 'card',
|
||||
selector: '.icon',
|
||||
},
|
||||
},
|
||||
],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'above',
|
||||
args: [
|
||||
{ type: 'VariableRef', name: 'icon' },
|
||||
{ type: 'VariableRef', name: 'content' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
assert.ok(isExistsFormula(formula))
|
||||
assert.strictEqual(formula.bindings[0].domain.domain, 'descendants')
|
||||
})
|
||||
|
||||
it('constructs boolean connectives', () => {
|
||||
const p: PredicateCall = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'width',
|
||||
args: [{ type: 'VariableRef', name: 'x' }],
|
||||
}
|
||||
|
||||
const q: PredicateCall = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'height',
|
||||
args: [{ type: 'VariableRef', name: 'x' }],
|
||||
}
|
||||
|
||||
const andF: AndFormula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'and',
|
||||
left: p,
|
||||
right: q,
|
||||
}
|
||||
|
||||
const orF: OrFormula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'or',
|
||||
left: p,
|
||||
right: q,
|
||||
}
|
||||
|
||||
const notF: NotFormula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'not',
|
||||
operand: p,
|
||||
}
|
||||
|
||||
const impliesF: ImpliesFormula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'implies',
|
||||
antecedent: p,
|
||||
consequent: q,
|
||||
}
|
||||
|
||||
assert.ok(isAndFormula(andF))
|
||||
assert.ok(isOrFormula(orF))
|
||||
assert.ok(isNotFormula(notF))
|
||||
assert.ok(isImpliesFormula(impliesF))
|
||||
assert.ok(isPredicateCall(p))
|
||||
})
|
||||
|
||||
it('type guards narrow correctly', () => {
|
||||
const varRef: VariableRef = { type: 'VariableRef', name: 'btn' }
|
||||
const domRef: DomainRef = { type: 'DomainRef', domain: 'elements', selector: '.btn' }
|
||||
const accTerm: AccessorTerm = { type: 'AccessorTerm', variable: 'btn', property: 'width' }
|
||||
|
||||
assert.ok(isVariableRef(varRef))
|
||||
assert.ok(isDomainRef(domRef))
|
||||
assert.ok(isAccessorTerm(accTerm))
|
||||
assert.strictEqual(isVariableRef(domRef), false)
|
||||
})
|
||||
|
||||
it('constructs PropertyRunBlock and SampledRunBlock', () => {
|
||||
const propBlock: PropertyRunBlock = {
|
||||
type: 'PropertyRunBlock',
|
||||
mode: 'enumerated',
|
||||
inputDomain: { size: ['sm', 'md', 'lg'] },
|
||||
body: [],
|
||||
}
|
||||
|
||||
const sampledBlock: SampledRunBlock = {
|
||||
type: 'SampledRunBlock',
|
||||
arbitrary: {},
|
||||
numRuns: 100,
|
||||
seed: 42,
|
||||
body: [],
|
||||
}
|
||||
|
||||
assert.strictEqual(propBlock.mode, 'enumerated')
|
||||
assert.strictEqual(sampledBlock.numRuns, 100)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logic IR
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Logic IR', () => {
|
||||
it('creates empty Logic IR with zero-length arrays', () => {
|
||||
const ir = createEmptyLogicIr()
|
||||
assert.strictEqual(ir.formulaCount, 0)
|
||||
assert.strictEqual(ir.formulaKind.length, 0)
|
||||
assert.strictEqual(ir.formulaLeft.length, 0)
|
||||
assert.strictEqual(ir.formulaRight.length, 0)
|
||||
assert.strictEqual(ir.bindingStart.length, 0)
|
||||
assert.strictEqual(ir.bindingCount.length, 0)
|
||||
assert.strictEqual(ir.predicateId.length, 0)
|
||||
assert.strictEqual(ir.argStart.length, 0)
|
||||
assert.strictEqual(ir.argCount.length, 0)
|
||||
assert.strictEqual(ir.originIndex.length, 0)
|
||||
})
|
||||
|
||||
it('creates empty binding and arg tables', () => {
|
||||
const bindings = createEmptyBindingTable()
|
||||
assert.strictEqual(bindings.count, 0)
|
||||
assert.strictEqual(bindings.variableNameId.length, 0)
|
||||
|
||||
const args = createEmptyArgTable()
|
||||
assert.strictEqual(args.count, 0)
|
||||
assert.strictEqual(args.termKind.length, 0)
|
||||
})
|
||||
|
||||
it('interns strings into the string table', () => {
|
||||
const table = createEmptyStringTable()
|
||||
const id1 = internString(table, 'width')
|
||||
const id2 = internString(table, 'height')
|
||||
const id3 = internString(table, 'width')
|
||||
|
||||
assert.strictEqual(id1, 0)
|
||||
assert.strictEqual(id2, 1)
|
||||
assert.strictEqual(id3, 0)
|
||||
assert.strictEqual(table.values.length, 2)
|
||||
assert.strictEqual(table.values[id1], 'width')
|
||||
})
|
||||
|
||||
it('exposes formula kind constants', () => {
|
||||
assert.strictEqual(FORMULA_KIND_FORALL, 1)
|
||||
assert.strictEqual(FORMULA_KIND_PREDICATE, 7)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domains
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Domains', () => {
|
||||
it('creates elements domain descriptor', () => {
|
||||
const d = createElementsDomain('dom_1', '.button')
|
||||
assert.strictEqual(d.domainId, 'dom_1')
|
||||
assert.strictEqual(d.kind, 'elements')
|
||||
assert.strictEqual(d.selector, '.button')
|
||||
})
|
||||
|
||||
it('creates descendants domain descriptor', () => {
|
||||
const d = createDescendantsDomain('dom_2', '$card', '.title')
|
||||
assert.strictEqual(d.kind, 'descendants')
|
||||
assert.strictEqual(d.parentVar, '$card')
|
||||
assert.strictEqual(d.selector, '.title')
|
||||
})
|
||||
|
||||
it('creates line boxes domain descriptor', () => {
|
||||
const d = createLineBoxesDomain('dom_3', 'text_1')
|
||||
assert.strictEqual(d.kind, 'lineBoxes')
|
||||
assert.strictEqual(d.selector, 'text_1')
|
||||
})
|
||||
|
||||
it('creates text runs domain descriptor', () => {
|
||||
const d = createTextRunsDomain('dom_4', 'text_1')
|
||||
assert.strictEqual(d.kind, 'textRuns')
|
||||
})
|
||||
|
||||
it('creates fragments domain descriptor', () => {
|
||||
const d = createFragmentsDomain('dom_5', 'subj_1')
|
||||
assert.strictEqual(d.kind, 'fragments')
|
||||
})
|
||||
|
||||
it('creates frames domain descriptor', () => {
|
||||
const d = createFramesDomain('dom_6', 'viewport')
|
||||
assert.strictEqual(d.kind, 'frames')
|
||||
assert.strictEqual(d.selector, 'viewport')
|
||||
})
|
||||
|
||||
it('creates domain value with subject ids', () => {
|
||||
const ids = new Uint32Array([10, 20, 30])
|
||||
const dv = createDomainValue('dom_1', ids, 'elements(.button)', true)
|
||||
assert.strictEqual(dv.domainId, 'dom_1')
|
||||
assert.strictEqual(dv.subjectIds.length, 3)
|
||||
assert.strictEqual(dv.closed, true)
|
||||
assert.strictEqual(dv.provenance, 'elements(.button)')
|
||||
})
|
||||
|
||||
it('registers and looks up domains', () => {
|
||||
clearDomainRegistry()
|
||||
const d = createElementsDomain('dom_test', '.test')
|
||||
registerDomain(d)
|
||||
|
||||
const lookedUp = lookupDomain('dom_test')
|
||||
assert.ok(lookedUp)
|
||||
assert.strictEqual(lookedUp?.selector, '.test')
|
||||
|
||||
const all = listDomains()
|
||||
assert.strictEqual(all.length, 1)
|
||||
|
||||
clearDomainRegistry()
|
||||
assert.strictEqual(listDomains().length, 0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,379 @@
|
||||
// perf.test.ts - Performance regression tests for Imhotep core hot paths
|
||||
// Uses Node.js built-in test runner and performance hooks.
|
||||
// Targets (V1 developer guide):
|
||||
// Unit tests < 10ms each
|
||||
// Integration tests < 100ms each
|
||||
// Compilation < 5ms per assertion
|
||||
// Extraction < 50ms per page
|
||||
// Solving < 1ms per clause
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
import { compile } from 'imhotep-dsl'
|
||||
import {
|
||||
normalizeSubjects,
|
||||
normalizeBoxes,
|
||||
normalizeTopology,
|
||||
normalizeFrames,
|
||||
buildTransformChain,
|
||||
composeTransform2D,
|
||||
clearTransformCaches,
|
||||
} from 'imhotep-geometry'
|
||||
import { evaluate, registerDefaultClauses } from 'imhotep-solver'
|
||||
|
||||
describe('Performance regression — compilation', () => {
|
||||
it('compiles 100 assertions in under 500ms', () => {
|
||||
const assertions = generateAssertions(100)
|
||||
const start = performance.now()
|
||||
const ir = compile(assertions)
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 500,
|
||||
true,
|
||||
`Compilation took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
|
||||
it('compiles 10 assertions in under 50ms (5ms per assertion)', () => {
|
||||
const assertions = generateAssertions(10)
|
||||
const start = performance.now()
|
||||
compile(assertions)
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 50,
|
||||
true,
|
||||
`Compilation took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance regression — world creation', () => {
|
||||
it('normalizes 1000 subjects in under 50ms', () => {
|
||||
const raw = generateRawSubjects(1000)
|
||||
const start = performance.now()
|
||||
normalizeSubjects(raw)
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 50,
|
||||
true,
|
||||
`Subject normalization took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes 1000 boxes in under 50ms', () => {
|
||||
const raw = generateRawBoxes(1000)
|
||||
const start = performance.now()
|
||||
normalizeBoxes(raw)
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 50,
|
||||
true,
|
||||
`Box normalization took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes topology in under 25ms', () => {
|
||||
const raw = generateRawTopology(1000)
|
||||
const start = performance.now()
|
||||
normalizeTopology(raw, 1000)
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 25,
|
||||
true,
|
||||
`Topology normalization took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance regression — frame resolution', () => {
|
||||
it('resolves 100 transform chains in under 50ms', () => {
|
||||
const matrices = generateTransformMatrices()
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
buildTransformChain(matrices)
|
||||
}
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 50,
|
||||
true,
|
||||
`Frame resolution took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
|
||||
it('composes 100 2D transforms in under 100ms', () => {
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
composeTransform2D(0, 0, i, i, 0, 1, 1)
|
||||
}
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 100,
|
||||
true,
|
||||
`Transform composition took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
|
||||
it('caches repeated transform chains (second pass under 1ms)', () => {
|
||||
const matrices = generateTransformMatrices()
|
||||
clearTransformCaches()
|
||||
buildTransformChain(matrices) // warm cache
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
buildTransformChain(matrices)
|
||||
}
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 1,
|
||||
true,
|
||||
`Cached frame resolution took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance regression — solving', () => {
|
||||
it('evaluates 100 clauses in under 100ms (1ms per clause)', () => {
|
||||
registerDefaultClauses()
|
||||
const world = generateMinimalWorld()
|
||||
const clauses = generateClauses(100)
|
||||
const start = performance.now()
|
||||
evaluate(world, clauses, { skipMissingEvaluators: true })
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 100,
|
||||
true,
|
||||
`Solving took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
|
||||
it('evaluates 10 clauses in under 10ms', () => {
|
||||
registerDefaultClauses()
|
||||
const world = generateMinimalWorld()
|
||||
const clauses = generateClauses(10)
|
||||
const start = performance.now()
|
||||
evaluate(world, clauses, { skipMissingEvaluators: true })
|
||||
const duration = performance.now() - start
|
||||
assert.strictEqual(
|
||||
duration < 10,
|
||||
true,
|
||||
`Solving took ${duration.toFixed(2)}ms`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateAssertions(n: number): Array<{
|
||||
type: 'RelationAssertion'
|
||||
relation: string
|
||||
subject: { type: 'SelectorRef'; value: string }
|
||||
reference: { type: 'SelectorRef'; value: string }
|
||||
options: { type: 'RelationOptions' }
|
||||
}> {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
type: 'RelationAssertion' as const,
|
||||
relation: i % 2 === 0 ? 'leftOf' : 'above',
|
||||
subject: { type: 'SelectorRef' as const, value: `.s-${i}` },
|
||||
reference: { type: 'SelectorRef' as const, value: `.r-${i}` },
|
||||
options: { type: 'RelationOptions' as const },
|
||||
}))
|
||||
}
|
||||
|
||||
function generateRawSubjects(n: number): Array<{
|
||||
id: number
|
||||
domNodeId: number
|
||||
kind: string
|
||||
primaryBoxId: number
|
||||
firstFragmentId: number
|
||||
fragmentCount: number
|
||||
firstTextRunId: number
|
||||
textRunCount: number
|
||||
}> {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: i,
|
||||
domNodeId: i,
|
||||
kind: 'element',
|
||||
primaryBoxId: i,
|
||||
firstFragmentId: 0,
|
||||
fragmentCount: 0,
|
||||
firstTextRunId: 0,
|
||||
textRunCount: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
function generateRawBoxes(n: number): Array<{
|
||||
id: number
|
||||
subjectId: number
|
||||
frameId: number
|
||||
borderLeft: number
|
||||
borderTop: number
|
||||
borderRight: number
|
||||
borderBottom: number
|
||||
paddingLeft: number
|
||||
paddingTop: number
|
||||
paddingRight: number
|
||||
paddingBottom: number
|
||||
contentLeft: number
|
||||
contentTop: number
|
||||
contentRight: number
|
||||
contentBottom: number
|
||||
}> {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: i,
|
||||
subjectId: i,
|
||||
frameId: 0,
|
||||
borderLeft: 0,
|
||||
borderTop: 0,
|
||||
borderRight: 10,
|
||||
borderBottom: 10,
|
||||
paddingLeft: 0,
|
||||
paddingTop: 0,
|
||||
paddingRight: 0,
|
||||
paddingBottom: 0,
|
||||
contentLeft: 0,
|
||||
contentTop: 0,
|
||||
contentRight: 10,
|
||||
contentBottom: 10,
|
||||
}))
|
||||
}
|
||||
|
||||
function generateRawTopology(n: number): {
|
||||
containingBlockOf: number[]
|
||||
nearestPositionedAncestorOf: number[]
|
||||
scrollContainerOf: number[]
|
||||
stackingContextOf: number[]
|
||||
formattingContextOf: number[]
|
||||
clippingRootOf: number[]
|
||||
paintOrderBucket: number[]
|
||||
paintOrderIndex: number[]
|
||||
} {
|
||||
return {
|
||||
containingBlockOf: Array.from({ length: n }, () => 0),
|
||||
nearestPositionedAncestorOf: Array.from({ length: n }, () => 0),
|
||||
scrollContainerOf: Array.from({ length: n }, () => 0),
|
||||
stackingContextOf: Array.from({ length: n }, () => 0),
|
||||
formattingContextOf: Array.from({ length: n }, () => 0),
|
||||
clippingRootOf: Array.from({ length: n }, () => 0),
|
||||
paintOrderBucket: Array.from({ length: n }, () => 0),
|
||||
paintOrderIndex: Array.from({ length: n }, () => 0),
|
||||
}
|
||||
}
|
||||
|
||||
function generateTransformMatrices(): Float64Array[] {
|
||||
return [
|
||||
new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
||||
new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 10, 0, 1]),
|
||||
]
|
||||
}
|
||||
|
||||
function generateMinimalWorld(): any {
|
||||
return {
|
||||
sceneId: 'scene_1',
|
||||
snapshotId: 'snapshot_1',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: new Uint32Array([0, 1]),
|
||||
domNodeId: new Uint32Array([0, 1]),
|
||||
subjectKind: new Uint16Array([1, 1]),
|
||||
primaryBoxId: new Uint32Array([0, 1]),
|
||||
firstFragmentId: new Uint32Array([0, 0]),
|
||||
fragmentCount: new Uint16Array([0, 0]),
|
||||
},
|
||||
boxes: {
|
||||
boxId: new Uint32Array([0, 1]),
|
||||
subjectId: new Uint32Array([0, 1]),
|
||||
frameId: new Uint32Array([0, 0]),
|
||||
borderLeft: new Float64Array([0, 0]),
|
||||
borderTop: new Float64Array([0, 0]),
|
||||
borderRight: new Float64Array([10, 10]),
|
||||
borderBottom: new Float64Array([10, 10]),
|
||||
paddingLeft: new Float64Array([0, 0]),
|
||||
paddingTop: new Float64Array([0, 0]),
|
||||
paddingRight: new Float64Array([0, 0]),
|
||||
paddingBottom: new Float64Array([0, 0]),
|
||||
contentLeft: new Float64Array([0, 0]),
|
||||
contentTop: new Float64Array([0, 0]),
|
||||
contentRight: new Float64Array([10, 10]),
|
||||
contentBottom: new Float64Array([10, 10]),
|
||||
},
|
||||
rects: {
|
||||
rectId: new Uint32Array([0, 1]),
|
||||
left: new Float64Array([0, 5]),
|
||||
top: new Float64Array([0, 5]),
|
||||
right: new Float64Array([10, 15]),
|
||||
bottom: new Float64Array([10, 15]),
|
||||
},
|
||||
topology: {
|
||||
containingBlockOf: new Uint32Array([0, 0]),
|
||||
nearestPositionedAncestorOf: new Uint32Array([0, 0]),
|
||||
scrollContainerOf: new Uint32Array([0, 0]),
|
||||
stackingContextOf: new Uint32Array([0, 0]),
|
||||
formattingContextOf: new Uint32Array([0, 0]),
|
||||
clippingRootOf: new Uint32Array([0, 0]),
|
||||
paintOrderBucket: new Uint16Array([0, 0]),
|
||||
paintOrderIndex: new Uint32Array([0, 1]),
|
||||
},
|
||||
scroll: {
|
||||
containerId: new Uint32Array([0]),
|
||||
scrollLeft: new Float64Array([0]),
|
||||
scrollTop: new Float64Array([0]),
|
||||
scrollWidth: new Float64Array([1280]),
|
||||
scrollHeight: new Float64Array([720]),
|
||||
clientWidth: new Float64Array([1280]),
|
||||
clientHeight: new Float64Array([720]),
|
||||
},
|
||||
clipping: {
|
||||
clipNodeId: new Uint32Array([0]),
|
||||
subjectId: new Uint32Array([0]),
|
||||
clipKind: new Uint16Array([1]),
|
||||
clipLeft: new Float64Array([0]),
|
||||
clipTop: new Float64Array([0]),
|
||||
clipRight: new Float64Array([1280]),
|
||||
clipBottom: new Float64Array([720]),
|
||||
parentClipNodeId: new Uint32Array([0]),
|
||||
},
|
||||
visibility: {
|
||||
subjectId: new Uint32Array([0, 1]),
|
||||
isRendered: new Uint8Array([1, 1]),
|
||||
isVisible: new Uint8Array([1, 1]),
|
||||
visibleArea: new Float64Array([100, 100]),
|
||||
clippedArea: new Float64Array([0, 0]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function generateClauses(n: number): Array<{
|
||||
clauseId: string
|
||||
clauseKind: string
|
||||
version: number
|
||||
subjectRef: number
|
||||
referenceRef?: number
|
||||
}> {
|
||||
const kinds = [
|
||||
'relation.leftOf',
|
||||
'relation.above',
|
||||
'relation.overlaps',
|
||||
'relation.inside',
|
||||
]
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
clauseId: `clause-${i}`,
|
||||
clauseKind: kinds[i % kinds.length],
|
||||
version: 1,
|
||||
subjectRef: 0,
|
||||
referenceRef: 1,
|
||||
}))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Property runner input domain contracts.
|
||||
*
|
||||
* These types define how render inputs are supplied to a property run:
|
||||
* either as an explicit finite enumeration (determinate) or as a
|
||||
* generated sampled domain (sampled). The mode is always explicit.
|
||||
*
|
||||
* Invariant 6: every InputDomain carries its mode at the type level so
|
||||
* downstream code cannot silently switch from enumerated to generated.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InputDomain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type InputDomain =
|
||||
| { mode: 'enumerated'; values: unknown[] }
|
||||
| { mode: 'generated'; arbitrary: unknown; seed?: number; numRuns?: number }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type Guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isEnumeratedDomain(domain: InputDomain): domain is Extract<InputDomain, { mode: 'enumerated' }> {
|
||||
return domain.mode === 'enumerated'
|
||||
}
|
||||
|
||||
export function isGeneratedDomain(domain: InputDomain): domain is Extract<InputDomain, { mode: 'generated' }> {
|
||||
return domain.mode === 'generated'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function enumeratedDomain(values: unknown[]): InputDomain {
|
||||
return { mode: 'enumerated', values }
|
||||
}
|
||||
|
||||
export function generatedDomain(arbitrary: unknown, options?: { seed?: number; numRuns?: number }): InputDomain {
|
||||
return { mode: 'generated', arbitrary, seed: options?.seed, numRuns: options?.numRuns }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Property run result contracts.
|
||||
*
|
||||
* These interfaces materialize Invariant 6: every result must clearly say
|
||||
* whether it is scene-determinate, scene-indeterminate, enumerated-determinate,
|
||||
* or sampled. The mode field is a literal discriminant so switch-exhaustiveness
|
||||
* checking works.
|
||||
*/
|
||||
|
||||
import type { ImhotepId } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SampledRunResult
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SampledRunResult {
|
||||
mode: 'sampled'
|
||||
seed: number
|
||||
numRuns: number
|
||||
passed: boolean
|
||||
counterexample?: unknown
|
||||
shrunkCounterexample?: unknown
|
||||
failingScene?: unknown
|
||||
diagnostics: unknown[]
|
||||
// P1.3 Property-Run Reporting Upgrade
|
||||
minimalFailingCase?: unknown
|
||||
caseIndex?: number
|
||||
durationMs?: number
|
||||
replayPayload?: { props: unknown; seed: number; caseIndex: number }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EnumeratedRunResult
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EnumeratedRunResult {
|
||||
mode: 'enumerated-determinate'
|
||||
totalCases: number
|
||||
passed: boolean
|
||||
failingCase?: unknown
|
||||
diagnostics: unknown[]
|
||||
// P1.3 Property-Run Reporting Upgrade
|
||||
minimalFailingCase?: unknown
|
||||
caseIndex?: number
|
||||
seed?: number
|
||||
durationMs?: number
|
||||
replayPayload?: { props: unknown; seed: number; caseIndex: number }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene Evaluation Results (for completeness in the taxonomy)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DeterministicSceneResult {
|
||||
mode: 'scene-determinate' | 'scene-indeterminate'
|
||||
sceneId: ImhotepId
|
||||
results: unknown[]
|
||||
proofs: unknown[]
|
||||
diagnostics: unknown[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Union type for any property or scene run result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PropertyRunResult =
|
||||
| SampledRunResult
|
||||
| EnumeratedRunResult
|
||||
| DeterministicSceneResult
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type Guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isSampledRunResult(result: PropertyRunResult): result is SampledRunResult {
|
||||
return result.mode === 'sampled'
|
||||
}
|
||||
|
||||
export function isEnumeratedRunResult(result: PropertyRunResult): result is EnumeratedRunResult {
|
||||
return result.mode === 'enumerated-determinate'
|
||||
}
|
||||
|
||||
export function isDeterministicSceneResult(result: PropertyRunResult): result is DeterministicSceneResult {
|
||||
return result.mode === 'scene-determinate' || result.mode === 'scene-indeterminate'
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Unit tests for SceneTarget discriminated union, type guards, and matchers.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import type { SceneTarget, RenderCase } from './scene-target.js'
|
||||
|
||||
import {
|
||||
isSceneTarget,
|
||||
isPageTarget,
|
||||
isPlaywrightPageTarget,
|
||||
isStorybookStoryTarget,
|
||||
isReactComponentTarget,
|
||||
isVueComponentTarget,
|
||||
isCustomRendererTarget,
|
||||
isFixtureTarget,
|
||||
isComponentTarget,
|
||||
isRendererTarget,
|
||||
matchSceneTarget,
|
||||
pageTarget,
|
||||
playwrightPageTarget,
|
||||
storybookStoryTarget,
|
||||
reactComponentTarget,
|
||||
vueComponentTarget,
|
||||
customRendererTarget,
|
||||
fixtureTarget,
|
||||
createRenderCase,
|
||||
} from './scene-target.js'
|
||||
|
||||
describe('SceneTarget type guards', () => {
|
||||
it('accepts valid page target', () => {
|
||||
const target = pageTarget('https://example.com')
|
||||
assert.strictEqual(isSceneTarget(target), true)
|
||||
assert.strictEqual(isPageTarget(target), true)
|
||||
assert.strictEqual(isPlaywrightPageTarget(target), false)
|
||||
assert.strictEqual(isComponentTarget(target), false)
|
||||
assert.strictEqual(isRendererTarget(target), false)
|
||||
})
|
||||
|
||||
it('accepts valid playwright-page target', () => {
|
||||
const target = playwrightPageTarget('page-1', 'https://example.com')
|
||||
assert.strictEqual(isSceneTarget(target), true)
|
||||
assert.strictEqual(isPlaywrightPageTarget(target), true)
|
||||
assert.strictEqual(isPageTarget(target), false)
|
||||
})
|
||||
|
||||
it('accepts valid storybook-story target', () => {
|
||||
const target = storybookStoryTarget('button--primary', 'http://localhost:6006')
|
||||
assert.strictEqual(isSceneTarget(target), true)
|
||||
assert.strictEqual(isStorybookStoryTarget(target), true)
|
||||
assert.strictEqual(isRendererTarget(target), true)
|
||||
assert.strictEqual(isComponentTarget(target), false)
|
||||
})
|
||||
|
||||
it('accepts valid react-component target', () => {
|
||||
const target = reactComponentTarget('react-1', 'Button')
|
||||
assert.strictEqual(isSceneTarget(target), true)
|
||||
assert.strictEqual(isReactComponentTarget(target), true)
|
||||
assert.strictEqual(isComponentTarget(target), true)
|
||||
assert.strictEqual(isRendererTarget(target), true)
|
||||
})
|
||||
|
||||
it('accepts valid vue-component target', () => {
|
||||
const target = vueComponentTarget('vue-1', 'Button')
|
||||
assert.strictEqual(isSceneTarget(target), true)
|
||||
assert.strictEqual(isVueComponentTarget(target), true)
|
||||
assert.strictEqual(isComponentTarget(target), true)
|
||||
})
|
||||
|
||||
it('accepts valid custom-renderer target', () => {
|
||||
const target = customRendererTarget('custom-1', 'my-widget')
|
||||
assert.strictEqual(isSceneTarget(target), true)
|
||||
assert.strictEqual(isCustomRendererTarget(target), true)
|
||||
assert.strictEqual(isComponentTarget(target), true)
|
||||
})
|
||||
|
||||
it('accepts valid fixture target', () => {
|
||||
const target = fixtureTarget('fixtures/button.html')
|
||||
assert.strictEqual(isSceneTarget(target), true)
|
||||
assert.strictEqual(isFixtureTarget(target), true)
|
||||
assert.strictEqual(isRendererTarget(target), false)
|
||||
})
|
||||
|
||||
it('rejects non-objects', () => {
|
||||
assert.strictEqual(isSceneTarget(null), false)
|
||||
assert.strictEqual(isSceneTarget(undefined), false)
|
||||
assert.strictEqual(isSceneTarget('string'), false)
|
||||
assert.strictEqual(isSceneTarget(42), false)
|
||||
})
|
||||
|
||||
it('rejects objects without valid kind', () => {
|
||||
assert.strictEqual(isSceneTarget({}), false)
|
||||
assert.strictEqual(isSceneTarget({ kind: 'unknown' }), false)
|
||||
})
|
||||
|
||||
it('rejects page target with missing url', () => {
|
||||
assert.strictEqual(isSceneTarget({ kind: 'page' }), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SceneTarget matchers', () => {
|
||||
it('matches page target to correct branch', () => {
|
||||
const target = pageTarget('https://example.com')
|
||||
const result = matchSceneTarget<string>(target, {
|
||||
page: (url) => `page:${url}`,
|
||||
'playwright-page': (pageRef) => `playwright-page:${pageRef}`,
|
||||
'storybook-story': (storyId) => `storybook-story:${storyId}`,
|
||||
'react-component': (rendererId, componentId) => `react-component:${rendererId}:${componentId}`,
|
||||
'vue-component': (rendererId, componentId) => `vue-component:${rendererId}:${componentId}`,
|
||||
'custom-renderer': (rendererId, targetId) => `custom-renderer:${rendererId}:${targetId}`,
|
||||
fixture: (fixtureId) => `fixture:${fixtureId}`,
|
||||
})
|
||||
assert.strictEqual(result, 'page:https://example.com')
|
||||
})
|
||||
|
||||
it('matches react-component target to correct branch', () => {
|
||||
const target = reactComponentTarget('react-1', 'Button')
|
||||
const result = matchSceneTarget(target, {
|
||||
page: () => 'page',
|
||||
'playwright-page': () => 'playwright-page',
|
||||
'storybook-story': () => 'storybook-story',
|
||||
'react-component': (rendererId, componentId) => `react:${rendererId}:${componentId}`,
|
||||
'vue-component': () => 'vue',
|
||||
'custom-renderer': () => 'custom',
|
||||
fixture: () => 'fixture',
|
||||
})
|
||||
assert.strictEqual(result, 'react:react-1:Button')
|
||||
})
|
||||
|
||||
it('matches all target kinds exhaustively', () => {
|
||||
const targets: SceneTarget[] = [
|
||||
pageTarget('https://a.com'),
|
||||
playwrightPageTarget('p1'),
|
||||
storybookStoryTarget('s1', 'http://sb'),
|
||||
reactComponentTarget('r1', 'C1'),
|
||||
vueComponentTarget('v1', 'C2'),
|
||||
customRendererTarget('c1', 't1'),
|
||||
fixtureTarget('f1'),
|
||||
]
|
||||
|
||||
for (const target of targets) {
|
||||
const result = matchSceneTarget(target, {
|
||||
page: () => 'page',
|
||||
'playwright-page': () => 'playwright-page',
|
||||
'storybook-story': () => 'storybook-story',
|
||||
'react-component': () => 'react-component',
|
||||
'vue-component': () => 'vue-component',
|
||||
'custom-renderer': () => 'custom-renderer',
|
||||
fixture: () => 'fixture',
|
||||
})
|
||||
assert.strictEqual(result, target.kind)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('SceneTarget factory functions', () => {
|
||||
it('pageTarget creates correct shape', () => {
|
||||
const target = pageTarget('https://example.com')
|
||||
assert.deepStrictEqual(target, { kind: 'page', url: 'https://example.com' })
|
||||
})
|
||||
|
||||
it('playwrightPageTarget creates correct shape with optional url', () => {
|
||||
const withUrl = playwrightPageTarget('page-1', 'https://example.com')
|
||||
assert.deepStrictEqual(withUrl, { kind: 'playwright-page', pageRef: 'page-1', url: 'https://example.com' })
|
||||
|
||||
const withoutUrl = playwrightPageTarget('page-1')
|
||||
assert.deepStrictEqual(withoutUrl, { kind: 'playwright-page', pageRef: 'page-1', url: undefined })
|
||||
})
|
||||
|
||||
it('storybookStoryTarget creates correct shape', () => {
|
||||
const target = storybookStoryTarget('button--primary', 'http://localhost:6006')
|
||||
assert.deepStrictEqual(target, {
|
||||
kind: 'storybook-story',
|
||||
storyId: 'button--primary',
|
||||
storybookUrl: 'http://localhost:6006',
|
||||
})
|
||||
})
|
||||
|
||||
it('reactComponentTarget creates correct shape', () => {
|
||||
const target = reactComponentTarget('react-dom', 'Button')
|
||||
assert.deepStrictEqual(target, { kind: 'react-component', rendererId: 'react-dom', componentId: 'Button' })
|
||||
})
|
||||
|
||||
it('vueComponentTarget creates correct shape', () => {
|
||||
const target = vueComponentTarget('vue-3', 'Button')
|
||||
assert.deepStrictEqual(target, { kind: 'vue-component', rendererId: 'vue-3', componentId: 'Button' })
|
||||
})
|
||||
|
||||
it('customRendererTarget creates correct shape', () => {
|
||||
const target = customRendererTarget('svelte', 'Widget')
|
||||
assert.deepStrictEqual(target, { kind: 'custom-renderer', rendererId: 'svelte', targetId: 'Widget' })
|
||||
})
|
||||
|
||||
it('fixtureTarget creates correct shape', () => {
|
||||
const target = fixtureTarget('fixtures/card.html')
|
||||
assert.deepStrictEqual(target, { kind: 'fixture', fixtureId: 'fixtures/card.html' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('RenderCase', () => {
|
||||
it('createRenderCase builds minimal case', () => {
|
||||
const rc = createRenderCase('case-1', { size: 'md' })
|
||||
assert.strictEqual(rc.caseId, 'case-1')
|
||||
assert.deepStrictEqual(rc.input, { size: 'md' })
|
||||
assert.strictEqual(rc.env, undefined)
|
||||
assert.strictEqual(rc.metadata, undefined)
|
||||
})
|
||||
|
||||
it('createRenderCase includes env and metadata when provided', () => {
|
||||
const rc = createRenderCase('case-2', { disabled: true }, { viewportWidth: 800 }, { run: 3 })
|
||||
assert.strictEqual(rc.caseId, 'case-2')
|
||||
assert.deepStrictEqual(rc.input, { disabled: true })
|
||||
assert.deepStrictEqual(rc.env, { viewportWidth: 800 })
|
||||
assert.deepStrictEqual(rc.metadata, { run: 3 })
|
||||
})
|
||||
|
||||
it('isSceneTarget rejects RenderCase', () => {
|
||||
const rc = createRenderCase('case-1', {})
|
||||
assert.strictEqual(isSceneTarget(rc), false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* SceneTarget discriminated union and type guards.
|
||||
*
|
||||
* The scene target is the stage. It must be possible to mount any component,
|
||||
* any story, any page, and treat it as a deterministic scene. The adapter
|
||||
* boundary is the moat that keeps renderer concerns out of the core.
|
||||
*/
|
||||
|
||||
import type { Environment } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SceneTarget Union
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SceneTarget =
|
||||
| { kind: 'page'; url: string }
|
||||
| { kind: 'playwright-page'; pageRef: string; url?: string }
|
||||
| { kind: 'storybook-story'; storyId: string; storybookUrl: string }
|
||||
| { kind: 'react-component'; rendererId: string; componentId: string }
|
||||
| { kind: 'vue-component'; rendererId: string; componentId: string }
|
||||
| { kind: 'custom-renderer'; rendererId: string; targetId: string }
|
||||
| { kind: 'fixture'; fixtureId: string }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RenderCase Contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RenderCase {
|
||||
caseId: string
|
||||
input: unknown
|
||||
env?: Partial<Environment>
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type Guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isSceneTarget(value: unknown): value is SceneTarget {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const obj = value as Record<string, unknown>
|
||||
if (typeof obj.kind !== 'string') return false
|
||||
switch (obj.kind) {
|
||||
case 'page':
|
||||
return typeof obj.url === 'string'
|
||||
case 'playwright-page':
|
||||
return typeof obj.pageRef === 'string' && (obj.url === undefined || typeof obj.url === 'string')
|
||||
case 'storybook-story':
|
||||
return typeof obj.storyId === 'string' && typeof obj.storybookUrl === 'string'
|
||||
case 'react-component':
|
||||
return typeof obj.rendererId === 'string' && typeof obj.componentId === 'string'
|
||||
case 'vue-component':
|
||||
return typeof obj.rendererId === 'string' && typeof obj.componentId === 'string'
|
||||
case 'custom-renderer':
|
||||
return typeof obj.rendererId === 'string' && typeof obj.targetId === 'string'
|
||||
case 'fixture':
|
||||
return typeof obj.fixtureId === 'string'
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isPageTarget(target: SceneTarget): target is Extract<SceneTarget, { kind: 'page' }> {
|
||||
return target.kind === 'page'
|
||||
}
|
||||
|
||||
export function isPlaywrightPageTarget(
|
||||
target: SceneTarget
|
||||
): target is Extract<SceneTarget, { kind: 'playwright-page' }> {
|
||||
return target.kind === 'playwright-page'
|
||||
}
|
||||
|
||||
export function isStorybookStoryTarget(
|
||||
target: SceneTarget
|
||||
): target is Extract<SceneTarget, { kind: 'storybook-story' }> {
|
||||
return target.kind === 'storybook-story'
|
||||
}
|
||||
|
||||
export function isReactComponentTarget(
|
||||
target: SceneTarget
|
||||
): target is Extract<SceneTarget, { kind: 'react-component' }> {
|
||||
return target.kind === 'react-component'
|
||||
}
|
||||
|
||||
export function isVueComponentTarget(
|
||||
target: SceneTarget
|
||||
): target is Extract<SceneTarget, { kind: 'vue-component' }> {
|
||||
return target.kind === 'vue-component'
|
||||
}
|
||||
|
||||
export function isCustomRendererTarget(
|
||||
target: SceneTarget
|
||||
): target is Extract<SceneTarget, { kind: 'custom-renderer' }> {
|
||||
return target.kind === 'custom-renderer'
|
||||
}
|
||||
|
||||
export function isFixtureTarget(target: SceneTarget): target is Extract<SceneTarget, { kind: 'fixture' }> {
|
||||
return target.kind === 'fixture'
|
||||
}
|
||||
|
||||
export function isComponentTarget(
|
||||
target: SceneTarget
|
||||
): target is
|
||||
| Extract<SceneTarget, { kind: 'react-component' }>
|
||||
| Extract<SceneTarget, { kind: 'vue-component' }>
|
||||
| Extract<SceneTarget, { kind: 'custom-renderer' }> {
|
||||
return target.kind === 'react-component' || target.kind === 'vue-component' || target.kind === 'custom-renderer'
|
||||
}
|
||||
|
||||
export function isRendererTarget(
|
||||
target: SceneTarget
|
||||
): target is
|
||||
| Extract<SceneTarget, { kind: 'react-component' }>
|
||||
| Extract<SceneTarget, { kind: 'vue-component' }>
|
||||
| Extract<SceneTarget, { kind: 'storybook-story' }>
|
||||
| Extract<SceneTarget, { kind: 'custom-renderer' }> {
|
||||
return (
|
||||
target.kind === 'react-component' ||
|
||||
target.kind === 'vue-component' ||
|
||||
target.kind === 'storybook-story' ||
|
||||
target.kind === 'custom-renderer'
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Matchers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function matchSceneTarget<R>(
|
||||
target: SceneTarget,
|
||||
cases: {
|
||||
page: (url: string) => R
|
||||
'playwright-page': (pageRef: string, url?: string) => R
|
||||
'storybook-story': (storyId: string, storybookUrl: string) => R
|
||||
'react-component': (rendererId: string, componentId: string) => R
|
||||
'vue-component': (rendererId: string, componentId: string) => R
|
||||
'custom-renderer': (rendererId: string, targetId: string) => R
|
||||
fixture: (fixtureId: string) => R
|
||||
}
|
||||
): R {
|
||||
switch (target.kind) {
|
||||
case 'page':
|
||||
return cases.page(target.url)
|
||||
case 'playwright-page':
|
||||
return cases['playwright-page'](target.pageRef, target.url)
|
||||
case 'storybook-story':
|
||||
return cases['storybook-story'](target.storyId, target.storybookUrl)
|
||||
case 'react-component':
|
||||
return cases['react-component'](target.rendererId, target.componentId)
|
||||
case 'vue-component':
|
||||
return cases['vue-component'](target.rendererId, target.componentId)
|
||||
case 'custom-renderer':
|
||||
return cases['custom-renderer'](target.rendererId, target.targetId)
|
||||
case 'fixture':
|
||||
return cases.fixture(target.fixtureId)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory Functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function pageTarget(url: string): SceneTarget {
|
||||
return { kind: 'page', url }
|
||||
}
|
||||
|
||||
export function playwrightPageTarget(pageRef: string, url?: string): SceneTarget {
|
||||
return { kind: 'playwright-page', pageRef, url }
|
||||
}
|
||||
|
||||
export function storybookStoryTarget(storyId: string, storybookUrl: string): SceneTarget {
|
||||
return { kind: 'storybook-story', storyId, storybookUrl }
|
||||
}
|
||||
|
||||
export function reactComponentTarget(rendererId: string, componentId: string): SceneTarget {
|
||||
return { kind: 'react-component', rendererId, componentId }
|
||||
}
|
||||
|
||||
export function vueComponentTarget(rendererId: string, componentId: string): SceneTarget {
|
||||
return { kind: 'vue-component', rendererId, componentId }
|
||||
}
|
||||
|
||||
export function customRendererTarget(rendererId: string, targetId: string): SceneTarget {
|
||||
return { kind: 'custom-renderer', rendererId, targetId }
|
||||
}
|
||||
|
||||
export function fixtureTarget(fixtureId: string): SceneTarget {
|
||||
return { kind: 'fixture', fixtureId }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RenderCase Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createRenderCase(
|
||||
caseId: string,
|
||||
input: unknown,
|
||||
env?: Partial<Environment>,
|
||||
metadata?: Record<string, unknown>
|
||||
): RenderCase {
|
||||
return { caseId, input, env, metadata }
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import type {
|
||||
Point,
|
||||
Position,
|
||||
LengthLiteral,
|
||||
ClauseStatus,
|
||||
TruthValue,
|
||||
ClauseResult,
|
||||
Proof,
|
||||
} from './types.js'
|
||||
|
||||
import type {
|
||||
DiagnosticCategory,
|
||||
DiagnosticCode,
|
||||
} from './diagnostics.js'
|
||||
|
||||
import {
|
||||
isAstNode,
|
||||
isParentNode,
|
||||
isProgram,
|
||||
isRelationAssertion,
|
||||
isCompoundAssertion,
|
||||
type Program,
|
||||
type RelationAssertion,
|
||||
type CompoundAssertion,
|
||||
} from './ast.js'
|
||||
|
||||
import { createEmptySemanticIr } from './ir.js'
|
||||
import { createEmptyWorld } from './world.js'
|
||||
import { createDiagnostic, isContractFailure, isSystemUseError } from './diagnostics.js'
|
||||
|
||||
describe('Core Types', () => {
|
||||
it('constructs a valid Point', () => {
|
||||
const p: Point = { line: 1, column: 1, offset: 0 }
|
||||
assert.strictEqual(p.line, 1)
|
||||
assert.strictEqual(p.column, 1)
|
||||
assert.strictEqual(p.offset, 0)
|
||||
})
|
||||
|
||||
it('constructs a valid Position', () => {
|
||||
const pos: Position = {
|
||||
start: { line: 2, column: 5, offset: 17 },
|
||||
end: { line: 2, column: 51, offset: 63 },
|
||||
}
|
||||
assert.strictEqual(pos.start.line, 2)
|
||||
assert.strictEqual(pos.end.offset, 63)
|
||||
})
|
||||
|
||||
it('constructs a valid LengthLiteral', () => {
|
||||
const len: LengthLiteral = { value: 16, unit: 'px' }
|
||||
assert.strictEqual(len.value, 16)
|
||||
assert.strictEqual(len.unit, 'px')
|
||||
})
|
||||
|
||||
it('narrows ClauseStatus discriminated union', () => {
|
||||
const statuses: ClauseStatus[] = ['pass', 'fail', 'error', 'skipped']
|
||||
for (const s of statuses) {
|
||||
assert.ok(['pass', 'fail', 'error', 'skipped'].includes(s))
|
||||
}
|
||||
})
|
||||
|
||||
it('narrows TruthValue discriminated union', () => {
|
||||
const truths: TruthValue[] = ['determinate', 'indeterminate', 'approximate']
|
||||
for (const t of truths) {
|
||||
assert.ok(['determinate', 'indeterminate', 'approximate'].includes(t))
|
||||
}
|
||||
})
|
||||
|
||||
it('constructs a valid ClauseResult', () => {
|
||||
const result: ClauseResult = {
|
||||
clauseId: 'clause_42',
|
||||
status: 'fail',
|
||||
truth: 'determinate',
|
||||
metrics: { observedGap: 11, minGap: 16, maxGap: 20 },
|
||||
witness: { subjectId: 2, referenceId: 9, frameId: 1 },
|
||||
diagnostics: [],
|
||||
}
|
||||
assert.strictEqual(result.status, 'fail')
|
||||
assert.strictEqual(result.truth, 'determinate')
|
||||
assert.strictEqual(result.metrics.observedGap, 11)
|
||||
})
|
||||
|
||||
it('constructs a valid Proof', () => {
|
||||
const proof: Proof = {
|
||||
proofId: 'proof_99',
|
||||
clauseId: 'clause_42',
|
||||
outcome: 'fail',
|
||||
truth: 'determinate',
|
||||
usedFacts: [102, 103, 220],
|
||||
derivedFacts: [501, 502],
|
||||
failedPredicate: { op: '>=', left: 11, right: 24 },
|
||||
witness: {
|
||||
subjectId: 5,
|
||||
referenceId: 8,
|
||||
envCaseId: 'env_3',
|
||||
snapshotId: 'hover',
|
||||
},
|
||||
}
|
||||
assert.strictEqual(proof.usedFacts.length, 3)
|
||||
assert.strictEqual(proof.failedPredicate?.op, '>=')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AST Type Guards', () => {
|
||||
it('identifies valid AstNode', () => {
|
||||
assert.strictEqual(isAstNode({ type: 'Program' }), true)
|
||||
assert.strictEqual(isAstNode(null), false)
|
||||
assert.strictEqual(isAstNode({}), false)
|
||||
})
|
||||
|
||||
it('identifies ParentNode via children property', () => {
|
||||
const program: Program = {
|
||||
type: 'Program',
|
||||
children: [],
|
||||
}
|
||||
assert.strictEqual(isParentNode(program), true)
|
||||
|
||||
const relation: RelationAssertion = {
|
||||
type: 'RelationAssertion',
|
||||
relation: 'leftOf',
|
||||
subject: { type: 'SelectorRef', value: '.a' },
|
||||
reference: { type: 'SelectorRef', value: '.b' },
|
||||
options: { type: 'RelationOptions' },
|
||||
}
|
||||
assert.strictEqual(isParentNode(relation), false)
|
||||
})
|
||||
|
||||
it('narrows Program type', () => {
|
||||
const node = { type: 'Program', children: [] } as const
|
||||
assert.strictEqual(isProgram(node), true)
|
||||
assert.strictEqual(isRelationAssertion(node), false)
|
||||
})
|
||||
|
||||
it('narrows RelationAssertion type', () => {
|
||||
const node: RelationAssertion = {
|
||||
type: 'RelationAssertion',
|
||||
relation: 'leftOf',
|
||||
subject: { type: 'SelectorRef', value: '.a' },
|
||||
reference: { type: 'SelectorRef', value: '.b' },
|
||||
options: { type: 'RelationOptions' },
|
||||
}
|
||||
assert.strictEqual(isRelationAssertion(node), true)
|
||||
assert.strictEqual(isProgram(node), false)
|
||||
})
|
||||
|
||||
it('narrows CompoundAssertion type', () => {
|
||||
const node: CompoundAssertion = {
|
||||
type: 'CompoundAssertion',
|
||||
quantifier: { type: 'Quantifier', kind: 'all' },
|
||||
children: [],
|
||||
}
|
||||
assert.strictEqual(isCompoundAssertion(node), true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Semantic IR', () => {
|
||||
it('creates empty Semantic IR with all tables', () => {
|
||||
const ir = createEmptySemanticIr()
|
||||
assert.ok(ir.subjects instanceof Map)
|
||||
assert.ok(ir.frames instanceof Map)
|
||||
assert.ok(ir.clauses instanceof Map)
|
||||
assert.ok(ir.groups instanceof Map)
|
||||
assert.ok(ir.tolerances instanceof Map)
|
||||
assert.ok(ir.envGuards instanceof Map)
|
||||
assert.ok(ir.states instanceof Map)
|
||||
assert.ok(ir.timelines instanceof Map)
|
||||
assert.ok(ir.diagnosticMetadata instanceof Map)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Geometry World', () => {
|
||||
it('creates empty world with correct scene/snapshot ids', () => {
|
||||
const world = createEmptyWorld('scene_1', 'snapshot_default')
|
||||
assert.strictEqual(world.sceneId, 'scene_1')
|
||||
assert.strictEqual(world.snapshotId, 'snapshot_default')
|
||||
assert.strictEqual(world.env.viewportWidth, 0)
|
||||
assert.ok(world.subjects.ids instanceof Uint32Array)
|
||||
assert.ok(world.boxes.boxId instanceof Uint32Array)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Diagnostics', () => {
|
||||
it('creates a diagnostic with correct category', () => {
|
||||
const d = createDiagnostic(
|
||||
'IMH_RELATION_LEFT_OF_FAILED',
|
||||
'contract-failure',
|
||||
'error',
|
||||
'Expected .gallery to be left of .details.',
|
||||
)
|
||||
assert.strictEqual(d.code, 'IMH_RELATION_LEFT_OF_FAILED')
|
||||
assert.strictEqual(d.category, 'contract-failure')
|
||||
assert.strictEqual(d.severity, 'error')
|
||||
assert.strictEqual(isContractFailure(d), true)
|
||||
assert.strictEqual(isSystemUseError(d), false)
|
||||
})
|
||||
|
||||
it('classifies system use errors correctly', () => {
|
||||
const parseErr = createDiagnostic(
|
||||
'IMH_PARSE_INVALID_SYNTAX',
|
||||
'parse-error',
|
||||
'error',
|
||||
'Unexpected token',
|
||||
)
|
||||
assert.strictEqual(isSystemUseError(parseErr), true)
|
||||
assert.strictEqual(isContractFailure(parseErr), false)
|
||||
|
||||
const resolutionErr = createDiagnostic(
|
||||
'IMH_SELECTOR_ZERO_MATCHES',
|
||||
'resolution-error',
|
||||
'error',
|
||||
'Selector matched zero elements.',
|
||||
)
|
||||
assert.strictEqual(isSystemUseError(resolutionErr), true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Core type definitions for Imhotep.
|
||||
*
|
||||
* Imhotep uses three internal representations:
|
||||
* 1. Authoring AST — rich tree, unist-style, source-positioned
|
||||
* 2. Semantic IR — normalized graph with explicit obligations
|
||||
* 3. Execution IR — flattened, data-oriented arrays for speed
|
||||
*
|
||||
* This file holds the foundational types shared across all stages.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Position and Source Origin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Point {
|
||||
/** 1-based line number in the source text */
|
||||
line: number
|
||||
/** 1-based column number in the source text */
|
||||
column: number
|
||||
/** 0-based character offset in the source text */
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
start: Point
|
||||
end: Point
|
||||
}
|
||||
|
||||
export interface SourceOrigin {
|
||||
/** AST node ids that contributed to this IR node */
|
||||
astNodeIds: string[]
|
||||
/** Source spans traceable back to authoring syntax */
|
||||
positions: Position[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identifier and Reference Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ImhotepId = string
|
||||
|
||||
export interface IdRef {
|
||||
id: ImhotepId
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Literal Value Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type LengthUnit = 'px' | 'em' | 'rem' | 'ch' | 'jnd'
|
||||
|
||||
export interface LengthLiteral {
|
||||
value: number
|
||||
unit: LengthUnit
|
||||
}
|
||||
|
||||
export interface RangeLiteral {
|
||||
min: LengthLiteral
|
||||
max: LengthLiteral
|
||||
}
|
||||
|
||||
export interface ToleranceLiteral {
|
||||
value: number
|
||||
unit: 'jnd' | 'px'
|
||||
}
|
||||
|
||||
export type LiteralValue =
|
||||
| { kind: 'length'; value: number; unit: LengthUnit }
|
||||
| { kind: 'range'; min: LengthLiteral; max: LengthLiteral }
|
||||
| { kind: 'tolerance'; value: number; unit: 'jnd' | 'px' }
|
||||
| { kind: 'string'; value: string }
|
||||
| { kind: 'number'; value: number }
|
||||
| { kind: 'boolean'; value: boolean }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment and Snapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ColorScheme = 'light' | 'dark'
|
||||
export type PointerType = 'coarse' | 'fine' | 'none'
|
||||
export type ReducedMotion = 'reduce' | 'no-preference'
|
||||
export type WritingMode = 'horizontal-tb' | 'vertical-rl' | 'vertical-lr'
|
||||
|
||||
export interface Environment {
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
deviceScaleFactor: number
|
||||
colorScheme: ColorScheme
|
||||
pointer: PointerType
|
||||
hover: boolean
|
||||
reducedMotion: ReducedMotion
|
||||
locale: string
|
||||
writingMode: WritingMode
|
||||
}
|
||||
|
||||
export interface EnvCase {
|
||||
id: ImhotepId
|
||||
env: Environment
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State and Timeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type StateKind =
|
||||
| 'default'
|
||||
| 'hover'
|
||||
| 'focus'
|
||||
| 'focusVisible'
|
||||
| 'active'
|
||||
| 'disabled'
|
||||
| 'checked'
|
||||
| 'expanded'
|
||||
| 'collapsed'
|
||||
| 'selected'
|
||||
| 'pressed'
|
||||
| 'visited'
|
||||
| 'custom'
|
||||
|
||||
export interface StateSnapshot {
|
||||
id: ImhotepId
|
||||
kind: StateKind
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type TimelineMode = 'static' | 'keyframes' | 'range' | 'transition'
|
||||
|
||||
export interface TimelineSnapshot {
|
||||
id: ImhotepId
|
||||
mode: TimelineMode
|
||||
samples?: number[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FrameKind =
|
||||
| 'viewport'
|
||||
| 'document'
|
||||
| 'element'
|
||||
| 'nearestPositionedAncestor'
|
||||
| 'containingBlock'
|
||||
| 'scrollContainer'
|
||||
| 'namedGridArea'
|
||||
| 'landmark'
|
||||
| 'stackingContextRoot'
|
||||
| 'custom'
|
||||
|
||||
export interface Frame {
|
||||
id: ImhotepId
|
||||
kind: FrameKind
|
||||
selector?: string
|
||||
name?: string
|
||||
originX: number
|
||||
originY: number
|
||||
writingMode: WritingMode
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subject Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SubjectKind =
|
||||
| 'element'
|
||||
| 'pseudoElement'
|
||||
| 'textRange'
|
||||
| 'lineBox'
|
||||
| 'fragmentBox'
|
||||
| 'landmark'
|
||||
| 'gridArea'
|
||||
| 'scrollContainer'
|
||||
| 'syntheticFrame'
|
||||
|
||||
export interface Subject {
|
||||
id: ImhotepId
|
||||
selector: string
|
||||
kind: SubjectKind
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assertion and Quantifier Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type RelationName =
|
||||
| 'leftOf'
|
||||
| 'rightOf'
|
||||
| 'above'
|
||||
| 'below'
|
||||
| 'alignedWith'
|
||||
| 'leftAlignedWith'
|
||||
| 'rightAlignedWith'
|
||||
| 'topAlignedWith'
|
||||
| 'bottomAlignedWith'
|
||||
| 'centeredWithin'
|
||||
| 'inside'
|
||||
| 'contains'
|
||||
| 'overlaps'
|
||||
| 'intersects'
|
||||
| 'touches'
|
||||
| 'separatedFrom'
|
||||
| 'hasGap'
|
||||
|
||||
export type SizeProperty =
|
||||
| 'width'
|
||||
| 'height'
|
||||
| 'minWidth'
|
||||
| 'minHeight'
|
||||
| 'maxWidth'
|
||||
| 'maxHeight'
|
||||
|
||||
export type QuantifierKind =
|
||||
| 'all'
|
||||
| 'any'
|
||||
| 'none'
|
||||
| 'exactly'
|
||||
| 'atLeast'
|
||||
| 'atMost'
|
||||
| 'ordered'
|
||||
| 'pairwise'
|
||||
|
||||
export interface Quantifier {
|
||||
kind: QuantifierKind
|
||||
count?: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clause Result and Truth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ClauseStatus = 'pass' | 'fail' | 'error' | 'skipped'
|
||||
export type TruthValue = 'determinate' | 'indeterminate' | 'approximate'
|
||||
|
||||
export interface ClauseMetrics {
|
||||
[key: string]: number | LengthLiteral | undefined
|
||||
}
|
||||
|
||||
export interface ClauseWitness {
|
||||
subjectId: number
|
||||
referenceId?: number
|
||||
frameId: number
|
||||
envCaseId?: ImhotepId
|
||||
snapshotId?: ImhotepId
|
||||
}
|
||||
|
||||
export interface SourceReference {
|
||||
/** 0-based index of the assertion in a fluent batch */
|
||||
fluentIndex?: number
|
||||
/** 1-based line number in the dense spec string */
|
||||
specLine?: number
|
||||
/** 1-based column number in the dense spec string */
|
||||
specColumn?: number
|
||||
}
|
||||
|
||||
export interface ClauseResult {
|
||||
clauseId: ImhotepId
|
||||
status: ClauseStatus
|
||||
truth: TruthValue
|
||||
metrics: ClauseMetrics
|
||||
witness: ClauseWitness
|
||||
diagnostics: ImhotepId[]
|
||||
/** Traceability metadata linking this result to its authored source */
|
||||
sourceRef?: SourceReference
|
||||
/** Human-readable label derived from the normalized contract */
|
||||
clauseLabel?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group Result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GroupOperator = 'and' | 'or' | 'not' | 'quantifier'
|
||||
|
||||
export interface GroupResult {
|
||||
groupId: ImhotepId
|
||||
operator: GroupOperator
|
||||
clauseResults: ImhotepId[]
|
||||
groupResults?: ImhotepId[]
|
||||
status: ClauseStatus
|
||||
truth: TruthValue
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proof and Determination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FailedPredicate {
|
||||
op: string
|
||||
left: number
|
||||
right: number
|
||||
}
|
||||
|
||||
export interface Proof {
|
||||
proofId: ImhotepId
|
||||
clauseId: ImhotepId
|
||||
outcome: ClauseStatus
|
||||
truth: TruthValue
|
||||
usedFacts: number[]
|
||||
derivedFacts: number[]
|
||||
failedPredicate?: FailedPredicate
|
||||
witness: {
|
||||
subjectId: number
|
||||
referenceId?: number
|
||||
envCaseId: ImhotepId
|
||||
snapshotId: ImhotepId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* Geometry world schema types for Imhotep.
|
||||
*
|
||||
* The geometry world is the extracted and normalized scene model.
|
||||
* It is immutable per snapshot, indexed by stable numeric ids for speed,
|
||||
* and explicit about confidence and provenance.
|
||||
*/
|
||||
|
||||
import type { ImhotepId, Environment } from './types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coordinate Space
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Coordinate space distinguishes between layout-local and visual-viewport
|
||||
* coordinates. Layout coordinates are pre-transform (the local box model
|
||||
* as computed by the browser). Visual coordinates are post-transform
|
||||
* (the effective position after all CSS transforms are applied).
|
||||
*
|
||||
* Playwright boundingBox() returns visual coordinates.
|
||||
* CDP DOM.getBoxModel returns layout coordinates.
|
||||
*/
|
||||
export enum CoordinateSpace {
|
||||
LAYOUT = 0, // Local layout coordinates (pre-transform)
|
||||
VISUAL = 1, // Visual viewport coordinates (post-transform)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-Level World
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GeometryWorld {
|
||||
sceneId: ImhotepId
|
||||
snapshotId: ImhotepId
|
||||
env: WorldEnvironment
|
||||
source: WorldSource
|
||||
strings: StringTable
|
||||
subjects: SubjectsTable
|
||||
dom: DomTable
|
||||
frames: FramesTable
|
||||
matrices: MatricesTable
|
||||
rects: RectsTable
|
||||
boxes: BoxesTable // Layout-space box geometry (CDP box model, pre-transform)
|
||||
visualBoxes: BoxesTable // Visual-space box geometry (post-transform, viewport-relative)
|
||||
fragments: FragmentsTable
|
||||
transforms: TransformsTable
|
||||
styles: StylesTable
|
||||
text: TextTable
|
||||
topology: TopologyTable
|
||||
scroll: ScrollTable
|
||||
clipping: ClippingTable
|
||||
paint: PaintTable
|
||||
visibility: VisibilityTable
|
||||
provenance: ProvenanceTable
|
||||
confidence: ConfidenceTable
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment and Source
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorldEnvironment {
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
deviceScaleFactor: number
|
||||
colorScheme: string
|
||||
pointer: string
|
||||
hover: boolean
|
||||
reducedMotion: string
|
||||
locale: string
|
||||
writingMode: string
|
||||
}
|
||||
|
||||
export interface WorldSource {
|
||||
url: string
|
||||
browserName: string
|
||||
browserVersion: string
|
||||
engine: 'chromium-cdp'
|
||||
extractedAt: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String Table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StringTable {
|
||||
values: string[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subjects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SubjectsTable {
|
||||
ids: Uint32Array
|
||||
domNodeId: Uint32Array
|
||||
subjectKind: Uint16Array
|
||||
primaryBoxId: Uint32Array
|
||||
firstFragmentId: Uint32Array
|
||||
fragmentCount: Uint16Array
|
||||
firstTextRunId: Uint32Array
|
||||
textRunCount: Uint16Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DomTable {
|
||||
nodeId: Uint32Array
|
||||
backendNodeId: Uint32Array
|
||||
parentNodeId: Uint32Array
|
||||
firstChildIndex: Uint32Array
|
||||
childCount: Uint16Array
|
||||
shadowRootKind: Uint8Array
|
||||
tagNameStringId: Uint32Array
|
||||
roleStringId: Uint32Array
|
||||
ariaNameStringId: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frames
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export enum FrameKind {
|
||||
Viewport = 1,
|
||||
ContainingBlock = 2,
|
||||
NearestPositionedAncestor = 3,
|
||||
ScrollContainer = 4,
|
||||
Synthetic = 5,
|
||||
}
|
||||
|
||||
export interface FramesTable {
|
||||
frameId: Uint32Array
|
||||
frameKind: Uint16Array
|
||||
ownerSubjectId: Uint32Array
|
||||
parentFrameId: Uint32Array
|
||||
originX: Float64Array
|
||||
originY: Float64Array
|
||||
axisMatrixStart: Uint32Array
|
||||
clipRectId: Uint32Array
|
||||
scrollContainerId: Uint32Array
|
||||
writingMode: Uint16Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Matrices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MatricesTable {
|
||||
values: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RectsTable {
|
||||
rectId: Uint32Array
|
||||
left: Float64Array
|
||||
top: Float64Array
|
||||
right: Float64Array
|
||||
bottom: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boxes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BoxesTable {
|
||||
boxId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
frameId: Uint32Array
|
||||
borderLeft: Float64Array
|
||||
borderTop: Float64Array
|
||||
borderRight: Float64Array
|
||||
borderBottom: Float64Array
|
||||
paddingLeft: Float64Array
|
||||
paddingTop: Float64Array
|
||||
paddingRight: Float64Array
|
||||
paddingBottom: Float64Array
|
||||
contentLeft: Float64Array
|
||||
contentTop: Float64Array
|
||||
contentRight: Float64Array
|
||||
contentBottom: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fragments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export enum FragmentKind {
|
||||
Line = 1,
|
||||
Column = 2,
|
||||
Region = 3,
|
||||
Page = 4,
|
||||
}
|
||||
|
||||
export interface FragmentsTable {
|
||||
fragmentId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
fragmentKind: Uint16Array
|
||||
boxLeft: Float64Array
|
||||
boxTop: Float64Array
|
||||
boxRight: Float64Array
|
||||
boxBottom: Float64Array
|
||||
lineIndex: Int32Array
|
||||
flowIndex: Int32Array
|
||||
parentFragmentId: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transforms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TransformsTable {
|
||||
transformId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
matrixStart: Uint32Array
|
||||
matrixLength: Uint16Array
|
||||
originX: Float64Array
|
||||
originY: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StylesTable {
|
||||
subjectId: Uint32Array
|
||||
display: Uint16Array
|
||||
position: Uint16Array
|
||||
zIndexKind: Uint8Array
|
||||
zIndexValue: Int32Array
|
||||
overflowX: Uint16Array
|
||||
overflowY: Uint16Array
|
||||
opacity: Float32Array
|
||||
visibility: Uint16Array
|
||||
containFlags: Uint32Array
|
||||
pointerEvents: Uint16Array
|
||||
lineHeight: Float64Array
|
||||
fontFamilyStringId: Uint32Array
|
||||
fontSize: Float64Array
|
||||
fontWeight: Uint16Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TextTable {
|
||||
runId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
contentStringId: Uint32Array
|
||||
lineBoxId: Uint32Array
|
||||
inkLeft: Float64Array
|
||||
inkTop: Float64Array
|
||||
inkRight: Float64Array
|
||||
inkBottom: Float64Array
|
||||
baselineY: Float64Array
|
||||
capHeight: Float64Array
|
||||
computedLineHeight: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topology
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TopologyTable {
|
||||
containingBlockOf: Uint32Array
|
||||
nearestPositionedAncestorOf: Uint32Array
|
||||
scrollContainerOf: Uint32Array
|
||||
stackingContextOf: Uint32Array
|
||||
formattingContextOf: Uint32Array
|
||||
clippingRootOf: Uint32Array
|
||||
paintOrderBucket: Uint16Array
|
||||
paintOrderIndex: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scroll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ScrollTable {
|
||||
containerId: Uint32Array
|
||||
scrollLeft: Float64Array
|
||||
scrollTop: Float64Array
|
||||
scrollWidth: Float64Array
|
||||
scrollHeight: Float64Array
|
||||
clientWidth: Float64Array
|
||||
clientHeight: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clipping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ClippingTable {
|
||||
clipNodeId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
clipKind: Uint16Array
|
||||
clipLeft: Float64Array
|
||||
clipTop: Float64Array
|
||||
clipRight: Float64Array
|
||||
clipBottom: Float64Array
|
||||
parentClipNodeId: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PaintTable {
|
||||
paintNodeId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
stackingContextId: Uint32Array
|
||||
bucket: Uint16Array
|
||||
localPaintIndex: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface VisibilityTable {
|
||||
subjectId: Uint32Array
|
||||
isRendered: Uint8Array
|
||||
isVisible: Uint8Array
|
||||
visibleArea: Float64Array
|
||||
clippedArea: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provenance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProvenanceTable {
|
||||
factId: Uint32Array
|
||||
extractionStepId: Uint32Array
|
||||
sourceKind: Uint16Array
|
||||
sourceRef: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Confidence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ConfidenceTable {
|
||||
factId: Uint32Array
|
||||
confidence: Float32Array
|
||||
reasonCode: Uint16Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fact Request Plan (fed to extractors)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FactRequestPlan {
|
||||
geometry: boolean
|
||||
fragments: boolean
|
||||
styles: string[] | boolean
|
||||
topology: string[] | boolean
|
||||
text: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Create empty world shell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createEmptyWorld(sceneId: ImhotepId, snapshotId: ImhotepId): GeometryWorld {
|
||||
return {
|
||||
sceneId,
|
||||
snapshotId,
|
||||
env: {
|
||||
viewportWidth: 0,
|
||||
viewportHeight: 0,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: 'no-preference',
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
source: {
|
||||
url: '',
|
||||
browserName: '',
|
||||
browserVersion: '',
|
||||
engine: 'chromium-cdp',
|
||||
extractedAt: 0,
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: new Uint32Array(0),
|
||||
domNodeId: new Uint32Array(0),
|
||||
subjectKind: new Uint16Array(0),
|
||||
primaryBoxId: new Uint32Array(0),
|
||||
firstFragmentId: new Uint32Array(0),
|
||||
fragmentCount: new Uint16Array(0),
|
||||
firstTextRunId: new Uint32Array(0),
|
||||
textRunCount: new Uint16Array(0),
|
||||
},
|
||||
dom: {
|
||||
nodeId: new Uint32Array(0),
|
||||
backendNodeId: new Uint32Array(0),
|
||||
parentNodeId: new Uint32Array(0),
|
||||
firstChildIndex: new Uint32Array(0),
|
||||
childCount: new Uint16Array(0),
|
||||
shadowRootKind: new Uint8Array(0),
|
||||
tagNameStringId: new Uint32Array(0),
|
||||
roleStringId: new Uint32Array(0),
|
||||
ariaNameStringId: new Uint32Array(0),
|
||||
},
|
||||
frames: {
|
||||
frameId: new Uint32Array(0),
|
||||
frameKind: new Uint16Array(0),
|
||||
ownerSubjectId: new Uint32Array(0),
|
||||
parentFrameId: new Uint32Array(0),
|
||||
originX: new Float64Array(0),
|
||||
originY: new Float64Array(0),
|
||||
axisMatrixStart: new Uint32Array(0),
|
||||
clipRectId: new Uint32Array(0),
|
||||
scrollContainerId: new Uint32Array(0),
|
||||
writingMode: new Uint16Array(0),
|
||||
},
|
||||
matrices: { values: new Float64Array(0) },
|
||||
rects: {
|
||||
rectId: new Uint32Array(0),
|
||||
left: new Float64Array(0),
|
||||
top: new Float64Array(0),
|
||||
right: new Float64Array(0),
|
||||
bottom: new Float64Array(0),
|
||||
},
|
||||
boxes: {
|
||||
boxId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
frameId: new Uint32Array(0),
|
||||
borderLeft: new Float64Array(0),
|
||||
borderTop: new Float64Array(0),
|
||||
borderRight: new Float64Array(0),
|
||||
borderBottom: new Float64Array(0),
|
||||
paddingLeft: new Float64Array(0),
|
||||
paddingTop: new Float64Array(0),
|
||||
paddingRight: new Float64Array(0),
|
||||
paddingBottom: new Float64Array(0),
|
||||
contentLeft: new Float64Array(0),
|
||||
contentTop: new Float64Array(0),
|
||||
contentRight: new Float64Array(0),
|
||||
contentBottom: new Float64Array(0),
|
||||
},
|
||||
visualBoxes: {
|
||||
boxId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
frameId: new Uint32Array(0),
|
||||
borderLeft: new Float64Array(0),
|
||||
borderTop: new Float64Array(0),
|
||||
borderRight: new Float64Array(0),
|
||||
borderBottom: new Float64Array(0),
|
||||
paddingLeft: new Float64Array(0),
|
||||
paddingTop: new Float64Array(0),
|
||||
paddingRight: new Float64Array(0),
|
||||
paddingBottom: new Float64Array(0),
|
||||
contentLeft: new Float64Array(0),
|
||||
contentTop: new Float64Array(0),
|
||||
contentRight: new Float64Array(0),
|
||||
contentBottom: new Float64Array(0),
|
||||
},
|
||||
fragments: {
|
||||
fragmentId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
fragmentKind: new Uint16Array(0),
|
||||
boxLeft: new Float64Array(0),
|
||||
boxTop: new Float64Array(0),
|
||||
boxRight: new Float64Array(0),
|
||||
boxBottom: new Float64Array(0),
|
||||
lineIndex: new Int32Array(0),
|
||||
flowIndex: new Int32Array(0),
|
||||
parentFragmentId: new Uint32Array(0),
|
||||
},
|
||||
transforms: {
|
||||
transformId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
matrixStart: new Uint32Array(0),
|
||||
matrixLength: new Uint16Array(0),
|
||||
originX: new Float64Array(0),
|
||||
originY: new Float64Array(0),
|
||||
},
|
||||
styles: {
|
||||
subjectId: new Uint32Array(0),
|
||||
display: new Uint16Array(0),
|
||||
position: new Uint16Array(0),
|
||||
zIndexKind: new Uint8Array(0),
|
||||
zIndexValue: new Int32Array(0),
|
||||
overflowX: new Uint16Array(0),
|
||||
overflowY: new Uint16Array(0),
|
||||
opacity: new Float32Array(0),
|
||||
visibility: new Uint16Array(0),
|
||||
containFlags: new Uint32Array(0),
|
||||
pointerEvents: new Uint16Array(0),
|
||||
lineHeight: new Float64Array(0),
|
||||
fontFamilyStringId: new Uint32Array(0),
|
||||
fontSize: new Float64Array(0),
|
||||
fontWeight: new Uint16Array(0),
|
||||
},
|
||||
text: {
|
||||
runId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
contentStringId: new Uint32Array(0),
|
||||
lineBoxId: new Uint32Array(0),
|
||||
inkLeft: new Float64Array(0),
|
||||
inkTop: new Float64Array(0),
|
||||
inkRight: new Float64Array(0),
|
||||
inkBottom: new Float64Array(0),
|
||||
baselineY: new Float64Array(0),
|
||||
capHeight: new Float64Array(0),
|
||||
computedLineHeight: new Float64Array(0),
|
||||
},
|
||||
topology: {
|
||||
containingBlockOf: new Uint32Array(0),
|
||||
nearestPositionedAncestorOf: new Uint32Array(0),
|
||||
scrollContainerOf: new Uint32Array(0),
|
||||
stackingContextOf: new Uint32Array(0),
|
||||
formattingContextOf: new Uint32Array(0),
|
||||
clippingRootOf: new Uint32Array(0),
|
||||
paintOrderBucket: new Uint16Array(0),
|
||||
paintOrderIndex: new Uint32Array(0),
|
||||
},
|
||||
scroll: {
|
||||
containerId: new Uint32Array(0),
|
||||
scrollLeft: new Float64Array(0),
|
||||
scrollTop: new Float64Array(0),
|
||||
scrollWidth: new Float64Array(0),
|
||||
scrollHeight: new Float64Array(0),
|
||||
clientWidth: new Float64Array(0),
|
||||
clientHeight: new Float64Array(0),
|
||||
},
|
||||
clipping: {
|
||||
clipNodeId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
clipKind: new Uint16Array(0),
|
||||
clipLeft: new Float64Array(0),
|
||||
clipTop: new Float64Array(0),
|
||||
clipRight: new Float64Array(0),
|
||||
clipBottom: new Float64Array(0),
|
||||
parentClipNodeId: new Uint32Array(0),
|
||||
},
|
||||
paint: {
|
||||
paintNodeId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
stackingContextId: new Uint32Array(0),
|
||||
bucket: new Uint16Array(0),
|
||||
localPaintIndex: new Uint32Array(0),
|
||||
},
|
||||
visibility: {
|
||||
subjectId: new Uint32Array(0),
|
||||
isRendered: new Uint8Array(0),
|
||||
isVisible: new Uint8Array(0),
|
||||
visibleArea: new Float64Array(0),
|
||||
clippedArea: new Float64Array(0),
|
||||
},
|
||||
provenance: {
|
||||
factId: new Uint32Array(0),
|
||||
extractionStepId: new Uint32Array(0),
|
||||
sourceKind: new Uint16Array(0),
|
||||
sourceRef: new Uint32Array(0),
|
||||
},
|
||||
confidence: {
|
||||
factId: new Uint32Array(0),
|
||||
confidence: new Float32Array(0),
|
||||
reasonCode: new Uint16Array(0),
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user