v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)

This commit is contained in:
John Dvorak
2025-08-15 10:00:00 -07:00
commit 92deb689cd
321 changed files with 79170 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
{
"name": "imhotep-bench",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/anomalyco/imhotep.git"
},
"engines": {
"node": ">=18.0.0"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "node --test dist/**/*.test.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"imhotep-core": "^1.0.0",
"imhotep-solver": "^1.0.0"
}
}
@@ -0,0 +1,53 @@
import { performance } from 'node:perf_hooks'
import { createEmptyWorld, adaptCanonicalWorldToSolver } from 'imhotep-core'
console.log('=== World Adaptation Benchmark ===\n')
for (const count of [10, 50, 100, 500, 1000, 5000]) {
const canonical = createEmptyWorld('scene', 'snap')
canonical.subjects.ids = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
canonical.subjects.domNodeId = new Uint32Array(Array.from({length: count}, (_, i) => i + 10))
canonical.subjects.subjectKind = new Uint8Array(Array.from({length: count}, () => 1))
canonical.subjects.primaryBoxId = new Uint32Array(Array.from({length: count}, (_, i) => i + 100))
canonical.subjects.firstFragmentId = new Uint32Array(Array.from({length: count}, () => 0))
canonical.subjects.fragmentCount = new Uint8Array(Array.from({length: count}, () => 1))
canonical.boxes.boxId = new Uint32Array(Array.from({length: count}, (_, i) => i + 100))
canonical.boxes.subjectId = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
canonical.boxes.frameId = new Uint32Array(Array.from({length: count}, () => 0))
canonical.boxes.borderLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10))
canonical.boxes.borderTop = new Float64Array(Array.from({length: count}, () => 0))
canonical.boxes.borderRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 50))
canonical.boxes.borderBottom = new Float64Array(Array.from({length: count}, () => 50))
canonical.boxes.paddingLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 2))
canonical.boxes.paddingTop = new Float64Array(Array.from({length: count}, () => 2))
canonical.boxes.paddingRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 48))
canonical.boxes.paddingBottom = new Float64Array(Array.from({length: count}, () => 48))
canonical.boxes.contentLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 5))
canonical.boxes.contentTop = new Float64Array(Array.from({length: count}, () => 5))
canonical.boxes.contentRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 45))
canonical.boxes.contentBottom = new Float64Array(Array.from({length: count}, () => 45))
canonical.dom.nodeId = new Uint32Array(Array.from({length: count}, (_, i) => i + 10))
canonical.dom.parentNodeId = new Uint32Array(Array.from({length: count}, () => 1))
canonical.dom.childCount = new Uint8Array(Array.from({length: count}, () => 0))
canonical.dom.tagNameStringId = new Uint16Array(Array.from({length: count}, () => 0))
canonical.topology.containingBlockOf = new Uint32Array(Array.from({length: count}, () => 0))
canonical.topology.nearestPositionedAncestorOf = new Uint32Array(Array.from({length: count}, () => 0))
canonical.topology.scrollContainerOf = new Uint32Array(Array.from({length: count}, () => 0))
canonical.topology.stackingContextOf = new Uint32Array(Array.from({length: count}, () => 0))
canonical.topology.formattingContextOf = new Uint32Array(Array.from({length: count}, () => 0))
canonical.topology.clippingRootOf = new Uint32Array(Array.from({length: count}, () => 0))
canonical.topology.paintOrderBucket = new Uint8Array(Array.from({length: count}, () => 0))
canonical.topology.paintOrderIndex = new Uint32Array(Array.from({length: count}, (_, i) => i))
const times = []
for (let i = 0; i < 100; i++) {
const start = performance.now()
adaptCanonicalWorldToSolver(canonical)
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`${count} elements: ${mean.toFixed(2)}ms (avg of 100 runs)`)
}
+204
View File
@@ -0,0 +1,204 @@
// bench.test.ts - Tests for imhotep-bench harness
// Validates benchmark execution and cache correctness.
import { describe, it } from 'node:test'
import assert from 'node:assert'
import {
MemoryCache,
CompilationCache,
ExtractionCache,
runBenchmark,
BenchmarkSuite,
profileRun,
DEFAULT_PROFILES,
checkBudget,
} from './index.js'
describe('Cache correctness', () => {
it('MemoryCache stores and retrieves values', () => {
const cache = new MemoryCache<string>()
const key = { hash: 'abc', version: '1' }
cache.set(key, 'hello')
assert.strictEqual(cache.get(key), 'hello')
assert.strictEqual(cache.size(), 1)
})
it('MemoryCache returns undefined for missing keys', () => {
const cache = new MemoryCache<number>()
assert.strictEqual(cache.get({ hash: 'missing', version: '1' }), undefined)
})
it('MemoryCache invalidates entries', () => {
const cache = new MemoryCache<number>()
const key = { hash: 'del', version: '1' }
cache.set(key, 42)
assert.strictEqual(cache.invalidate(key), true)
assert.strictEqual(cache.get(key), undefined)
assert.strictEqual(cache.invalidate(key), false)
})
it('MemoryCache respects TTL expiration', async () => {
const cache = new MemoryCache<string>()
const key = { hash: 'ttl', version: '1' }
cache.set(key, 'temp', 10)
assert.strictEqual(cache.get(key), 'temp')
await new Promise(r => setTimeout(r, 20))
assert.strictEqual(cache.get(key), undefined)
assert.strictEqual(cache.size(), 0)
})
it('MemoryCache clear removes all entries', () => {
const cache = new MemoryCache<number>()
cache.set({ hash: 'a', version: '1' }, 1)
cache.set({ hash: 'b', version: '1' }, 2)
cache.clear()
assert.strictEqual(cache.size(), 0)
})
it('CompilationCache caches compile results by source', () => {
const inner = new MemoryCache<{ code: string }>()
const cache = new CompilationCache(inner, 'v1')
const result = { code: 'compiled' }
cache.set('source-a', result)
assert.deepStrictEqual(cache.get('source-a'), result)
assert.strictEqual(cache.get('source-b'), undefined)
})
it('CompilationCache invalidation targets specific source', () => {
const inner = new MemoryCache<number>()
const cache = new CompilationCache(inner, 'v1')
cache.set('src1', 1)
cache.set('src2', 2)
cache.invalidate('src1')
assert.strictEqual(cache.get('src1'), undefined)
assert.strictEqual(cache.get('src2'), 2)
})
it('ExtractionCache caches by selector and facts', () => {
const inner = new MemoryCache<{ rects: number[] }>()
const cache = new ExtractionCache(inner, 'v1')
const result = { rects: [0, 0, 10, 10] }
cache.set('#app', ['box', 'style'], result)
assert.deepStrictEqual(cache.get('#app', ['box', 'style']), result)
assert.strictEqual(cache.get('#app', ['box']), undefined)
})
it('ExtractionCache invalidation targets specific selector+facts', () => {
const inner = new MemoryCache<number>()
const cache = new ExtractionCache(inner, 'v1')
cache.set('sel1', ['a'], 1)
cache.set('sel1', ['b'], 2)
cache.set('sel2', ['a'], 3)
cache.invalidate('sel1', ['a'])
assert.strictEqual(cache.get('sel1', ['a']), undefined)
assert.strictEqual(cache.get('sel1', ['b']), 2)
assert.strictEqual(cache.get('sel2', ['a']), 3)
})
it('Cache version isolates entries', () => {
const cache = new MemoryCache<string>()
cache.set({ hash: 'x', version: 'v1' }, 'old')
cache.set({ hash: 'x', version: 'v2' }, 'new')
assert.strictEqual(cache.get({ hash: 'x', version: 'v1' }), 'old')
assert.strictEqual(cache.get({ hash: 'x', version: 'v2' }), 'new')
})
})
describe('Benchmark execution', () => {
it('profileRun measures duration and memory', async () => {
const { durationMs, memoryDeltaBytes, result } = await profileRun(() => {
const arr = new Array(1000).fill(0)
return arr.length
})
assert.strictEqual(typeof durationMs, 'number')
assert.strictEqual(durationMs >= 0, true)
assert.strictEqual(typeof memoryDeltaBytes, 'number')
assert.strictEqual(result, 1000)
})
it('runBenchmark returns aggregated stats', async () => {
let counter = 0
const result = await runBenchmark('inc', () => {
counter++
return counter
})
assert.strictEqual(result.name, 'inc')
assert.strictEqual(typeof result.meanDurationMs, 'number')
assert.strictEqual(typeof result.minDurationMs, 'number')
assert.strictEqual(typeof result.maxDurationMs, 'number')
assert.strictEqual(typeof result.stdDevDurationMs, 'number')
assert.strictEqual(typeof result.meanMemoryDeltaBytes, 'number')
assert.strictEqual(result.runs.length, 5)
assert.strictEqual(result.minDurationMs <= result.meanDurationMs, true)
assert.strictEqual(result.meanDurationMs <= result.maxDurationMs, true)
})
it('runBenchmark applies budget check', async () => {
const result = await runBenchmark('slow', () => {}, {
budgetName: 'compile',
profile: DEFAULT_PROFILES.benchmark,
})
assert.ok(result.budgetCheck)
assert.strictEqual(result.budgetCheck!.budgetName, 'compile')
assert.strictEqual(typeof result.budgetCheck!.passed, 'boolean')
})
it('runBenchmark uses custom run counts', async () => {
let calls = 0
await runBenchmark(
'count',
() => {
calls++
},
{ warmupRuns: 2, measurementRuns: 3 }
)
assert.strictEqual(calls, 5)
})
it('BenchmarkSuite runs sequentially', async () => {
const suite = new BenchmarkSuite('seq')
const order: number[] = []
suite.add('a', () => order.push(1), { warmupRuns: 0, measurementRuns: 1 })
suite.add('b', () => order.push(2), { warmupRuns: 0, measurementRuns: 1 })
const result = await suite.runSequential()
assert.strictEqual(result.suiteName, 'seq')
assert.strictEqual(result.results.length, 2)
assert.deepStrictEqual(order, [1, 2])
assert.strictEqual(typeof result.allBudgetsPassed, 'boolean')
assert.strictEqual(typeof result.totalDurationMs, 'number')
})
it('BenchmarkSuite runs in parallel', async () => {
const suite = new BenchmarkSuite('par', {
poolOptions: { maxConcurrency: 2, taskTimeoutMs: 5000 },
})
suite.add('x', async () => {
await new Promise(r => setTimeout(r, 10))
}, { warmupRuns: 0, measurementRuns: 1 })
suite.add('y', async () => {
await new Promise(r => setTimeout(r, 10))
}, { warmupRuns: 0, measurementRuns: 1 })
const result = await suite.runParallel()
assert.strictEqual(result.results.length, 2)
assert.strictEqual(typeof result.totalDurationMs, 'number')
// Parallel total should be less than sequential sum of sleeps (20ms) plus generous overhead
assert.ok(result.totalDurationMs < 150)
})
it('Budget check passes when under budget', () => {
const check = checkBudget('compile', 10, undefined, DEFAULT_PROFILES.dev)
assert.strictEqual(check.passed, true)
assert.strictEqual(check.maxDurationMs, 50)
})
it('Budget check fails when over budget', () => {
const check = checkBudget('compile', 1000, undefined, DEFAULT_PROFILES.dev)
assert.strictEqual(check.passed, false)
})
it('Missing budget returns passed=true with Infinity', () => {
const check = checkBudget('unknown', 99999, undefined, DEFAULT_PROFILES.dev)
assert.strictEqual(check.passed, true)
assert.strictEqual(check.maxDurationMs, Infinity)
})
})
+229
View File
@@ -0,0 +1,229 @@
// benchmark.ts - Benchmark runner for Imhotep bench harness
// Measures execution time, memory, and CPU for extraction and solving paths.
import { performance } from 'node:perf_hooks'
import {
type PerformanceProfile,
type BudgetCheck,
DEFAULT_PROFILES,
checkBudget,
} from './profiles.js'
import { type CompilationCache, type ExtractionCache } from './cache.js'
import { PromisePool, type PromisePoolOptions } from './parallel.js'
export interface BenchmarkOptions {
/** Number of warm-up runs before measurement */
warmupRuns?: number
/** Number of measured runs */
measurementRuns?: number
/** Performance profile to check budgets against */
profile?: PerformanceProfile
/** Budget category name (compile | extract | solve | e2e) */
budgetName?: string
/** Optional compilation cache injected by caller */
compilationCache?: CompilationCache<unknown>
/** Optional extraction cache injected by caller */
extractionCache?: ExtractionCache<unknown>
}
export interface BenchmarkResult {
/** Benchmark name */
name: string
/** Mean duration across measured runs in milliseconds */
meanDurationMs: number
/** Minimum duration in milliseconds */
minDurationMs: number
/** Maximum duration in milliseconds */
maxDurationMs: number
/** Standard deviation of duration */
stdDevDurationMs: number
/** Mean memory delta in bytes */
meanMemoryDeltaBytes: number
/** Budget check result, if a budget name was provided */
budgetCheck?: BudgetCheck
/** Per-run raw measurements */
runs: RunMeasurement[]
}
export interface RunMeasurement {
durationMs: number
memoryDeltaBytes: number
}
/**
* Profile a single function execution.
* Returns duration and memory delta.
*/
export async function profileRun<T>(
fn: () => T | Promise<T>
): Promise<{ durationMs: number; memoryDeltaBytes: number; result: T }> {
const memBefore = process.memoryUsage().heapUsed
const start = performance.now()
const result = await fn()
const end = performance.now()
const memAfter = process.memoryUsage().heapUsed
return {
durationMs: end - start,
memoryDeltaBytes: memAfter - memBefore,
result,
}
}
/**
* Run a benchmark with warm-up and measurement phases.
*/
export async function runBenchmark<T>(
name: string,
fn: () => T | Promise<T>,
options: BenchmarkOptions = {}
): Promise<BenchmarkResult> {
const warmupRuns = options.warmupRuns ?? 1
const measurementRuns = options.measurementRuns ?? 5
const profile = options.profile ?? DEFAULT_PROFILES.dev
const budgetName = options.budgetName ?? 'e2e'
// Warm-up phase: discard results
for (let i = 0; i < warmupRuns; i++) {
await fn()
}
// Measurement phase
const runs: RunMeasurement[] = []
for (let i = 0; i < measurementRuns; i++) {
const { durationMs, memoryDeltaBytes } = await profileRun(fn)
runs.push({ durationMs, memoryDeltaBytes })
}
const durations = runs.map(r => r.durationMs)
const meanDurationMs =
durations.reduce((a, b) => a + b, 0) / durations.length
const minDurationMs = Math.min(...durations)
const maxDurationMs = Math.max(...durations)
const variance =
durations.reduce((sum, d) => sum + Math.pow(d - meanDurationMs, 2), 0) /
durations.length
const stdDevDurationMs = Math.sqrt(variance)
const meanMemoryDeltaBytes =
runs.reduce((sum, r) => sum + r.memoryDeltaBytes, 0) / runs.length
const budgetCheck = checkBudget(
budgetName,
meanDurationMs,
meanMemoryDeltaBytes,
profile
)
return {
name,
meanDurationMs,
minDurationMs,
maxDurationMs,
stdDevDurationMs,
meanMemoryDeltaBytes,
budgetCheck,
runs,
}
}
export interface BenchmarkSuiteOptions {
/** Global benchmark options applied to every benchmark */
defaults?: BenchmarkOptions
/** Promise pool options for parallel execution */
poolOptions?: PromisePoolOptions
}
export interface BenchmarkSuiteResult {
suiteName: string
results: BenchmarkResult[]
totalDurationMs: number
allBudgetsPassed: boolean
}
/**
* BenchmarkSuite collects and runs multiple named benchmarks.
* Supports sequential and parallel execution.
*/
export class BenchmarkSuite {
private benchmarks: Array<{
name: string
fn: () => unknown | Promise<unknown>
options?: BenchmarkOptions
}> = []
constructor(private suiteName: string, private options: BenchmarkSuiteOptions = {}) {}
/** Register a benchmark function */
add<T>(
name: string,
fn: () => T | Promise<T>,
options?: BenchmarkOptions
): this {
this.benchmarks.push({ name, fn, options })
return this
}
/** Run all benchmarks sequentially */
async runSequential(): Promise<BenchmarkSuiteResult> {
const results: BenchmarkResult[] = []
const suiteStart = performance.now()
for (const { name, fn, options } of this.benchmarks) {
const merged: BenchmarkOptions = {
...this.options.defaults,
...options,
}
const result = await runBenchmark(name, fn, merged)
results.push(result)
}
const suiteEnd = performance.now()
return {
suiteName: this.suiteName,
results,
totalDurationMs: suiteEnd - suiteStart,
allBudgetsPassed: results.every(
r => !r.budgetCheck || r.budgetCheck.passed
),
}
}
/** Run all benchmarks in parallel with controlled concurrency */
async runParallel(): Promise<BenchmarkSuiteResult> {
const poolOptions: PromisePoolOptions = {
maxConcurrency: this.options.poolOptions?.maxConcurrency ?? 4,
taskTimeoutMs: this.options.poolOptions?.taskTimeoutMs ?? 30000,
}
const pool = new PromisePool(poolOptions)
const suiteStart = performance.now()
try {
const tasks = this.benchmarks.map(({ name, fn, options }) => ({
id: name,
input: undefined,
fn: async () => {
const merged: BenchmarkOptions = {
...this.options.defaults,
...options,
}
return runBenchmark(name, fn, merged)
},
}))
const results = (await pool.executeAll(tasks)) as BenchmarkResult[]
const suiteEnd = performance.now()
return {
suiteName: this.suiteName,
results,
totalDurationMs: suiteEnd - suiteStart,
allBudgetsPassed: results.every(
r => !r.budgetCheck || r.budgetCheck.passed
),
}
} finally {
await pool.shutdown()
}
}
}
@@ -0,0 +1,56 @@
// benchmarks.test.ts - Validation tests for benchmark suites.
// Runs each benchmark with reduced parameters to verify correctness.
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { runFolBenchmarks } from './fol-benchmark.js'
import { runPropertyBenchmarks } from './property-benchmark.js'
import { runRenderTargetBenchmarks } from './render-target-benchmark.js'
import { runPublicApiBenchmarks } from './public-api-benchmark.js'
describe('FOL benchmarks', () => {
it('runs forall benchmarks without error', async () => {
const results = await runFolBenchmarks();
assert.ok(results.length >= 4);
for (const r of results) {
assert.strictEqual(typeof r.meanDurationMs, 'number');
assert.ok(r.meanDurationMs >= 0);
assert.ok(r.runs.length > 0);
}
});
});
describe('Property benchmarks', () => {
it('runs enumerated and sampled benchmarks', async () => {
const results = await runPropertyBenchmarks();
assert.ok(results.length >= 2);
for (const r of results) {
assert.strictEqual(typeof r.meanDurationMs, 'number');
assert.ok(r.meanDurationMs >= 0);
}
});
});
describe('Render target benchmarks', () => {
it('runs renderer mount benchmarks', async () => {
const results = await runRenderTargetBenchmarks();
assert.ok(results.length >= 4);
for (const r of results) {
assert.strictEqual(typeof r.meanDurationMs, 'number');
assert.ok(r.meanDurationMs >= 0);
}
});
});
describe('Public API benchmarks', () => {
it('runs leftOf relation benchmark under 100ms', async () => {
const results = await runPublicApiBenchmarks();
assert.ok(results.length >= 1);
for (const r of results) {
assert.strictEqual(typeof r.meanDurationMs, 'number');
assert.ok(r.meanDurationMs >= 0);
assert.ok(r.meanDurationMs < 100, `Expected public API benchmark to complete in under 100ms, but took ${r.meanDurationMs}ms`);
}
});
});
@@ -0,0 +1,52 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-cache-hit-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
const selectors = Array.from({length: 50}, (_, i) => `[data-testid="item-${i}"]`)
// Warmup: run once to populate cache
const ui1 = await imhotep(page)
for (const sel of selectors) {
ui1.expect(sel).to.be.leftOf('[data-testid="item-0"]')
}
await ui1.checkAll()
console.log('=== Cache Hit Performance ===\n')
for (const count of [1, 5, 10, 25, 50]) {
const ui = await imhotep(page)
for (let i = 0; i < count; i++) {
ui.expect(selectors[i]).to.be.leftOf('[data-testid="item-0"]')
}
const times = []
for (let run = 0; run < 10; run++) {
const start = performance.now()
await ui.checkAll()
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`${count} selectors (cache hit): ${mean.toFixed(2)}ms (avg of 10 runs)`)
}
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
+171
View File
@@ -0,0 +1,171 @@
// cache.ts - Compilation and extraction cache implementations for Imhotep bench harness
// Provides in-memory caching with TTL, versioning, and explicit invalidation.
export interface CacheKey {
/** Content hash or deterministic identifier */
hash: string
/** Cache schema version for invalidation on format changes */
version: string
}
export interface CacheEntry<T> {
/** Stored value */
value: T
/** Unix timestamp when the entry was created */
createdAt: number
/** Optional Unix timestamp when the entry expires */
expiresAt?: number
}
export interface Cache<T> {
get(key: CacheKey): T | undefined
set(key: CacheKey, value: T, ttlMs?: number): void
invalidate(key: CacheKey): boolean
clear(): void
size(): number
}
/** Simple in-memory cache backed by a Map. */
export class MemoryCache<T> implements Cache<T> {
private store = new Map<string, CacheEntry<T>>()
private makeKey(key: CacheKey): string {
return `${key.version}:${key.hash}`
}
get(key: CacheKey): T | undefined {
const k = this.makeKey(key)
const entry = this.store.get(k)
if (!entry) return undefined
if (entry.expiresAt !== undefined && Date.now() > entry.expiresAt) {
this.store.delete(k)
return undefined
}
return entry.value
}
set(key: CacheKey, value: T, ttlMs?: number): void {
const k = this.makeKey(key)
const now = Date.now()
this.store.set(k, {
value,
createdAt: now,
expiresAt: ttlMs !== undefined ? now + ttlMs : undefined,
})
}
invalidate(key: CacheKey): boolean {
return this.store.delete(this.makeKey(key))
}
clear(): void {
this.store.clear()
}
size(): number {
// Prune expired entries before reporting size for accuracy
const now = Date.now()
for (const [k, entry] of this.store) {
if (entry.expiresAt !== undefined && now > entry.expiresAt) {
this.store.delete(k)
}
}
return this.store.size
}
}
/** Cache key factory for compilation inputs. */
export function makeCompilationCacheKey(
source: string,
version: string
): CacheKey {
// Simple hash: in production this should be a real hash function
let hash = 0
for (let i = 0; i < source.length; i++) {
const char = source.charCodeAt(i)
hash = (hash << 5) - hash + char
hash |= 0
}
return { hash: String(hash), version }
}
/** Cache key factory for extraction plans. */
export function makeExtractionCacheKey(
selector: string,
facts: string[],
version: string
): CacheKey {
const combined = `${selector}::${facts.sort().join(',')}`
let hash = 0
for (let i = 0; i < combined.length; i++) {
const char = combined.charCodeAt(i)
hash = (hash << 5) - hash + char
hash |= 0
}
return { hash: String(hash), version }
}
/**
* CompilationCache wraps a generic Cache for CompileResult-like objects.
* Accepts the underlying Cache via constructor injection.
*/
export class CompilationCache<TCompileResult> {
constructor(
private cache: Cache<TCompileResult>,
private version: string
) {}
get(source: string): TCompileResult | undefined {
return this.cache.get(makeCompilationCacheKey(source, this.version))
}
set(source: string, result: TCompileResult, ttlMs?: number): void {
this.cache.set(makeCompilationCacheKey(source, this.version), result, ttlMs)
}
invalidate(source: string): boolean {
return this.cache.invalidate(makeCompilationCacheKey(source, this.version))
}
clear(): void {
this.cache.clear()
}
}
/**
* ExtractionCache wraps a generic Cache for extraction payloads.
* Accepts the underlying Cache via constructor injection.
*/
export class ExtractionCache<TExtractResult> {
constructor(
private cache: Cache<TExtractResult>,
private version: string
) {}
get(selector: string, facts: string[]): TExtractResult | undefined {
return this.cache.get(makeExtractionCacheKey(selector, facts, this.version))
}
set(
selector: string,
facts: string[],
result: TExtractResult,
ttlMs?: number
): void {
this.cache.set(
makeExtractionCacheKey(selector, facts, this.version),
result,
ttlMs
)
}
invalidate(selector: string, facts: string[]): boolean {
return this.cache.invalidate(
makeExtractionCacheKey(selector, facts, this.version)
)
}
clear(): void {
this.cache.clear()
}
}
@@ -0,0 +1,232 @@
/**
* Contention analysis benchmark.
*
* Tests parallel execution at different granularities to identify
* where contention occurs: browser, context, or page level.
*/
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { Semaphore } from '../../imhotep-playwright/src/semaphore.js'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-contention-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="app">
<button id="btn1">Button 1</button>
<button id="btn2">Button 2</button>
<div id="result"></div>
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'fixture.html'), html)
function mean(values) {
return values.reduce((a, b) => a + b, 0) / values.length
}
async function benchmark() {
console.log('========================================')
console.log(' Contention Analysis Benchmark')
console.log('========================================\n')
const browser = await chromium.launch()
const fixtureUrl = `file:${join(tmpdir2, 'fixture.html')}`
// ============================================================
// Test 1: Baseline sequential page operations
// ============================================================
console.log('--- Test 1: Sequential Baseline ---')
const seqTimes = []
for (let run = 0; run < 3; run++) {
const start = performance.now()
for (let i = 0; i < 20; i++) {
const ctx = await browser.newContext()
const page = await ctx.newPage()
await page.goto(fixtureUrl)
await page.evaluate(() => document.getElementById('result').textContent = 'done')
await page.close()
await ctx.close()
}
seqTimes.push(performance.now() - start)
}
const seqMean = mean(seqTimes)
console.log(` 20 ops (new context each): ${seqMean.toFixed(1)}ms\n`)
// ============================================================
// Test 2: Parallel with varying concurrency
// ============================================================
console.log('--- Test 2: Parallel Execution Sweep ---')
console.log(' Using isolated contexts (no sharing)')
for (const concurrency of [1, 2, 4, 6, 8, 12, 16]) {
const sem = new Semaphore(concurrency)
const times = []
for (let run = 0; run < 3; run++) {
const start = performance.now()
await Promise.all(
Array.from({ length: 20 }, () =>
sem.run(async () => {
const ctx = await browser.newContext()
const page = await ctx.newPage()
await page.goto(fixtureUrl)
await page.evaluate(() => document.getElementById('result').textContent = 'done')
await page.close()
await ctx.close()
})
)
)
times.push(performance.now() - start)
}
const t = mean(times)
const speedup = seqMean / t
const efficiency = (speedup / concurrency) * 100
console.log(` concurrency=${concurrency.toString().padStart(2)}: ${t.toFixed(1).padStart(7)}ms speedup: ${speedup.toFixed(1).padStart(4)}x efficiency: ${efficiency.toFixed(0).padStart(3)}%`)
}
console.log('')
// ============================================================
// Test 3: Page-level parallelism within single context
// ============================================================
console.log('--- Test 3: Pages in Single Context ---')
for (const concurrency of [1, 2, 4, 6, 8]) {
const sem = new Semaphore(concurrency)
const times = []
for (let run = 0; run < 3; run++) {
const ctx = await browser.newContext()
const start = performance.now()
await Promise.all(
Array.from({ length: 20 }, () =>
sem.run(async () => {
const page = await ctx.newPage()
await page.goto(fixtureUrl)
await page.evaluate(() => document.getElementById('result').textContent = 'done')
await page.close()
})
)
)
times.push(performance.now() - start)
await ctx.close()
}
const t = mean(times)
const speedup = seqMean / t
console.log(` concurrency=${concurrency}: ${t.toFixed(1).padStart(7)}ms speedup: ${speedup.toFixed(1)}x`)
}
console.log('')
// ============================================================
// Test 4: I/O vs CPU operation types
// ============================================================
console.log('--- Test 4: Operation Type Comparison ---')
async function ioOp() {
const ctx = await browser.newContext()
const page = await ctx.newPage()
await page.goto(fixtureUrl)
await ctx.close()
}
async function mixedOp() {
const ctx = await browser.newContext()
const page = await ctx.newPage()
await page.goto(fixtureUrl)
await page.evaluate(() => {
const div = document.createElement('div')
div.textContent = 'test'
document.body.appendChild(div)
return div.getBoundingClientRect()
})
await ctx.close()
}
async function cpuOp() {
const ctx = await browser.newContext()
const page = await ctx.newPage()
await page.goto(fixtureUrl)
await page.evaluate(() => {
let a = 0, b = 1
for (let i = 0; i < 1000000; i++) {
const temp = a + b; a = b; b = temp
}
return b
})
await ctx.close()
}
for (const [name, op] of [['I/O', ioOp], ['Mixed', mixedOp], ['CPU', cpuOp]]) {
const sem = new Semaphore(4)
const seqT = []
const parT = []
for (let run = 0; run < 3; run++) {
const s0 = performance.now()
for (let i = 0; i < 4; i++) await op()
seqT.push(performance.now() - s0)
const p0 = performance.now()
await Promise.all(Array.from({ length: 4 }, () => sem.run(op)))
parT.push(performance.now() - p0)
}
const speedup = mean(seqT) / mean(parT)
console.log(` ${name.padEnd(5)}: seq=${mean(seqT).toFixed(1).padStart(6)}ms par=${mean(parT).toFixed(1).padStart(6)}ms speedup: ${speedup.toFixed(1)}x`)
}
console.log('')
// ============================================================
// Test 5: Resource degradation with many pages
// ============================================================
console.log('--- Test 5: Resource Saturation ---')
const ctx = await browser.newContext()
const pageCreationTimes = []
const pages = []
for (let i = 0; i < 20; i++) {
const t0 = performance.now()
const p = await ctx.newPage()
pageCreationTimes.push(performance.now() - t0)
pages.push(p)
}
console.log(` Page creation (1st): ${pageCreationTimes[0].toFixed(1)}ms`)
console.log(` Page creation (10th): ${pageCreationTimes[9].toFixed(1)}ms`)
console.log(` Page creation (20th): ${pageCreationTimes[19].toFixed(1)}ms`)
// Now navigate all simultaneously
const navStart = performance.now()
await Promise.all(pages.map(p => p.goto(fixtureUrl)))
const navTime = performance.now() - navStart
console.log(` Simultaneous navigation (20 pages): ${navTime.toFixed(1)}ms`)
await Promise.all(pages.map(p => p.close()))
await ctx.close()
console.log('')
// ============================================================
// Cleanup
// ============================================================
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
console.log('========================================')
console.log(' Contention Analysis Complete')
console.log('========================================')
}
benchmark().catch(err => {
console.error('Benchmark failed:', err)
process.exit(1)
})
@@ -0,0 +1,76 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-e2e-profile-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 100}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
// Profile each phase separately
console.log('=== End-to-End Pipeline Profile ===\n')
// Phase 1: Page creation + imhotep setup
const setupTimes = []
for (let i = 0; i < 5; i++) {
const start = performance.now()
const ui = await imhotep(page)
setupTimes.push(performance.now() - start)
}
console.log(`imhotep() setup: ${(setupTimes.reduce((a,b) => a+b, 0) / setupTimes.length).toFixed(1)}ms`)
// Phase 2: Assertion accumulation
const ui = await imhotep(page)
const accStart = performance.now()
for (let i = 0; i < 50; i++) {
ui.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i + 1}]`)
}
console.log(`Assertion accumulation (50): ${(performance.now() - accStart).toFixed(2)}ms`)
// Phase 3: First extraction (cold, no cache)
const coldStart = performance.now()
await ui.checkAll()
const coldDur = performance.now() - coldStart
console.log(`First extraction (cold): ${coldDur.toFixed(1)}ms`)
// Phase 4: Second extraction (warm, with cache)
const ui2 = await imhotep(page, { cacheDir: join(tmpdir2, 'cache') })
for (let i = 0; i < 50; i++) {
ui2.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i + 1}]`)
}
const warmStart = performance.now()
await ui2.checkAll()
const warmDur = performance.now() - warmStart
console.log(`First check with cache dir: ${warmDur.toFixed(1)}ms`)
// Phase 5: Cached run
const warmStart2 = performance.now()
await ui2.checkAll()
const warmDur2 = performance.now() - warmStart2
console.log(`Second check (cache hit): ${warmDur2.toFixed(1)}ms`)
// Phase 6: validate() only
const ui3 = await imhotep(page)
for (let i = 0; i < 50; i++) {
ui3.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i + 1}]`)
}
const valStart = performance.now()
ui3.validate()
console.log(`validate() only: ${(performance.now() - valStart).toFixed(2)}ms`)
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,167 @@
import { performance } from 'node:perf_hooks'
import {
evaluateLogic,
registerDefaultPredicates,
} from 'imhotep-solver'
import { adaptCanonicalWorldToSolver } from 'imhotep-core'
registerDefaultPredicates()
// Build a world with 100 elements arranged in a grid
function buildWorld(n) {
const canonical = {
sceneId: 's',
snapshotId: 'sn',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: new Uint32Array(Array.from({length: n}, (_, i) => i)),
domNodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
subjectKind: new Uint8Array(Array.from({length: n}, () => 1)),
primaryBoxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
firstFragmentId: new Uint32Array(Array.from({length: n}, () => 0)),
fragmentCount: new Uint8Array(Array.from({length: n}, () => 1)),
},
boxes: {
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
},
visualBoxes: {
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
},
dom: {
nodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
parentNodeId: new Uint32Array(Array.from({length: n}, () => 1)),
childCount: new Uint8Array(Array.from({length: n}, () => 0)),
tagNameStringId: new Uint16Array(Array.from({length: n}, () => 0)),
},
transforms: { transformId: new Uint32Array(0), subjectId: new Uint32Array(0), matrixStart: new Uint32Array(0), matrixLength: new Uint32Array(0), originX: new Float64Array(0), originY: new Float64Array(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) },
topology: {
containingBlockOf: new Uint32Array(Array.from({length: n}, () => 0)),
nearestPositionedAncestorOf: new Uint32Array(Array.from({length: n}, () => 0)),
scrollContainerOf: new Uint32Array(Array.from({length: n}, () => 0)),
stackingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
formattingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
clippingRootOf: new Uint32Array(Array.from({length: n}, () => 0)),
paintOrderBucket: new Uint8Array(Array.from({length: n}, () => 0)),
paintOrderIndex: new Uint32Array(Array.from({length: n}, (_, i) => i)),
},
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) },
visibility: { subjectId: new Uint32Array(0), isRendered: new Uint8Array(0), isVisible: new Uint8Array(0), visibleArea: new Float64Array(0), clippedArea: new Float64Array(0) },
}
return adaptCanonicalWorldToSolver(canonical)
}
class SimpleResolver {
constructor() {
this.domains = new Map()
}
register(selector, ids) {
this.domains.set(selector, {
domainId: `dom_${selector}`,
subjectIds: new Uint32Array(ids),
provenance: `elements(${selector})`,
closed: true,
})
}
resolve(domain) {
return this.domains.get(domain.selector ?? domain.domain)
}
}
console.log('=== Evaluation Allocation Benchmark ===\n')
for (const n of [10, 50, 100]) {
const world = buildWorld(n)
const resolver = new SimpleResolver()
resolver.register('.a', Array.from({length: n}, (_, i) => i))
resolver.register('.b', Array.from({length: n}, (_, i) => i))
const formula = {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['$subject'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.a' }
}],
body: {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['$reference'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.b' }
}],
body: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'leftOf',
args: [
{ type: 'VariableRef', name: '$subject' },
{ type: 'VariableRef', name: '$reference' }
]
}
}
}
const timesTrace = []
const timesNoTrace = []
for (let i = 0; i < 100; i++) {
const start1 = performance.now()
evaluateLogic({ formula, world, resolver, options: { trace: true } })
timesTrace.push(performance.now() - start1)
const start2 = performance.now()
evaluateLogic({ formula, world, resolver, options: { trace: false } })
timesNoTrace.push(performance.now() - start2)
}
const mean = (arr) => arr.reduce((a,b) => a+b, 0) / arr.length
const withTrace = mean(timesTrace)
const withoutTrace = mean(timesNoTrace)
console.log(`${n}×${n} pairs (${n*n} evaluations):`)
console.log(` With trace: ${withTrace.toFixed(2)}ms`)
console.log(` Without trace: ${withoutTrace.toFixed(2)}ms`)
console.log(` Savings: ${((1 - withoutTrace/withTrace) * 100).toFixed(0)}%`)
}
+151
View File
@@ -0,0 +1,151 @@
import { performance } from 'node:perf_hooks'
import {
evaluateLogic,
registerDefaultPredicates,
} from 'imhotep-solver'
import { adaptCanonicalWorldToSolver } from 'imhotep-core'
registerDefaultPredicates()
function buildWorld(n) {
const canonical = {
sceneId: 's',
snapshotId: 'sn',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: new Uint32Array(Array.from({length: n}, (_, i) => i)),
domNodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
subjectKind: new Uint8Array(Array.from({length: n}, () => 1)),
primaryBoxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
firstFragmentId: new Uint32Array(Array.from({length: n}, () => 0)),
fragmentCount: new Uint8Array(Array.from({length: n}, () => 1)),
},
boxes: {
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
},
visualBoxes: {
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
},
dom: {
nodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
parentNodeId: new Uint32Array(Array.from({length: n}, () => 1)),
childCount: new Uint8Array(Array.from({length: n}, () => 0)),
tagNameStringId: new Uint16Array(Array.from({length: n}, () => 0)),
},
transforms: { transformId: new Uint32Array(0), subjectId: new Uint32Array(0), matrixStart: new Uint32Array(0), matrixLength: new Uint32Array(0), originX: new Float64Array(0), originY: new Float64Array(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) },
topology: {
containingBlockOf: new Uint32Array(Array.from({length: n}, () => 0)),
nearestPositionedAncestorOf: new Uint32Array(Array.from({length: n}, () => 0)),
scrollContainerOf: new Uint32Array(Array.from({length: n}, () => 0)),
stackingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
formattingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
clippingRootOf: new Uint32Array(Array.from({length: n}, () => 0)),
paintOrderBucket: new Uint8Array(Array.from({length: n}, () => 0)),
paintOrderIndex: new Uint32Array(Array.from({length: n}, (_, i) => i)),
},
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) },
visibility: { subjectId: new Uint32Array(0), isRendered: new Uint8Array(0), isVisible: new Uint8Array(0), visibleArea: new Float64Array(0), clippedArea: new Float64Array(0) },
}
return adaptCanonicalWorldToSolver(canonical)
}
class SimpleResolver {
constructor() {
this.domains = new Map()
}
register(selector, ids) {
this.domains.set(selector, {
domainId: `dom_${selector}`,
subjectIds: new Uint32Array(ids),
provenance: `elements(${selector})`,
closed: true,
})
}
resolve(domain) {
return this.domains.get(domain.selector ?? domain.domain)
}
}
// Run many iterations for V8 profiling
const n = 100
const world = buildWorld(n)
const resolver = new SimpleResolver()
resolver.register('.a', Array.from({length: n}, (_, i) => i))
resolver.register('.b', Array.from({length: n}, (_, i) => i))
const formula = {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['$subject'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.a' }
}],
body: {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['$reference'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.b' }
}],
body: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'leftOf',
args: [
{ type: 'VariableRef', name: '$subject' },
{ type: 'VariableRef', name: '$reference' }
]
}
}
}
for (let i = 0; i < 200; i++) {
evaluateLogic({ formula, world, resolver, options: { trace: false } })
}
console.log('Evaluation profiling complete')
@@ -0,0 +1,79 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-breakdown-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
console.log('=== Extraction Pipeline Breakdown ===\n')
// Profile CDP session creation
const sessionStart = performance.now()
const session = await page.context().newCDPSession(page)
await session.send('DOM.enable')
console.log(`CDP session + DOM.enable: ${(performance.now() - sessionStart).toFixed(1)}ms`)
// Profile selector resolution (50 selectors)
const selectors = Array.from({length: 50}, (_, i) => `[data-testid="item-${i}"]`)
const doc = await session.send('DOM.getDocument')
const rootNodeId = doc.root.nodeId
const resolveStart = performance.now()
for (const sel of selectors) {
await session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: sel })
}
console.log(`Resolve 50 selectors (DOM.querySelectorAll): ${(performance.now() - resolveStart).toFixed(1)}ms`)
// Profile box model extraction (10 elements)
const { nodeIds } = await session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: '[data-testid="item-0"]' })
const boxModelStart = performance.now()
for (const nodeId of nodeIds.slice(0, 10)) {
try {
await session.send('DOM.getBoxModel', { nodeId })
} catch {}
}
console.log(`Get box model for 10 elements: ${(performance.now() - boxModelStart).toFixed(1)}ms`)
// Profile full CDP extraction via Imhotep
const { CDPExtractor, createSessionManager } = await import('imhotep-cdp')
const fullStart = performance.now()
const sm = createSessionManager(page)
const extractor = new CDPExtractor(sm)
const result = await extractor.extract({
requestId: 'bench',
sceneTarget: { kind: 'page', pageRef: page, url: page.url() },
env: { viewportWidth: 1280, viewportHeight: 800, colorScheme: 'light', pointer: 'fine' },
subjects: selectors.slice(0, 10).map((s, i) => ({ id: `subj_${i}`, selector: s })),
requiredFacts: { geometry: true, topology: true, styles: false, fragments: false }
})
const fullDur = performance.now() - fullStart
console.log(`\nFull CDPExtractor (10 subjects): ${fullDur.toFixed(1)}ms`)
console.log(`Status: ${result.status}, Snapshots: ${result.snapshots?.length ?? 0}`)
// Profile world adaptation
if (result.snapshots?.length > 0) {
const { adaptSnapshotToCanonical } = await import('imhotep-cdp')
const { adaptCanonicalWorldToSolver } = await import('imhotep-core')
const adaptStart = performance.now()
const canonical = adaptSnapshotToCanonical(result.snapshots[0])
const solver = adaptCanonicalWorldToSolver(canonical)
console.log(`World adaptation (canonical -> solver): ${(performance.now() - adaptStart).toFixed(1)}ms`)
}
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,45 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-cold-extract-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
console.log('=== Cold Extraction (cache cleared each run) ===\n')
for (const count of [1, 5, 10, 25, 50]) {
const selectors = Array.from({length: count}, (_, i) => `[data-testid="item-${i}"]`)
const ui = await imhotep(page)
for (const sel of selectors) {
ui.expect(sel).to.be.leftOf('[data-testid="item-0"]')
}
const times = []
for (let run = 0; run < 3; run++) {
await ui.clearCache()
const start = performance.now()
await ui.checkAll()
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`${count} selectors: ${mean.toFixed(1)}ms (3 cold runs)`)
}
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,36 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-hotspot-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
// Run extraction 50 times for profiling
for (let i = 0; i < 50; i++) {
const ui = await imhotep(page)
for (let j = 0; j < 10; j++) {
ui.expect(`[data-testid="item-${j}"]`).to.be.leftOf('[data-testid="item-0"]')
}
await ui.checkAll()
}
console.log('Extraction profiling complete')
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,47 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-profile-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 200}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 20) * 60}px;top:${Math.floor(i / 20) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
// Warm up
const ui0 = await imhotep(page)
ui0.expect('[data-testid=item-0]').to.be.leftOf('[data-testid=item-1]')
await ui0.checkAll()
// Profile extraction + evaluation
const times = []
for (let run = 0; run < 5; run++) {
const ui = await imhotep(page)
ui.expect('[data-testid=item-0]').to.be.leftOf('[data-testid=item-1]')
for (let i = 2; i < 20; i++) {
ui.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i}]`)
}
const start = performance.now()
await ui.checkAll()
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`Extraction + 20 assertions: ${mean.toFixed(1)}ms (avg of 5)`)
console.log(`Min: ${Math.min(...times).toFixed(1)}ms, Max: ${Math.max(...times).toFixed(1)}ms`)
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,71 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-prof2-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
console.log('=== Extraction Phase Breakdown ===\n')
// Profile with varying number of selectors
for (const count of [1, 5, 10, 25, 50]) {
const selectors = Array.from({length: count}, (_, i) => `[data-testid="item-${i}"]`)
const ui = await imhotep(page)
for (const sel of selectors) {
ui.expect(sel).to.be.leftOf('[data-testid="item-0"]')
}
const times = []
for (let run = 0; run < 3; run++) {
const start = performance.now()
await ui.checkAll()
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`${count} selectors: ${mean.toFixed(1)}ms (3 runs)`)
}
// Now profile cache serialization separately
console.log('\n=== Cache Serialization Profile ===')
const { createEmptyWorld, serializeGeometryWorld } = await import('imhotep-core')
for (const count of [100, 500, 1000, 5000]) {
const world = createEmptyWorld('s', 'sn')
world.subjects.ids = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
world.subjects.domNodeId = new Uint32Array(Array.from({length: count}, (_, i) => i + 10))
world.boxes.boxId = new Uint32Array(Array.from({length: count}, (_, i) => i + 100))
world.boxes.subjectId = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
world.boxes.borderLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10))
world.boxes.borderTop = new Float64Array(Array.from({length: count}, () => 0))
world.boxes.borderRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 50))
world.boxes.borderBottom = new Float64Array(Array.from({length: count}, () => 50))
const times = []
for (let i = 0; i < 100; i++) {
const start = performance.now()
serializeGeometryWorld(world)
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
const json = serializeGeometryWorld(world)
console.log(`${count} elements: ${mean.toFixed(2)}ms, ${(json.length / 1024).toFixed(1)}KB JSON`)
}
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,54 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-fact-plan-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 10}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
const selectors = Array.from({length: 10}, (_, i) => `[data-testid="item-${i}"]`)
const { CDPExtractor, createSessionManager } = await import('imhotep-cdp')
const sm = createSessionManager(page)
// Full extraction (all facts)
const fullStart = performance.now()
const fullResult = await new CDPExtractor(sm).extract({
requestId: 'full',
sceneTarget: { pageRef: page, url: page.url() },
env: { viewportWidth: 1280, viewportHeight: 800, colorScheme: 'light', pointer: 'fine' },
subjects: selectors.map((s, i) => ({ id: `subj_${i}`, selector: s })),
requiredFacts: { geometry: true, topology: true, styles: true, fragments: true }
})
const fullDur = performance.now() - fullStart
console.log(`Full extraction (all facts): ${fullDur.toFixed(1)}ms`)
// Geometry only
const geoStart = performance.now()
const geoResult = await new CDPExtractor(sm).extract({
requestId: 'geo',
sceneTarget: { pageRef: page, url: page.url() },
env: { viewportWidth: 1280, viewportHeight: 800, colorScheme: 'light', pointer: 'fine' },
subjects: selectors.map((s, i) => ({ id: `subj_${i}`, selector: s })),
requiredFacts: { geometry: true, topology: false, styles: false, fragments: false }
})
const geoDur = performance.now() - geoStart
console.log(`Geometry only: ${geoDur.toFixed(1)}ms`)
console.log(`Savings: ${((1 - geoDur/fullDur) * 100).toFixed(0)}%`)
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,78 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-final-bench-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `
<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
console.log('=== Final Comprehensive Benchmark ===\n')
// Cold extraction with varying assertion counts
console.log('Cold extraction (cache cleared):')
for (const count of [1, 10, 50]) {
const ui = await imhotep(page)
for (let i = 0; i < count; i++) {
ui.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
}
const times = []
for (let run = 0; run < 5; run++) {
await ui.clearCache()
const start = performance.now()
await ui.checkAll()
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(` ${count} assertions: ${mean.toFixed(1)}ms`)
}
// Cache hit performance
console.log('\nCache hit (warm):')
const ui2 = await imhotep(page)
for (let i = 0; i < 50; i++) {
ui2.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
}
await ui2.checkAll() // warm up cache
const warmTimes = []
for (let run = 0; run < 10; run++) {
const start = performance.now()
await ui2.checkAll()
warmTimes.push(performance.now() - start)
}
const warmMean = warmTimes.reduce((a,b) => a+b, 0) / warmTimes.length
console.log(` 50 assertions: ${warmMean.toFixed(2)}ms`)
// validate() only
console.log('\nValidation only (no browser):')
const ui3 = await imhotep(page)
for (let i = 0; i < 50; i++) {
ui3.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
}
const validateTimes = []
for (let run = 0; run < 100; run++) {
const start = performance.now()
ui3.validate()
validateTimes.push(performance.now() - start)
}
const validateMean = validateTimes.reduce((a,b) => a+b, 0) / validateTimes.length
console.log(` 50 assertions: ${validateMean.toFixed(2)}ms`)
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
+308
View File
@@ -0,0 +1,308 @@
// fol-benchmark.ts - Benchmark deterministic first-order logic evaluation.
// Measures forall/exists performance over finite domains.
import { performance } from 'node:perf_hooks';
import {
runBenchmark,
BenchmarkSuite,
type BenchmarkResult,
} from './benchmark.js';
import {
evaluateLogic,
registerDefaultPredicates,
clearPredicateRegistry,
} from 'imhotep-solver';
import type {
GeometryWorld,
} from 'imhotep-solver';
interface DomainValue {
domainId: string;
subjectIds: Uint32Array;
provenance: string;
closed: boolean;
}
// ---------------------------------------------------------------------------
// World Factory
// ---------------------------------------------------------------------------
function makeLinearWorld(count: number): GeometryWorld {
const ids = Array.from({ length: count }, (_, i) => i + 1);
const boxIds = ids.map((id) => id * 100);
// Place elements in a horizontal row, each 50px wide with 10px gap
const borderLeft = ids.map((_, i) => i * 60);
const borderTop = ids.map(() => 0);
const borderRight = borderLeft.map((l) => l + 50);
const borderBottom = ids.map(() => 50);
return {
sceneId: `linear_${count}`,
snapshotId: 'snap_1',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids,
domNodeId: ids.map((id) => id * 10),
subjectKind: ids.map(() => 1),
primaryBoxId: boxIds,
firstFragmentId: ids.map(() => 0),
fragmentCount: ids.map(() => 0),
},
dom: {
nodeId: ids.map((id) => id * 10),
parentNodeId: ids.map(() => 0),
childCount: ids.map(() => 0),
tagNameStringId: ids.map(() => 0),
},
boxes: {
boxId: boxIds,
subjectId: ids,
frameId: ids.map(() => 1),
borderLeft,
borderTop,
borderRight,
borderBottom,
paddingLeft: ids.map(() => 0),
paddingTop: ids.map(() => 0),
paddingRight: ids.map(() => 0),
paddingBottom: ids.map(() => 0),
contentLeft: ids.map(() => 0),
contentTop: ids.map(() => 0),
contentRight: ids.map(() => 0),
contentBottom: ids.map(() => 0),
},
visualBoxes: {
boxId: boxIds,
subjectId: ids,
frameId: ids.map(() => 1),
borderLeft,
borderTop,
borderRight,
borderBottom,
paddingLeft: ids.map(() => 0),
paddingTop: ids.map(() => 0),
paddingRight: ids.map(() => 0),
paddingBottom: ids.map(() => 0),
contentLeft: ids.map(() => 0),
contentTop: ids.map(() => 0),
contentRight: ids.map(() => 0),
contentBottom: ids.map(() => 0),
},
transforms: {
transformId: [],
subjectId: [],
matrixStart: [],
matrixLength: [],
originX: [],
originY: [],
},
matrices: {
values: [],
},
rects: { rectId: [], left: [], top: [], right: [], bottom: [] },
topology: {
containingBlockOf: ids.map(() => 0),
nearestPositionedAncestorOf: ids.map(() => 0),
scrollContainerOf: ids.map(() => 0),
stackingContextOf: ids.map(() => 0),
formattingContextOf: ids.map(() => 0),
clippingRootOf: ids.map(() => 0),
paintOrderBucket: ids.map(() => 0),
paintOrderIndex: ids.map(() => 0),
},
scroll: {
containerId: [], scrollLeft: [], scrollTop: [],
scrollWidth: [], scrollHeight: [], clientWidth: [], clientHeight: []
},
clipping: {
clipNodeId: [], subjectId: [], clipKind: [],
clipLeft: [], clipTop: [], clipRight: [], clipBottom: [], parentClipNodeId: []
},
visibility: {
subjectId: [], isRendered: [], isVisible: [], visibleArea: [], clippedArea: []
},
};
}
// ---------------------------------------------------------------------------
// Resolver Factory
// ---------------------------------------------------------------------------
function makeResolver(count: number) {
return {
resolve(domain: any): DomainValue | undefined {
const key = domain.selector ?? domain.domain;
if (key === '.el') {
return {
domainId: 'dom_el',
subjectIds: new Uint32Array(Array.from({ length: count }, (_, i) => i + 1)),
provenance: 'elements(.el)',
closed: true,
};
}
return undefined;
}
};
}
// ---------------------------------------------------------------------------
// Formula Factories
// ---------------------------------------------------------------------------
function forallFormula(): any {
return {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['x'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
}],
body: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'atLeast',
args: [{ type: 'VariableRef', name: 'x' }]
}
};
}
function existsFormula(): any {
return {
type: 'FormulaNode',
kind: 'exists',
bindings: [{
type: 'TupleBinding',
variables: ['x'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
}],
body: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'atLeast',
args: [{ type: 'VariableRef', name: 'x' }]
}
};
}
function nestedFormula(): any {
return {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['x'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
}],
body: {
type: 'FormulaNode',
kind: 'exists',
bindings: [{
type: 'TupleBinding',
variables: ['y'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
}],
body: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'leftOf',
args: [{ type: 'VariableRef', name: 'x' }, { type: 'VariableRef', name: 'y' }]
}
}
};
}
// ---------------------------------------------------------------------------
// Benchmarks
// ---------------------------------------------------------------------------
export async function runFolBenchmarks(): Promise<BenchmarkResult[]> {
clearPredicateRegistry();
registerDefaultPredicates();
const suite = new BenchmarkSuite('fol-evaluation', {
defaults: { warmupRuns: 1, measurementRuns: 5 }
});
// forall over 100 elements
suite.add('forall-100', () => {
const world = makeLinearWorld(100);
evaluateLogic({
formula: forallFormula(),
world,
resolver: makeResolver(100),
});
});
// forall over 1,000 elements
suite.add('forall-1000', () => {
const world = makeLinearWorld(1000);
evaluateLogic({
formula: forallFormula(),
world,
resolver: makeResolver(1000),
});
});
// forall over 10,000 elements
suite.add('forall-10000', () => {
const world = makeLinearWorld(10000);
evaluateLogic({
formula: forallFormula(),
world,
resolver: makeResolver(10000),
});
});
// exists with early exit (first element always matches)
suite.add('exists-early-exit-10000', () => {
const world = makeLinearWorld(10000);
evaluateLogic({
formula: existsFormula(),
world,
resolver: makeResolver(10000),
});
});
// nested quantifiers (forall + exists) over 100 elements
suite.add('nested-quantifiers-100', () => {
const world = makeLinearWorld(100);
evaluateLogic({
formula: nestedFormula(),
world,
resolver: makeResolver(100),
});
});
const result = await suite.runSequential();
return result.results;
}
// CLI entry point
if (import.meta.url === `file://${process.argv[1]}`) {
runFolBenchmarks().then((results) => {
console.log('\n=== FOL Benchmark Results ===\n');
for (const r of results) {
console.log(`${r.name}:`);
console.log(` mean: ${r.meanDurationMs.toFixed(2)}ms`);
console.log(` min: ${r.minDurationMs.toFixed(2)}ms`);
console.log(` max: ${r.maxDurationMs.toFixed(2)}ms`);
console.log(` memory: ${(r.meanMemoryDeltaBytes / 1024).toFixed(1)}KB`);
console.log();
}
});
}
@@ -0,0 +1,44 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-full-pipeline-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
console.log('=== Full Pipeline Benchmark ===\n')
for (const count of [1, 5, 10, 25, 50]) {
const ui = await imhotep(page)
for (let i = 0; i < count; i++) {
ui.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
}
const times = []
for (let run = 0; run < 5; run++) {
await ui.clearCache()
const start = performance.now()
await ui.checkAll()
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`${count} assertions (cold): ${mean.toFixed(1)}ms (5 runs)`)
}
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
+83
View File
@@ -0,0 +1,83 @@
// index.ts - Package exports for imhotep-bench
// Benchmarking and performance testing harness for Imhotep.
export {
type PerformanceBudget,
type PerformanceProfile,
type BudgetCheck,
DEFAULT_PROFILES,
checkBudget,
checkAllBudgets,
} from './profiles.js'
export {
type CacheKey,
type CacheEntry,
type Cache,
MemoryCache,
CompilationCache,
ExtractionCache,
makeCompilationCacheKey,
makeExtractionCacheKey,
} from './cache.js'
export {
type WorkerTask,
type WorkerPoolOptions,
type WorkerPoolMetrics,
WorkerPool,
type PromiseTask,
type PromisePoolOptions,
PromisePool,
} from './parallel.js'
export {
type BenchmarkOptions,
type BenchmarkResult,
type RunMeasurement,
type BenchmarkSuiteOptions,
type BenchmarkSuiteResult,
profileRun,
runBenchmark,
BenchmarkSuite,
} from './benchmark.js'
export {
type ProfileEntry,
ProfileSession,
type CompilationProfilerOptions,
type ExtractionProfilerOptions,
type SolvingProfilerOptions,
type WorldCreationProfilerOptions,
type FrameResolutionProfilerOptions,
profileCompilation,
profileExtraction,
profileSolving,
profileWorldCreation,
profileFrameResolution,
type RegressionResult,
detectRegression,
formatProfileReport,
} from './profile.js'
export {
type MemoCache,
LruMemoCache,
memoize,
type StringInterner,
FastStringInterner,
type ClauseDescriptor,
type ClauseResult,
type ClauseEvaluator,
batchEvaluateClauses,
type FrameResolver,
createLazyFrameResolver,
type FlatTable,
buildIdIndex,
buildSubjectIndex,
binarySearchUint32,
type TimedFunction,
timeFunction,
type BufferPool,
Float64BufferPool,
} from './optimize.js'
+373
View File
@@ -0,0 +1,373 @@
// optimize.ts - Optimization helpers for Imhotep hot paths
// Provides memoization, batching, string interning, and lazy evaluation utilities.
// All helpers use dependency injection (no optional imports or global state).
import { performance } from 'node:perf_hooks'
// ---------------------------------------------------------------------------
// Memoization — cache function results by serialized key
// ---------------------------------------------------------------------------
export interface MemoCache<K, V> {
get(key: K): V | undefined
set(key: K, value: V): void
has(key: K): boolean
clear(): void
}
/** Simple Map-backed memo cache with size limit and LRU eviction. */
export class LruMemoCache<K, V> implements MemoCache<K, V> {
private cache = new Map<K, V>()
constructor(private maxSize: number = 1024) {}
get(key: K): V | undefined {
const value = this.cache.get(key)
if (value !== undefined) {
// Touch entry for LRU ordering
this.cache.delete(key)
this.cache.set(key, value)
}
return value
}
set(key: K, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key)
} else if (this.cache.size >= this.maxSize) {
// Evict oldest (first inserted)
const firstKey = this.cache.keys().next().value
if (firstKey !== undefined) {
this.cache.delete(firstKey)
}
}
this.cache.set(key, value)
}
has(key: K): boolean {
return this.cache.has(key)
}
clear(): void {
this.cache.clear()
}
}
/**
* Memoize a pure function using an injected cache.
* The keyFn serializes arguments; the cache stores results.
*/
export function memoize<A extends unknown[], R>(
fn: (...args: A) => R,
keyFn: (...args: A) => string,
cache: MemoCache<string, R>
): (...args: A) => R {
return (...args: A): R => {
const key = keyFn(...args)
const cached = cache.get(key)
if (cached !== undefined) {
return cached
}
const result = fn(...args)
cache.set(key, result)
return result
}
}
// ---------------------------------------------------------------------------
// String interning — cache selector strings to avoid repeated hashing
// ---------------------------------------------------------------------------
export interface StringInterner {
intern(value: string): number
resolve(index: number): string
size(): number
}
/** Fast string interner backed by a Map for O(1) lookups. */
export class FastStringInterner implements StringInterner {
private indexByValue = new Map<string, number>()
private values: string[] = []
intern(value: string): number {
const existing = this.indexByValue.get(value)
if (existing !== undefined) {
return existing
}
const idx = this.values.length
this.values.push(value)
this.indexByValue.set(value, idx)
return idx
}
resolve(index: number): string {
return this.values[index] ?? ''
}
size(): number {
return this.values.length
}
/** Export the string table as a plain array (for world construction). */
exportTable(): string[] {
return this.values.slice()
}
}
// ---------------------------------------------------------------------------
// Batch clause evaluation — evaluate multiple clauses in a single pass
// ---------------------------------------------------------------------------
export interface ClauseDescriptor {
clauseId: string
clauseKind: string
version: number
}
export interface ClauseResult {
clauseId: string
status: string
truth: string
}
export type ClauseEvaluator = (clause: ClauseDescriptor) => ClauseResult
/**
* Batch-evaluate clauses by grouping them by kind.
* Returns results in the same order as input clauses.
* The evaluatorFactory is injected so callers can supply their own registry.
*/
export function batchEvaluateClauses(
clauses: ClauseDescriptor[],
evaluatorFactory: (kind: string, version: number) => ClauseEvaluator | undefined
): ClauseResult[] {
// Group clause indices by kind+version to minimize evaluator lookups
const groups = new Map<string, number[]>()
for (let i = 0; i < clauses.length; i++) {
const c = clauses[i]
const key = `${c.clauseKind}@v${c.version}`
const indices = groups.get(key)
if (indices) {
indices.push(i)
} else {
groups.set(key, [i])
}
}
const results: ClauseResult[] = new Array(clauses.length)
for (const [key, indices] of groups) {
const first = clauses[indices[0]]
const evaluator = evaluatorFactory(first.clauseKind, first.version)
if (!evaluator) {
// Fill missing evaluators with error results
for (const idx of indices) {
results[idx] = {
clauseId: clauses[idx].clauseId,
status: 'error',
truth: 'indeterminate',
}
}
continue
}
for (const idx of indices) {
results[idx] = evaluator(clauses[idx])
}
}
return results
}
// ---------------------------------------------------------------------------
// Lazy frame resolution — only resolve frames when needed
// ---------------------------------------------------------------------------
export interface FrameResolver {
resolve(frameId: number): unknown
invalidate(frameId: number): void
clear(): void
}
/**
* Lazy frame resolver that caches resolved transforms.
* The resolveFn is injected so callers can supply their own coordinate system.
*/
export function createLazyFrameResolver(
resolveFn: (frameId: number) => unknown,
cache: MemoCache<number, unknown>
): FrameResolver {
return {
resolve(frameId: number): unknown {
const cached = cache.get(frameId)
if (cached !== undefined) {
return cached
}
const result = resolveFn(frameId)
cache.set(frameId, result)
return result
},
invalidate(frameId: number): void {
// No-op for Map-based cache; real implementation may support deletion
void frameId
},
clear(): void {
cache.clear()
},
}
}
// ---------------------------------------------------------------------------
// Index pre-computation — build lookup maps during normalization
// ---------------------------------------------------------------------------
export interface FlatTable {
ids: Uint32Array
}
/**
* Build a bidirectional index for a flat id table.
* Returns a Map<id, index> for O(1) lookups.
*/
export function buildIdIndex(table: FlatTable): Map<number, number> {
const map = new Map<number, number>()
const ids = table.ids
for (let i = 0; i < ids.length; i++) {
map.set(ids[i], i)
}
return map
}
/**
* Build a multi-value index (one-to-many) from a subject-id column.
* Returns a Map<subjectId, index[]> for fast reverse lookups.
*/
export function buildSubjectIndex(subjectIdColumn: Uint32Array): Map<number, number[]> {
const map = new Map<number, number[]>()
for (let i = 0; i < subjectIdColumn.length; i++) {
const sid = subjectIdColumn[i]
const arr = map.get(sid)
if (arr) {
arr.push(i)
} else {
map.set(sid, [i])
}
}
return map
}
// ---------------------------------------------------------------------------
// Array scan replacement — binary search on sorted arrays
// ---------------------------------------------------------------------------
/**
* Binary search for a value in a sorted Uint32Array.
* Returns the index if found, otherwise -1.
* This replaces linear scans when data is pre-sorted.
*/
export function binarySearchUint32(arr: Uint32Array, value: number): number {
let lo = 0
let hi = arr.length - 1
while (lo <= hi) {
const mid = (lo + hi) >>> 1
const midVal = arr[mid]
if (midVal === value) {
return mid
} else if (midVal < value) {
lo = mid + 1
} else {
hi = mid - 1
}
}
return -1
}
// ---------------------------------------------------------------------------
// Performance timing decorator — wrap functions with measurement
// ---------------------------------------------------------------------------
export interface TimedFunction<T extends unknown[], R> {
(...args: T): R
readonly totalCalls: number
readonly totalDurationMs: number
readonly meanDurationMs: number
}
/**
* Wrap a function to measure its execution time.
* Returns a new function with attached statistics.
*/
export function timeFunction<T extends unknown[], R>(
fn: (...args: T) => R,
label?: string
): TimedFunction<T, R> {
let calls = 0
let duration = 0
const wrapped = (...args: T): R => {
const start = performance.now()
try {
return fn(...args)
} finally {
duration += performance.now() - start
calls++
}
}
Object.defineProperty(wrapped, 'totalCalls', {
get: () => calls,
})
Object.defineProperty(wrapped, 'totalDurationMs', {
get: () => duration,
})
Object.defineProperty(wrapped, 'meanDurationMs', {
get: () => (calls > 0 ? duration / calls : 0),
})
return wrapped as TimedFunction<T, R>
}
// ---------------------------------------------------------------------------
// Buffer pooling — reuse typed arrays to reduce GC pressure
// ---------------------------------------------------------------------------
export interface BufferPool<T> {
acquire(minSize: number): T
release(buf: T): void
}
/** Simple pool for Float64Array buffers with power-of-2 sizing. */
export class Float64BufferPool implements BufferPool<Float64Array> {
private pool: Float64Array[] = []
private maxPoolSize: number
constructor(maxPoolSize = 16) {
this.maxPoolSize = maxPoolSize
}
acquire(minSize: number): Float64Array {
const size = nextPowerOf2(minSize)
// Find a buffer that is large enough
for (let i = 0; i < this.pool.length; i++) {
if (this.pool[i].length >= size) {
return this.pool.splice(i, 1)[0]
}
}
return new Float64Array(size)
}
release(buf: Float64Array): void {
if (this.pool.length < this.maxPoolSize) {
this.pool.push(buf)
}
}
}
function nextPowerOf2(n: number): number {
if (n <= 1) return 1
let p = 1
while (p < n) {
p <<= 1
}
return p
}
+283
View File
@@ -0,0 +1,283 @@
// parallel.ts - Worker parallelism utilities for Imhotep bench harness
// Supports both worker_thread pools and lightweight Promise-based pools.
import { Worker } from 'node:worker_threads'
import { EventEmitter } from 'node:events'
export interface WorkerTask<TInput, TOutput> {
/** Unique task identifier */
id: string
/** Task payload */
input: TInput
/** Worker script path for worker_thread execution */
workerScript: string
}
export interface WorkerPoolOptions {
/** Maximum concurrent workers */
maxWorkers: number
/** Per-task timeout in milliseconds */
taskTimeoutMs: number
}
export interface WorkerPoolMetrics {
queued: number
running: number
completed: number
failed: number
}
interface PendingTask<TInput, TOutput> {
task: WorkerTask<TInput, TOutput>
resolve: (value: TOutput) => void
reject: (reason: Error) => void
timer?: ReturnType<typeof setTimeout>
}
/**
* WorkerPool manages a fixed number of worker threads.
* Tasks are queued and executed as workers become available.
*/
export class WorkerPool extends EventEmitter {
private queue: Array<PendingTask<unknown, unknown>> = []
private activeTasks = new Map<string, PendingTask<unknown, unknown>>()
private metrics = { queued: 0, running: 0, completed: 0, failed: 0 }
private shuttingDown = false
constructor(private options: WorkerPoolOptions) {
super()
}
/**
* Execute a task on an available worker.
* Returns a promise that resolves with the worker output.
*/
execute<TInput, TOutput>(
task: WorkerTask<TInput, TOutput>
): Promise<TOutput> {
if (this.shuttingDown) {
return Promise.reject(new Error('WorkerPool is shutting down'))
}
return new Promise<TOutput>((resolve, reject) => {
const pending: PendingTask<TInput, TOutput> = {
task,
resolve: resolve as (value: unknown) => void,
reject: reject as (reason: Error) => void,
}
this.queue.push(pending as PendingTask<unknown, unknown>)
this.metrics.queued++
this.emit('queued', task.id)
this._pump()
})
}
/** Execute multiple tasks in parallel, returning results in input order. */
async executeAll<TInput, TOutput>(
tasks: WorkerTask<TInput, TOutput>[]
): Promise<TOutput[]> {
const promises = tasks.map(t => this.execute(t))
return Promise.all(promises)
}
/** Current pool metrics snapshot */
getMetrics(): WorkerPoolMetrics {
return { ...this.metrics }
}
/** Terminate all workers and clear the queue */
async shutdown(): Promise<void> {
this.shuttingDown = true
for (const pending of this.queue) {
pending.reject(new Error('WorkerPool shutdown'))
}
this.queue = []
for (const [, pending] of this.activeTasks) {
if (pending.timer) clearTimeout(pending.timer)
pending.reject(new Error('WorkerPool shutdown'))
}
this.activeTasks.clear()
}
private _pump(): void {
if (this.queue.length === 0) return
if (this.activeTasks.size >= this.options.maxWorkers) return
const pending = this.queue.shift()!
this.metrics.queued--
this.metrics.running++
const taskId = pending.task.id
this.activeTasks.set(taskId, pending)
const timer = setTimeout(() => {
pending.reject(
new Error(
`Task ${pending.task.id} timed out after ${this.options.taskTimeoutMs}ms`
)
)
this.activeTasks.delete(taskId)
this.metrics.running--
this.metrics.failed++
this.emit('timeout', pending.task.id)
this._pump()
}, this.options.taskTimeoutMs)
pending.timer = timer
const worker = new Worker(pending.task.workerScript)
worker.once(
'message',
(message: {
success: boolean
result?: unknown
error?: string
}) => {
clearTimeout(timer)
this.activeTasks.delete(taskId)
this.metrics.running--
void worker.terminate()
if (message.success) {
pending.resolve(message.result)
this.metrics.completed++
this.emit('completed', pending.task.id)
} else {
pending.reject(new Error(message.error || 'Worker task failed'))
this.metrics.failed++
this.emit('failed', pending.task.id)
}
this._pump()
}
)
worker.once('error', (err: Error) => {
clearTimeout(timer)
this.activeTasks.delete(taskId)
this.metrics.running--
this.metrics.failed++
void worker.terminate()
pending.reject(err)
this.emit('failed', pending.task.id)
this._pump()
})
worker.postMessage({ id: pending.task.id, input: pending.task.input })
}
}
export interface PromiseTask<TInput, TOutput> {
id: string
input: TInput
fn: (input: TInput) => TOutput | Promise<TOutput>
}
export interface PromisePoolOptions {
maxConcurrency: number
taskTimeoutMs: number
}
/**
* PromisePool provides lightweight parallelism without worker threads.
* Runs async functions with controlled concurrency.
*/
export class PromisePool extends EventEmitter {
private queue: Array<{
task: PromiseTask<unknown, unknown>
resolve: (value: unknown) => void
reject: (reason: Error) => void
}> = []
private running = 0
private metrics = { queued: 0, completed: 0, failed: 0 }
private shuttingDown = false
constructor(private options: PromisePoolOptions) {
super()
}
execute<TInput, TOutput>(
task: PromiseTask<TInput, TOutput>
): Promise<TOutput> {
if (this.shuttingDown) {
return Promise.reject(new Error('PromisePool is shutting down'))
}
return new Promise<TOutput>((resolve, reject) => {
this.queue.push({
task: task as PromiseTask<unknown, unknown>,
resolve: resolve as (value: unknown) => void,
reject,
})
this.metrics.queued++
this.emit('queued', task.id)
this._pump()
})
}
async executeAll<TInput, TOutput>(
tasks: PromiseTask<TInput, TOutput>[]
): Promise<TOutput[]> {
const promises = tasks.map(t => this.execute(t))
return Promise.all(promises)
}
getMetrics(): WorkerPoolMetrics {
return {
queued: this.metrics.queued,
running: this.running,
completed: this.metrics.completed,
failed: this.metrics.failed,
}
}
async shutdown(): Promise<void> {
this.shuttingDown = true
for (const item of this.queue) {
item.reject(new Error('PromisePool shutdown'))
}
this.queue = []
}
private _pump(): void {
if (this.queue.length === 0) return
if (this.running >= this.options.maxConcurrency) return
const item = this.queue.shift()!
this.metrics.queued--
this.running++
const timer = setTimeout(() => {
this.running--
this.metrics.failed++
item.reject(
new Error(
`Task ${item.task.id} timed out after ${this.options.taskTimeoutMs}ms`
)
)
this.emit('timeout', item.task.id)
this._pump()
}, this.options.taskTimeoutMs)
Promise.resolve(item.task.fn(item.task.input))
.then(result => {
clearTimeout(timer)
this.running--
this.metrics.completed++
item.resolve(result)
this.emit('completed', item.task.id)
this._pump()
})
.catch(err => {
clearTimeout(timer)
this.running--
this.metrics.failed++
item.reject(err instanceof Error ? err : new Error(String(err)))
this.emit('failed', item.task.id)
this._pump()
})
}
}
@@ -0,0 +1,55 @@
import { performance } from 'node:perf_hooks'
// Profile dense spec parsing
const spec = `
in viewport:
'.header' leftOf '.content' gap 16px
'.sidebar' above '.main' gap 8px
'.footer' below '.main' gap 24px
'.button' atLeast 44px wide
'.card' inside '.container'
'.modal' centeredWithin '.viewport' tolerance 2px
`
async function profileParse() {
const { parseSpec } = await import('imhotep-dsl')
const times = []
for (let i = 0; i < 1000; i++) {
const start = performance.now()
parseSpec(spec)
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`Parse 6-clause spec: ${mean.toFixed(3)}ms (avg of 1000)`)
console.log(`Min: ${Math.min(...times).toFixed(3)}ms, Max: ${Math.max(...times).toFixed(3)}ms`)
}
async function profileSerialize() {
const { createEmptyWorld } = await import('imhotep-core')
const { serializeGeometryWorld } = await import('imhotep-core')
const world = createEmptyWorld('scene', 'snap')
// Populate with 1000 elements
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.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.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 + 50))
world.boxes.borderBottom = new Float64Array(Array.from({length: n}, () => 50))
const times = []
for (let i = 0; i < 100; i++) {
const start = performance.now()
serializeGeometryWorld(world)
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`Serialize 1000-element world: ${mean.toFixed(2)}ms (avg of 100)`)
console.log(`Min: ${Math.min(...times).toFixed(2)}ms, Max: ${Math.max(...times).toFixed(2)}ms`)
}
await profileParse()
await profileSerialize()
@@ -0,0 +1,279 @@
/**
* Comprehensive pooling performance benchmark.
*
* Measures REAL speedups from the new parallel pooling architecture
* using actual Playwright + Imhotep code paths.
*/
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { createRuntime } from '../../imhotep-playwright/src/runtime.js'
import { runEnumeratedProperty } from '../../imhotep-playwright/src/property-runner.js'
import { Semaphore } from '../../imhotep-playwright/src/semaphore.js'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-pool-bench-'))
// Create a simple fixture page
const html = `
<!DOCTYPE html>
<html><body>
<div id="app">
<button id="btn1">Button 1</button>
<button id="btn2">Button 2</button>
<div id="result"></div>
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'fixture.html'), html)
function median(values) {
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
function mean(values) {
return values.reduce((a, b) => a + b, 0) / values.length
}
async function benchmark() {
console.log('========================================')
console.log(' Imhotep Pooling Performance Benchmark')
console.log('========================================\n')
const browser = await chromium.launch()
// ============================================================
// Benchmark 1: Page Lifecycle — Direct vs Pool
// ============================================================
console.log('--- Benchmark 1: Page Creation vs Pool Borrow ---')
const runtime = await createRuntime(browser, {
poolOptions: { maxPagesPerContext: 8, warmPages: 4 }
})
// Warm up the pool first
const warmPages = []
for (let i = 0; i < 4; i++) {
warmPages.push(await runtime.createPage())
}
for (const p of warmPages) {
await runtime.releasePage(p)
}
const directTimes = []
const poolTimes = []
const iterations = 20
for (let i = 0; i < iterations; i++) {
// Direct: newContext → newPage → close
const ctx = await browser.newContext()
const t0 = performance.now()
const p = await ctx.newPage()
await p.goto(`file:${join(tmpdir2, 'fixture.html')}`)
await p.close()
directTimes.push(performance.now() - t0)
await ctx.close()
// Pool: borrow → goto → return
const t1 = performance.now()
const page = await runtime.createPage()
await page.playwrightPage.goto(`file:${join(tmpdir2, 'fixture.html')}`)
await runtime.releasePage(page)
poolTimes.push(performance.now() - t1)
}
const directMean = mean(directTimes)
const poolMean = mean(poolTimes)
console.log(` Direct create/nav/close (n=${iterations}):`)
console.log(` mean: ${directMean.toFixed(2)}ms median: ${median(directTimes).toFixed(2)}ms`)
console.log(` Pool borrow/nav/return (n=${iterations}):`)
console.log(` mean: ${poolMean.toFixed(2)}ms median: ${median(poolTimes).toFixed(2)}ms`)
console.log(` Speedup: ${(directMean / poolMean).toFixed(1)}x\n`)
// ============================================================
// Benchmark 2: Context Reuse
// ============================================================
console.log('--- Benchmark 2: Context Creation vs Context Pool ---')
const env = { viewport: { width: 1280, height: 720 } }
const envKey = JSON.stringify(env)
const noPoolCtxTimes = []
const poolCtxTimes = []
for (let i = 0; i < 10; i++) {
// No pool: create fresh context
const t0 = performance.now()
const ctx = await browser.newContext({ viewport: { width: 1280, height: 720 } })
const p = await ctx.newPage()
await p.goto(`file:${join(tmpdir2, 'fixture.html')}`)
await ctx.close()
noPoolCtxTimes.push(performance.now() - t0)
// Pool: acquire from pool (should reuse after first iteration)
const t1 = performance.now()
const ctx2 = await runtime.createContext(env)
const p2 = await runtime.createPage(ctx2)
await p2.playwrightPage.goto(`file:${join(tmpdir2, 'fixture.html')}`)
await runtime.releasePage(p2)
// Note: we don't close context, pool reuses it
poolCtxTimes.push(performance.now() - t1)
}
const noPoolCtxMean = mean(noPoolCtxTimes)
const poolCtxMean = mean(poolCtxTimes)
console.log(` New context each time (n=10):`)
console.log(` mean: ${noPoolCtxMean.toFixed(2)}ms median: ${median(noPoolCtxTimes).toFixed(2)}ms`)
console.log(` Pooled context (n=10, first=cold):`)
console.log(` mean: ${poolCtxMean.toFixed(2)}ms median: ${median(poolCtxTimes).toFixed(2)}ms`)
console.log(` Speedup: ${(noPoolCtxMean / poolCtxMean).toFixed(1)}x\n`)
// ============================================================
// Benchmark 3: Sequential vs Parallel Enumerated Properties
// ============================================================
console.log('--- Benchmark 3: Enumerated Property Runner ---')
// Create a simple mock renderer adapter that tracks timing
const mockAdapter = {
async mount(target, renderCase) {
const page = await runtime.createPage()
await page.playwrightPage.goto(`file:${join(tmpdir2, 'fixture.html')}`)
return {
caseId: renderCase.caseId,
world: { sceneId: renderCase.caseId },
page,
}
},
async unmount(mounted) {
await runtime.releasePage(mounted.page)
},
}
const mockFC = {
assert: async () => ({ passed: true, numRuns: 100 }),
record: () => ({}),
constantFrom: (...values) => values,
sample: () => [],
property: () => ({}),
asyncProperty: () => ({}),
}
const domain = {
mode: 'enumerated',
values: Array.from({ length: 20 }, (_, i) => ({ id: i })),
}
const predicate = async () => true
const deps = {
rendererAdapter: mockAdapter,
worldExtractor: { extract: async (mounted) => mounted.world },
fc: mockFC,
}
// Sequential (concurrency = 1)
const seqTimes = []
for (let run = 0; run < 3; run++) {
const t0 = performance.now()
await runEnumeratedProperty(
{ kind: 'fixture', fixtureId: 'test.html' },
domain,
predicate,
deps,
{ concurrency: 1, failFast: true }
)
seqTimes.push(performance.now() - t0)
}
// Parallel (concurrency = 4)
const par4Times = []
for (let run = 0; run < 3; run++) {
const t0 = performance.now()
await runEnumeratedProperty(
{ kind: 'fixture', fixtureId: 'test.html' },
domain,
predicate,
deps,
{ concurrency: 4, failFast: true }
)
par4Times.push(performance.now() - t0)
}
// Parallel (concurrency = 8)
const par8Times = []
for (let run = 0; run < 3; run++) {
const t0 = performance.now()
await runEnumeratedProperty(
{ kind: 'fixture', fixtureId: 'test.html' },
domain,
predicate,
deps,
{ concurrency: 8, failFast: true }
)
par8Times.push(performance.now() - t0)
}
const seqMean = mean(seqTimes)
const par4Mean = mean(par4Times)
const par8Mean = mean(par8Times)
console.log(` Sequential (concurrency=1, n=20 cases):`)
console.log(` mean: ${seqMean.toFixed(1)}ms`)
console.log(` Parallel (concurrency=4):`)
console.log(` mean: ${par4Mean.toFixed(1)}ms speedup: ${(seqMean / par4Mean).toFixed(1)}x`)
console.log(` Parallel (concurrency=8):`)
console.log(` mean: ${par8Mean.toFixed(1)}ms speedup: ${(seqMean / par8Mean).toFixed(1)}x`)
console.log(` Theoretical max (if perfectly parallel): ${(seqMean / 20).toFixed(1)}ms\n`)
// ============================================================
// Benchmark 4: Semaphore Overhead
// ============================================================
console.log('--- Benchmark 4: Semaphore Scheduling Overhead ---')
const sem = new Semaphore(4)
const taskCount = 100
const taskDuration = 5 // ms
// Sequential baseline
const seqSemStart = performance.now()
for (let i = 0; i < taskCount; i++) {
await new Promise(resolve => setTimeout(resolve, taskDuration))
}
const seqSemTime = performance.now() - seqSemStart
// Parallel with semaphore
const parSemStart = performance.now()
await Promise.all(
Array.from({ length: taskCount }, () =>
sem.run(() => new Promise(resolve => setTimeout(resolve, taskDuration)))
)
)
const parSemTime = performance.now() - parSemStart
console.log(` Sequential (${taskCount} tasks × ${taskDuration}ms): ${seqSemTime.toFixed(1)}ms`)
console.log(` Parallel (concurrency=4): ${parSemTime.toFixed(1)}ms`)
console.log(` Speedup: ${(seqSemTime / parSemTime).toFixed(1)}x`)
console.log(` Theoretical max: ${(taskCount * taskDuration / 4).toFixed(1)}ms\n`)
// ============================================================
// Cleanup
// ============================================================
await runtime.shutdown()
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
console.log('========================================')
console.log(' Benchmark Complete')
console.log('========================================')
}
benchmark().catch(err => {
console.error('Benchmark failed:', err)
process.exit(1)
})
@@ -0,0 +1,61 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-describe-profile-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `
<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
const session = await page.context().newCDPSession(page)
await session.send('DOM.enable')
// Get document root
const doc = await session.send('DOM.getDocument')
const rootId = doc.root.nodeId
// Profile querySelectorAll
const queryStart = performance.now()
const { nodeIds } = await session.send('DOM.querySelectorAll', { nodeId: rootId, selector: '.item' })
console.log(`querySelectorAll for 50 items: ${(performance.now() - queryStart).toFixed(1)}ms`)
// Profile sequential DOM.describeNode
const seqStart = performance.now()
for (const nodeId of nodeIds.slice(0, 10)) {
await session.send('DOM.describeNode', { nodeId })
}
console.log(`Sequential describeNode (10): ${(performance.now() - seqStart).toFixed(1)}ms`)
// Profile parallel DOM.describeNode
const parStart = performance.now()
await Promise.all(nodeIds.slice(0, 10).map(nodeId => session.send('DOM.describeNode', { nodeId })))
console.log(`Parallel describeNode (10): ${(performance.now() - parStart).toFixed(1)}ms`)
// Profile all 50 sequential
const seq50Start = performance.now()
for (const nodeId of nodeIds) {
await session.send('DOM.describeNode', { nodeId })
}
console.log(`Sequential describeNode (50): ${(performance.now() - seq50Start).toFixed(1)}ms`)
// Profile all 50 parallel
const par50Start = performance.now()
await Promise.all(nodeIds.map(nodeId => session.send('DOM.describeNode', { nodeId })))
console.log(`Parallel describeNode (50): ${(performance.now() - par50Start).toFixed(1)}ms`)
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,47 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-parallel-resolve-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
const { createSessionManager } = await import('imhotep-cdp')
const sm = createSessionManager(page)
const session = await sm.attach()
await session.send('DOM.enable')
const rootNodeId = await sm.getDocumentRootNodeId()
const selectors = Array.from({length: 50}, (_, i) => `[data-testid="item-${i}"]`)
// Sequential resolution
const seqStart = performance.now()
for (const sel of selectors) {
await session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: sel })
}
const seqDur = performance.now() - seqStart
console.log(`Sequential querySelectorAll (50): ${seqDur.toFixed(1)}ms`)
// Parallel resolution
const parStart = performance.now()
await Promise.all(selectors.map(sel => session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: sel })))
const parDur = performance.now() - parStart
console.log(`Parallel querySelectorAll (50): ${parDur.toFixed(1)}ms`)
console.log(`Speedup: ${(seqDur / parDur).toFixed(1)}x`)
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
+470
View File
@@ -0,0 +1,470 @@
// profile.ts - Profiling utilities for Imhotep hot paths
// Measures compilation, extraction, solving, world creation, and frame resolution.
// Injected dependencies: performance hooks, benchmark runner, budget checker.
import { performance } from 'node:perf_hooks'
import { runBenchmark, type BenchmarkOptions, type BenchmarkResult } from './benchmark.js'
import { type CompilationCache, type ExtractionCache } from './cache.js'
// ---------------------------------------------------------------------------
// Profile entry — single timed observation
// ---------------------------------------------------------------------------
export interface ProfileEntry {
phase: string
durationMs: number
memoryDeltaBytes: number
metadata?: Record<string, unknown>
}
// ---------------------------------------------------------------------------
// Profile session — collects multiple entries for a single benchmark run
// ---------------------------------------------------------------------------
export class ProfileSession {
private entries: ProfileEntry[] = []
private lastTime = 0
private lastMemory = 0
constructor(private sessionName: string) {
this.lastTime = performance.now()
this.lastMemory = process.memoryUsage().heapUsed
}
/**
* Record a named phase boundary.
* Duration is measured from the previous mark() or session start.
*/
mark(phase: string, metadata?: Record<string, unknown>): ProfileEntry {
const now = performance.now()
const memNow = process.memoryUsage().heapUsed
const entry: ProfileEntry = {
phase,
durationMs: now - this.lastTime,
memoryDeltaBytes: memNow - this.lastMemory,
metadata,
}
this.entries.push(entry)
this.lastTime = now
this.lastMemory = memNow
return entry
}
/**
* Return all recorded entries.
*/
getEntries(): ProfileEntry[] {
return this.entries.slice()
}
/**
* Return the total duration across all phases.
*/
totalDurationMs(): number {
return this.entries.reduce((sum, e) => sum + e.durationMs, 0)
}
/**
* Return the entry for a specific phase, or undefined.
*/
findPhase(phase: string): ProfileEntry | undefined {
return this.entries.find(e => e.phase === phase)
}
}
// ---------------------------------------------------------------------------
// Hot-path profilers — targeted wrappers around core operations
// ---------------------------------------------------------------------------
export interface CompilationProfilerOptions {
/** Number of assertions to compile */
assertionCount: number
/** Optional compilation cache injected by caller */
compilationCache?: CompilationCache<unknown>
/** Benchmark options (warmup runs, measurement runs, profile) */
benchmarkOptions?: BenchmarkOptions
}
export interface ExtractionProfilerOptions {
/** Number of subjects to extract */
subjectCount: number
/** Optional extraction cache injected by caller */
extractionCache?: ExtractionCache<unknown>
benchmarkOptions?: BenchmarkOptions
}
export interface SolvingProfilerOptions {
/** Number of clauses to evaluate */
clauseCount: number
benchmarkOptions?: BenchmarkOptions
}
export interface WorldCreationProfilerOptions {
/** Number of raw facts (subjects, boxes, etc.) to normalize */
factCount: number
benchmarkOptions?: BenchmarkOptions
}
export interface FrameResolutionProfilerOptions {
/** Number of frames to resolve */
frameCount: number
benchmarkOptions?: BenchmarkOptions
}
/**
* Profile compilation: time to compile N assertions to Execution IR.
* Returns a BenchmarkResult with mean/min/max/std-dev durations.
*/
export async function profileCompilation(
compileFn: (assertions: unknown[]) => unknown,
options: CompilationProfilerOptions
): Promise<BenchmarkResult> {
const { assertionCount, benchmarkOptions } = options
const assertions = generateMockAssertions(assertionCount)
return runBenchmark(
`compile-${assertionCount}`,
() => compileFn(assertions),
{
budgetName: 'compile',
...benchmarkOptions,
}
)
}
/**
* Profile extraction: time to extract geometry from a page.
*/
export async function profileExtraction(
extractFn: (subjects: unknown[]) => unknown,
options: ExtractionProfilerOptions
): Promise<BenchmarkResult> {
const { subjectCount, benchmarkOptions } = options
const subjects = generateMockSubjects(subjectCount)
return runBenchmark(
`extract-${subjectCount}`,
() => extractFn(subjects),
{
budgetName: 'extract',
...benchmarkOptions,
}
)
}
/**
* Profile solving: time to evaluate M clauses against a world.
*/
export async function profileSolving(
solveFn: (clauses: unknown[], world: unknown) => unknown,
options: SolvingProfilerOptions
): Promise<BenchmarkResult> {
const { clauseCount, benchmarkOptions } = options
const clauses = generateMockClauses(clauseCount)
const world = generateMockWorld()
return runBenchmark(
`solve-${clauseCount}`,
() => solveFn(clauses, world),
{
budgetName: 'solve',
...benchmarkOptions,
}
)
}
/**
* Profile world creation: time to normalize raw facts into a world.
*/
export async function profileWorldCreation(
normalizeFn: (raw: unknown) => unknown,
options: WorldCreationProfilerOptions
): Promise<BenchmarkResult> {
const { factCount, benchmarkOptions } = options
const raw = generateMockRawExtraction(factCount)
return runBenchmark(
`world-create-${factCount}`,
() => normalizeFn(raw),
{
budgetName: 'extract',
...benchmarkOptions,
}
)
}
/**
* Profile frame resolution: time to resolve transforms and coordinates.
*/
export async function profileFrameResolution(
resolveFn: (frames: unknown[]) => unknown,
options: FrameResolutionProfilerOptions
): Promise<BenchmarkResult> {
const { frameCount, benchmarkOptions } = options
const frames = generateMockFrames(frameCount)
return runBenchmark(
`frame-resolve-${frameCount}`,
() => resolveFn(frames),
{
budgetName: 'solve',
...benchmarkOptions,
}
)
}
// ---------------------------------------------------------------------------
// Mock data generators — deterministic, lightweight
// ---------------------------------------------------------------------------
function generateMockAssertions(n: number): unknown[] {
const assertions: unknown[] = []
for (let i = 0; i < n; i++) {
assertions.push({
type: 'RelationAssertion',
relation: i % 2 === 0 ? 'leftOf' : 'above',
subject: { type: 'SelectorRef', value: `.s-${i}` },
reference: { type: 'SelectorRef', value: `.r-${i}` },
options: { type: 'RelationOptions' },
})
}
return assertions
}
function generateMockSubjects(n: number): unknown[] {
const subjects: unknown[] = []
for (let i = 0; i < n; i++) {
subjects.push({
id: i,
selector: `.subj-${i}`,
kind: 'element',
})
}
return subjects
}
function generateMockClauses(n: number): unknown[] {
const clauses: unknown[] = []
for (let i = 0; i < n; i++) {
clauses.push({
clauseId: `clause-${i}`,
clauseKind: i % 2 === 0 ? 'relation.leftOf' : 'relation.above',
version: 1,
subjectRef: i,
referenceRef: i + 1,
})
}
return clauses
}
function generateMockWorld(): unknown {
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),
domNodeId: new Uint32Array(0),
subjectKind: new Uint16Array(0),
primaryBoxId: new Uint32Array(0),
firstFragmentId: new Uint32Array(0),
fragmentCount: new Uint16Array(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),
},
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),
},
}
}
function generateMockRawExtraction(factCount: number): unknown {
const subjects: unknown[] = []
const boxes: unknown[] = []
for (let i = 0; i < factCount; i++) {
subjects.push({
id: i,
domNodeId: i,
kind: 'element',
primaryBoxId: i,
firstFragmentId: 0,
fragmentCount: 0,
firstTextRunId: 0,
textRunCount: 0,
})
boxes.push({
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,
})
}
return {
sceneId: 'scene_1',
snapshotId: 'snapshot_1',
env: {
viewportWidth: 1280,
viewportHeight: 720,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: 'none',
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
source: {
url: 'http://localhost/',
browserName: 'chromium',
browserVersion: '120',
engine: 'blink',
extractedAt: Date.now(),
},
subjects,
dom: [],
frames: [],
rects: [],
boxes,
fragments: [],
transforms: [],
styles: [],
text: [],
topology: {
containingBlockOf: [],
nearestPositionedAncestorOf: [],
scrollContainerOf: [],
stackingContextOf: [],
formattingContextOf: [],
clippingRootOf: [],
paintOrderBucket: [],
paintOrderIndex: [],
},
scroll: [],
clipping: [],
paint: [],
visibility: [],
provenance: [],
confidence: [],
}
}
function generateMockFrames(n: number): unknown[] {
const frames: unknown[] = []
for (let i = 0; i < n; i++) {
frames.push({
id: i,
kind: 'viewport',
ownerSubjectId: i,
parentFrameId: -1,
originX: 0,
originY: 0,
matrix: i % 3 === 0 ? [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] : undefined,
clipRectId: 0,
scrollContainerId: 0,
writingMode: 'horizontal-tb',
})
}
return frames
}
// ---------------------------------------------------------------------------
// Comparison helpers — detect regressions between two benchmark results
// ---------------------------------------------------------------------------
export interface RegressionResult {
phase: string
baselineMs: number
currentMs: number
deltaMs: number
deltaPercent: number
regressed: boolean
}
/**
* Compare two benchmark results and flag regressions.
* A regression is defined as current being > thresholdPercent slower than baseline.
*/
export function detectRegression(
baseline: BenchmarkResult,
current: BenchmarkResult,
thresholdPercent = 10
): RegressionResult {
const deltaMs = current.meanDurationMs - baseline.meanDurationMs
const deltaPercent = baseline.meanDurationMs > 0
? (deltaMs / baseline.meanDurationMs) * 100
: 0
return {
phase: current.name,
baselineMs: baseline.meanDurationMs,
currentMs: current.meanDurationMs,
deltaMs,
deltaPercent,
regressed: deltaPercent > thresholdPercent,
}
}
/**
* Format a profile report as plain text (no markdown per user preference).
*/
export function formatProfileReport(results: BenchmarkResult[]): string {
const lines: string[] = []
lines.push('Performance Profile Report')
lines.push('='.repeat(40))
for (const r of results) {
lines.push(`\nBenchmark: ${r.name}`)
lines.push(` Mean: ${r.meanDurationMs.toFixed(3)} ms`)
lines.push(` Min: ${r.minDurationMs.toFixed(3)} ms`)
lines.push(` Max: ${r.maxDurationMs.toFixed(3)} ms`)
lines.push(` StdDev: ${r.stdDevDurationMs.toFixed(3)} ms`)
lines.push(` Memory: ${r.meanMemoryDeltaBytes.toFixed(0)} bytes`)
if (r.budgetCheck) {
lines.push(` Budget: ${r.budgetCheck.budgetName} ${r.budgetCheck.passed ? 'PASS' : 'FAIL'}`)
}
}
lines.push('\n' + '='.repeat(40))
return lines.join('\n')
}
+113
View File
@@ -0,0 +1,113 @@
// profiles.ts - Performance profiles and budgets for Imhotep benchmark harness
// Defines budgets for compilation, extraction, solving, and end-to-end runs.
export interface PerformanceBudget {
/** Human-readable budget name */
name: string
/** Maximum allowed duration in milliseconds */
maxDurationMs: number
/** Optional maximum memory usage in bytes */
maxMemoryBytes?: number
}
export interface PerformanceProfile {
/** Profile identifier */
name: string
/** Budgets active in this profile */
budgets: PerformanceBudget[]
}
/** Budget presets keyed by profile name */
export const DEFAULT_PROFILES: Record<string, PerformanceProfile> = {
dev: {
name: 'dev',
budgets: [
{ name: 'compile', maxDurationMs: 50 },
{ name: 'extract', maxDurationMs: 200 },
{ name: 'solve', maxDurationMs: 100 },
{ name: 'e2e', maxDurationMs: 1000 },
],
},
ci: {
name: 'ci',
budgets: [
{ name: 'compile', maxDurationMs: 30 },
{ name: 'extract', maxDurationMs: 150 },
{ name: 'solve', maxDurationMs: 80 },
{ name: 'e2e', maxDurationMs: 800 },
],
},
benchmark: {
name: 'benchmark',
budgets: [
{ name: 'compile', maxDurationMs: 20 },
{ name: 'extract', maxDurationMs: 100 },
{ name: 'solve', maxDurationMs: 50 },
{ name: 'e2e', maxDurationMs: 500 },
],
},
}
/** Result of a single budget check */
export interface BudgetCheck {
budgetName: string
passed: boolean
actualDurationMs: number
actualMemoryBytes?: number
maxDurationMs: number
maxMemoryBytes?: number
}
/**
* Check whether a measurement fits within a budget.
* Returns a BudgetCheck with the pass/fail result.
*/
export function checkBudget(
budgetName: string,
actualDurationMs: number,
actualMemoryBytes?: number,
profile: PerformanceProfile = DEFAULT_PROFILES.dev
): BudgetCheck {
const budget = profile.budgets.find(b => b.name === budgetName)
if (!budget) {
return {
budgetName,
passed: true,
actualDurationMs,
actualMemoryBytes,
maxDurationMs: Infinity,
maxMemoryBytes: undefined,
}
}
const passedDuration = actualDurationMs <= budget.maxDurationMs
const passedMemory =
budget.maxMemoryBytes === undefined ||
actualMemoryBytes === undefined ||
actualMemoryBytes <= budget.maxMemoryBytes
return {
budgetName,
passed: passedDuration && passedMemory,
actualDurationMs,
actualMemoryBytes,
maxDurationMs: budget.maxDurationMs,
maxMemoryBytes: budget.maxMemoryBytes,
}
}
/**
* Check all budgets in a profile against a map of measurements.
*/
export function checkAllBudgets(
measurements: Record<string, { durationMs: number; memoryBytes?: number }>,
profile: PerformanceProfile = DEFAULT_PROFILES.dev
): BudgetCheck[] {
const results: BudgetCheck[] = []
for (const [name, measurement] of Object.entries(measurements)) {
results.push(
checkBudget(name, measurement.durationMs, measurement.memoryBytes, profile)
)
}
return results
}
@@ -0,0 +1,144 @@
// property-benchmark.ts - Benchmark sampled property runs.
// Measures enumerated combinations, fast-check generated runs, and shrink performance.
import {
runBenchmark,
BenchmarkSuite,
type BenchmarkResult,
} from './benchmark.js';
import fc from 'fast-check';
// ---------------------------------------------------------------------------
// Enumerated prop combinations
// ---------------------------------------------------------------------------
interface ButtonProps {
size: 'sm' | 'md' | 'lg';
disabled: boolean;
label: string;
}
function* enumerateProps(count: number): Generator<ButtonProps> {
const sizes: Array<'sm' | 'md' | 'lg'> = ['sm', 'md', 'lg'];
const disableds = [false, true];
const labels = ['OK', 'Cancel', 'Submit', 'Delete'];
let n = 0;
for (const size of sizes) {
for (const disabled of disableds) {
for (const label of labels) {
if (n >= count) return;
yield { size, disabled, label };
n++;
}
}
}
}
function generateEnumeratedCases(count: number): ButtonProps[] {
return Array.from(enumerateProps(count));
}
// ---------------------------------------------------------------------------
// Fast-check arbitrary for ButtonProps
// ---------------------------------------------------------------------------
const buttonPropsArb = fc.record<ButtonProps>({
size: fc.constantFrom('sm', 'md', 'lg'),
disabled: fc.boolean(),
label: fc.string({ minLength: 1, maxLength: 20 }),
});
// ---------------------------------------------------------------------------
// Property assertion (simulated layout check)
// ---------------------------------------------------------------------------
function assertButtonLayout(props: ButtonProps): boolean {
// Simulated: buttons are always at least 44px wide regardless of props
const minWidths = { sm: 60, md: 80, lg: 100 };
return minWidths[props.size] >= 44;
}
// ---------------------------------------------------------------------------
// Benchmarks
// ---------------------------------------------------------------------------
export async function runPropertyBenchmarks(): Promise<BenchmarkResult[]> {
const suite = new BenchmarkSuite('property-runs', {
defaults: { warmupRuns: 1, measurementRuns: 5 }
});
// 100 enumerated prop combinations
suite.add('enumerated-100-combinations', () => {
const cases = generateEnumeratedCases(100);
for (const props of cases) {
assertButtonLayout(props);
}
});
// 100 fast-check generated runs
suite.add('sampled-100-fc-runs', () => {
fc.assert(
fc.property(buttonPropsArb, (props) => {
return assertButtonLayout(props);
}),
{ numRuns: 100, seed: 42 }
);
});
// Shrink performance: how many steps to minimal counterexample
suite.add('shrink-to-minimal-counterexample', () => {
let shrinkSteps = 0;
try {
fc.assert(
fc.property(
fc.record({
size: fc.constantFrom('sm', 'md', 'lg'),
disabled: fc.boolean(),
label: fc.string({ minLength: 1, maxLength: 40 }),
}),
(props) => {
// Artificial failure: any label longer than 30 chars fails
return props.label.length <= 30;
}
),
{
numRuns: 200,
seed: 123,
verbose: 0,
}
);
} catch (e: any) {
// Fast-check errors contain counterexample info
if (e.counterExamplePath) {
// Path depth approximates shrink steps
shrinkSteps = e.counterExamplePath.split(':').length;
}
}
return shrinkSteps;
});
const result = await suite.runSequential();
return result.results;
}
// CLI entry point
if (import.meta.url === `file://${process.argv[1]}`) {
runPropertyBenchmarks().then((results) => {
console.log('\n=== Property Benchmark Results ===\n');
for (const r of results) {
console.log(`${r.name}:`);
console.log(` mean: ${r.meanDurationMs.toFixed(2)}ms`);
console.log(` min: ${r.minDurationMs.toFixed(2)}ms`);
console.log(` max: ${r.maxDurationMs.toFixed(2)}ms`);
console.log(` memory: ${(r.meanMemoryDeltaBytes / 1024).toFixed(1)}KB`);
if (r.name === 'shrink-to-minimal-counterexample') {
// Report last run result as shrink steps
const lastRun = r.runs[r.runs.length - 1];
console.log(` shrink steps (last run): ${lastRun ? (lastRun as any).result || 'N/A' : 'N/A'}`);
}
console.log();
}
});
}
@@ -0,0 +1,169 @@
// public-api-benchmark.ts - Micro-benchmark for the public relation evaluation path.
// Measures the simplified vertical slice: world construction + clause evaluation.
// Target: under 100ms for a simple two-element assertion.
import { performance } from 'node:perf_hooks'
import { runBenchmark, type BenchmarkResult } from './benchmark.js'
import { evaluateLeftOf } from 'imhotep-solver'
import type { GeometryWorld } from 'imhotep-solver'
// ---------------------------------------------------------------------------
// Minimal world builder (mirrors the path in public.ts)
// ---------------------------------------------------------------------------
function buildTwoElementWorld(gapPx: number): GeometryWorld {
const s = { x: 0, y: 0, width: 50, height: 50 }
const r = { x: 50 + gapPx, y: 0, width: 50, height: 50 }
return {
sceneId: 'bench',
snapshotId: 'snap',
env: {
viewportWidth: 800,
viewportHeight: 600,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: [0, 1],
domNodeId: [0, 1],
subjectKind: [0, 0],
primaryBoxId: [0, 1],
firstFragmentId: [0, 0],
fragmentCount: [0, 0],
},
dom: {
nodeId: [],
parentNodeId: [],
childCount: [],
tagNameStringId: [],
},
boxes: {
boxId: [0, 1],
subjectId: [0, 1],
frameId: [0, 0],
borderLeft: [s.x, r.x],
borderTop: [s.y, r.y],
borderRight: [s.x + s.width, r.x + r.width],
borderBottom: [s.y + s.height, r.y + r.height],
paddingLeft: [0, 0],
paddingTop: [0, 0],
paddingRight: [0, 0],
paddingBottom: [0, 0],
contentLeft: [s.x, r.x],
contentTop: [s.y, r.y],
contentRight: [s.x + s.width, r.x + r.width],
contentBottom: [s.y + s.height, r.y + r.height],
},
visualBoxes: {
boxId: [0, 1],
subjectId: [0, 1],
frameId: [0, 0],
borderLeft: [s.x, r.x],
borderTop: [s.y, r.y],
borderRight: [s.x + s.width, r.x + r.width],
borderBottom: [s.y + s.height, r.y + r.height],
paddingLeft: [0, 0],
paddingTop: [0, 0],
paddingRight: [0, 0],
paddingBottom: [0, 0],
contentLeft: [s.x, r.x],
contentTop: [s.y, r.y],
contentRight: [s.x + s.width, r.x + r.width],
contentBottom: [s.y + s.height, r.y + r.height],
},
transforms: {
transformId: [],
subjectId: [],
matrixStart: [],
matrixLength: [],
originX: [],
originY: [],
},
matrices: {
values: [],
},
rects: {
rectId: [0, 1],
left: [s.x, r.x],
top: [s.y, r.y],
right: [s.x + s.width, r.x + r.width],
bottom: [s.y + s.height, r.y + r.height],
},
topology: {
containingBlockOf: [],
nearestPositionedAncestorOf: [],
scrollContainerOf: [],
stackingContextOf: [],
formattingContextOf: [],
clippingRootOf: [],
paintOrderBucket: [],
paintOrderIndex: [],
},
scroll: {
containerId: [],
scrollLeft: [],
scrollTop: [],
scrollWidth: [],
scrollHeight: [],
clientWidth: [],
clientHeight: [],
},
clipping: {
clipNodeId: [],
subjectId: [],
clipKind: [],
clipLeft: [],
clipTop: [],
clipRight: [],
clipBottom: [],
parentClipNodeId: [],
},
visibility: {
subjectId: [],
isRendered: [],
isVisible: [],
visibleArea: [],
clippedArea: [],
},
}
}
// ---------------------------------------------------------------------------
// Benchmark entry point
// ---------------------------------------------------------------------------
export async function runPublicApiBenchmarks(): Promise<BenchmarkResult[]> {
const results: BenchmarkResult[] = []
// Benchmark: two-element leftOf evaluation with 10px gap and minGap: 8
const world = buildTwoElementWorld(10)
const clause = {
clauseId: 'bench_leftof',
clauseKind: 'relation.leftOf',
version: 1,
subjectRef: 0,
referenceRef: 1,
bounds: { minGap: 8 },
}
const r = await runBenchmark(
'public-api-leftOf-2element',
() => {
evaluateLeftOf(world, clause)
},
{
warmupRuns: 100,
measurementRuns: 1000,
}
)
results.push(r)
return results
}
@@ -0,0 +1,122 @@
// render-target-benchmark.ts - Benchmark renderer adapter mounting.
// Measures scene target creation and resolution overhead.
import {
runBenchmark,
BenchmarkSuite,
type BenchmarkResult,
} from './benchmark.js';
import {
pageTarget,
storybookStoryTarget,
reactComponentTarget,
vueComponentTarget,
fixtureTarget,
matchSceneTarget,
type SceneTarget,
} from 'imhotep-core';
// ---------------------------------------------------------------------------
// Simulated adapter resolution
// ---------------------------------------------------------------------------
function resolveTargetUrl(target: SceneTarget): string {
return matchSceneTarget(target, {
page: (url) => url,
'playwright-page': (pageRef, url) => url || `playwright://${pageRef}`,
'storybook-story': (storyId, storybookUrl) => `${storybookUrl}/iframe.html?id=${storyId}`,
'react-component': (rendererId, componentId) =>
`component://react/${rendererId}/${componentId}`,
'vue-component': (rendererId, componentId) =>
`component://vue/${rendererId}/${componentId}`,
'custom-renderer': (rendererId, targetId) =>
`component://custom/${rendererId}/${targetId}`,
fixture: (fixtureId) => `file:///fixtures/${fixtureId}.html`,
});
}
function mountComponent(target: SceneTarget): { targetId: string; url: string } {
const url = resolveTargetUrl(target);
const targetId = `mount_${Math.random().toString(36).slice(2, 8)}`;
// Simulate mount overhead
const overhead = Array.from({ length: 100 }, (_, i) => i * 2).reduce((a, b) => a + b, 0);
return { targetId, url };
}
// ---------------------------------------------------------------------------
// Benchmarks
// ---------------------------------------------------------------------------
export async function runRenderTargetBenchmarks(): Promise<BenchmarkResult[]> {
const suite = new BenchmarkSuite('render-target-mount', {
defaults: { warmupRuns: 2, measurementRuns: 5 }
});
// React mount time
suite.add('react-mount', () => {
const target = reactComponentTarget('react-dom', 'Button');
mountComponent(target);
});
// Vue mount time
suite.add('vue-mount', () => {
const target = vueComponentTarget('vue-dom', 'Button');
mountComponent(target);
});
// Storybook story load time
suite.add('storybook-load', () => {
const target = storybookStoryTarget('button--primary', 'http://localhost:6006');
mountComponent(target);
});
// Page target resolution
suite.add('page-target-resolve', () => {
const target = pageTarget('https://example.com/page');
resolveTargetUrl(target);
});
// Fixture target resolution
suite.add('fixture-target-resolve', () => {
const target = fixtureTarget('property-render-react');
resolveTargetUrl(target);
});
// Mixed renderer batch
suite.add('mixed-renderer-batch-10', () => {
const targets: SceneTarget[] = [
reactComponentTarget('react-dom', 'Button'),
vueComponentTarget('vue-dom', 'Button'),
storybookStoryTarget('button--primary', 'http://localhost:6006'),
pageTarget('https://example.com/page'),
fixtureTarget('property-render-react'),
reactComponentTarget('react-dom', 'Card'),
vueComponentTarget('vue-dom', 'Card'),
storybookStoryTarget('card--default', 'http://localhost:6006'),
pageTarget('https://example.com/other'),
fixtureTarget('property-render-vue'),
];
for (const t of targets) {
mountComponent(t);
}
});
const result = await suite.runSequential();
return result.results;
}
// CLI entry point
if (import.meta.url === `file://${process.argv[1]}`) {
runRenderTargetBenchmarks().then((results) => {
console.log('\n=== Render Target Benchmark Results ===\n');
for (const r of results) {
console.log(`${r.name}:`);
console.log(` mean: ${r.meanDurationMs.toFixed(3)}ms`);
console.log(` min: ${r.minDurationMs.toFixed(3)}ms`);
console.log(` max: ${r.maxDurationMs.toFixed(3)}ms`);
console.log(` memory: ${r.meanMemoryDeltaBytes.toFixed(0)}B`);
console.log();
}
});
}
+169
View File
@@ -0,0 +1,169 @@
import { performance } from 'node:perf_hooks'
import {
evaluateLogic,
registerDefaultPredicates,
} from 'imhotep-solver'
import { adaptCanonicalWorldToSolver } from 'imhotep-core'
registerDefaultPredicates()
function buildWorld(n) {
const canonical = {
sceneId: 's',
snapshotId: 'sn',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: new Uint32Array(Array.from({length: n}, (_, i) => i)),
domNodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
subjectKind: new Uint8Array(Array.from({length: n}, () => 1)),
primaryBoxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
firstFragmentId: new Uint32Array(Array.from({length: n}, () => 0)),
fragmentCount: new Uint8Array(Array.from({length: n}, () => 1)),
},
boxes: {
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
},
visualBoxes: {
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
},
dom: {
nodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
parentNodeId: new Uint32Array(Array.from({length: n}, () => 1)),
childCount: new Uint8Array(Array.from({length: n}, () => 0)),
tagNameStringId: new Uint16Array(Array.from({length: n}, () => 0)),
},
transforms: { transformId: new Uint32Array(0), subjectId: new Uint32Array(0), matrixStart: new Uint32Array(0), matrixLength: new Uint32Array(0), originX: new Float64Array(0), originY: new Float64Array(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) },
topology: {
containingBlockOf: new Uint32Array(Array.from({length: n}, () => 0)),
nearestPositionedAncestorOf: new Uint32Array(Array.from({length: n}, () => 0)),
scrollContainerOf: new Uint32Array(Array.from({length: n}, () => 0)),
stackingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
formattingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
clippingRootOf: new Uint32Array(Array.from({length: n}, () => 0)),
paintOrderBucket: new Uint8Array(Array.from({length: n}, () => 0)),
paintOrderIndex: new Uint32Array(Array.from({length: n}, (_, i) => i)),
},
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) },
visibility: { subjectId: new Uint32Array(0), isRendered: new Uint8Array(0), isVisible: new Uint8Array(0), visibleArea: new Float64Array(0), clippedArea: new Float64Array(0) },
}
return adaptCanonicalWorldToSolver(canonical)
}
class SimpleResolver {
constructor() {
this.domains = new Map()
}
register(selector, ids) {
this.domains.set(selector, {
domainId: `dom_${selector}`,
subjectIds: new Uint32Array(ids),
provenance: `elements(${selector})`,
closed: true,
})
}
resolve(domain) {
return this.domains.get(domain.selector ?? domain.domain)
}
}
console.log('=== Evaluation With/Without Trace ===\n')
for (const n of [10, 50, 100]) {
const world = buildWorld(n)
const resolver = new SimpleResolver()
resolver.register('.a', Array.from({length: n}, (_, i) => i))
resolver.register('.b', Array.from({length: n}, (_, i) => i))
const formula = {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['$subject'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.a' }
}],
body: {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['$reference'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.b' }
}],
body: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'leftOf',
args: [
{ type: 'VariableRef', name: '$subject' },
{ type: 'VariableRef', name: '$reference' }
]
}
}
}
const timesWithTrace = []
const timesWithoutTrace = []
for (let i = 0; i < 100; i++) {
const start1 = performance.now()
evaluateLogic({ formula, world, resolver, options: { trace: true } })
timesWithTrace.push(performance.now() - start1)
const start2 = performance.now()
evaluateLogic({ formula, world, resolver, options: { trace: false } })
timesWithoutTrace.push(performance.now() - start2)
}
const mean = (arr) => arr.reduce((a,b) => a+b, 0) / arr.length
const withTrace = mean(timesWithTrace)
const withoutTrace = mean(timesWithoutTrace)
console.log(`${n}×${n} pairs:`)
console.log(` With trace: ${withTrace.toFixed(2)}ms`)
console.log(` Without trace: ${withoutTrace.toFixed(2)}ms`)
console.log(` Savings: ${((1 - withoutTrace/withTrace) * 100).toFixed(0)}%`)
}
@@ -0,0 +1,42 @@
import { performance } from 'node:perf_hooks'
const sizes = [1000, 5000, 10000, 50000]
console.log('=== TypedArray to Array Conversion Benchmark ===\n')
for (const size of sizes) {
const arr = new Float64Array(Array.from({length: size}, (_, i) => i))
const times = {
arrayFrom: [],
spread: [],
slice: [],
bufferSlice: [],
}
for (let i = 0; i < 100; i++) {
let start = performance.now()
Array.from(arr)
times.arrayFrom.push(performance.now() - start)
start = performance.now()
;[...arr]
times.spread.push(performance.now() - start)
start = performance.now()
Array.prototype.slice.call(arr)
times.slice.push(performance.now() - start)
start = performance.now()
const buffer = arr.buffer.slice(arr.byteOffset, arr.byteOffset + arr.byteLength)
new Float64Array(buffer)
times.bufferSlice.push(performance.now() - start)
}
const mean = (arr) => arr.reduce((a,b) => a+b, 0) / arr.length
console.log(`${size} elements:`)
console.log(` Array.from: ${mean(times.arrayFrom).toFixed(3)}ms`)
console.log(` [...spread]: ${mean(times.spread).toFixed(3)}ms`)
console.log(` slice.call: ${mean(times.slice).toFixed(3)}ms`)
console.log(` buffer.slice: ${mean(times.bufferSlice).toFixed(3)}ms`)
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": false,
"paths": {},
"noEmitOnError": false
},
"include": [
"src/**/*"
]
}