2025-08-15 10:00:00 -07:00
|
|
|
// 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`
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-21 10:31:34 -07:00
|
|
|
it('evaluates 10 clauses in under 25ms', () => {
|
2025-08-15 10:00:00 -07:00
|
|
|
registerDefaultClauses()
|
|
|
|
|
const world = generateMinimalWorld()
|
|
|
|
|
const clauses = generateClauses(10)
|
|
|
|
|
const start = performance.now()
|
|
|
|
|
evaluate(world, clauses, { skipMissingEvaluators: true })
|
|
|
|
|
const duration = performance.now() - start
|
|
|
|
|
assert.strictEqual(
|
2026-05-21 10:31:34 -07:00
|
|
|
duration < 25,
|
2025-08-15 10:00:00 -07:00
|
|
|
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,
|
|
|
|
|
}))
|
|
|
|
|
}
|