79 lines
6.1 KiB
TypeScript
79 lines
6.1 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { writeFile } from 'node:fs/promises'
|
|
import { performance } from 'node:perf_hooks'
|
|
import { createSketch, solveSketch, type SketchConstraint, type SketchDiagnostic, type SketchGeometry, type SketchSolverStatus } from '../src/facade/sketcher'
|
|
|
|
type SolverFuzzCase = {
|
|
name: string
|
|
geometry: SketchGeometry[]
|
|
constraints: SketchConstraint[]
|
|
expectedStatus?: SketchSolverStatus
|
|
expectedCode?: SketchDiagnostic['code']
|
|
}
|
|
|
|
let state = 0xa5a5f00d
|
|
const random = () => {
|
|
state ^= state << 13
|
|
state ^= state >>> 17
|
|
state ^= state << 5
|
|
return state >>> 0
|
|
}
|
|
const value = () => (random() % 100_000) / 1000
|
|
const line = (id: string, offset = value()): Extract<SketchGeometry, { type: 'line' }> => ({ id, type: 'line', start: { x: offset, y: value() }, end: { x: offset + 1 + value(), y: value() } })
|
|
|
|
const createCase = (index: number): SolverFuzzCase => {
|
|
const first = line('first')
|
|
const second = line('second')
|
|
switch (index % 10) {
|
|
case 0: return { name: 'horizontal', geometry: [first], constraints: [{ id: 'horizontal', type: 'horizontal', geometryId: 'first' }], expectedStatus: 'under-constrained' }
|
|
case 1: return { name: 'parallel', geometry: [first, second], constraints: [{ id: 'parallel', type: 'parallel', firstGeometryId: 'first', secondGeometryId: 'second' }], expectedStatus: 'under-constrained' }
|
|
case 2: return { name: 'unknown-geometry', geometry: [first], constraints: [{ id: 'missing', type: 'vertical', geometryId: 'absent' }], expectedStatus: 'invalid', expectedCode: 'UNKNOWN_GEOMETRY' }
|
|
case 3: return { name: 'invalid-dimension', geometry: [first], constraints: [{ id: 'distance', type: 'distance', first: { geometryId: 'first', point: 'start' }, second: { geometryId: 'first', point: 'end' }, value: Number.NaN }], expectedStatus: 'invalid', expectedCode: 'INVALID_VALUE' }
|
|
case 4: return { name: 'redundant', geometry: [first], constraints: [{ id: 'h1', type: 'horizontal', geometryId: 'first' }, { id: 'h2', type: 'horizontal', geometryId: 'first' }], expectedStatus: 'under-constrained', expectedCode: 'REDUNDANT_CONSTRAINT' }
|
|
case 5: return { name: 'reference-distance', geometry: [first], constraints: [{ id: 'reference', type: 'distanceX', first: { geometryId: 'first', point: 'start' }, second: { geometryId: 'first', point: 'end' }, value: value(), driving: false }], expectedStatus: 'under-constrained' }
|
|
case 6: return { name: 'line-circle-tangent', geometry: [first, { id: 'circle', type: 'circle', center: { x: value(), y: value() }, radius: 1 + value() }], constraints: [{ id: 'tangent', type: 'tangent', firstGeometryId: 'first', secondGeometryId: 'circle' }], expectedStatus: 'under-constrained' }
|
|
case 7: return { name: 'unsupported-bspline-weight', geometry: [{ id: 'curve', type: 'bspline', degree: 2, controlPoints: [{ x: 0, y: 0 }, { x: 1, y: value() }, { x: 2, y: 0 }], weights: [1, 1, 1] }], constraints: [{ id: 'weight', type: 'weight', geometryId: 'curve', controlPointIndex: 1, value: 2 }], expectedStatus: 'invalid', expectedCode: 'UNSUPPORTED_CONSTRAINT' }
|
|
case 8: return { name: 'unknown-point', geometry: [first], constraints: [{ id: 'bad-point', type: 'coincident', first: { geometryId: 'first', point: 'center' }, second: { geometryId: 'first', point: 'end' } }], expectedStatus: 'invalid', expectedCode: 'UNKNOWN_POINT' }
|
|
default: return { name: 'blocked-conflict', geometry: [first], constraints: [{ id: 'block', type: 'block', geometryId: 'first' }, { id: 'horizontal', type: 'horizontal', geometryId: 'first' }], expectedStatus: 'conflicting', expectedCode: 'SOLVER_NOT_CONVERGED' }
|
|
}
|
|
}
|
|
|
|
const durations: number[] = []
|
|
const categories = new Map<string, number>()
|
|
const statuses: Record<SketchSolverStatus, number> = { solved: 0, 'under-constrained': 0, conflicting: 0, invalid: 0 }
|
|
for (let index = 0; index < 2000; index += 1) {
|
|
const fuzzCase = createCase(index)
|
|
categories.set(fuzzCase.name, (categories.get(fuzzCase.name) ?? 0) + 1)
|
|
const snapshot = createSketch(`solver-fuzz-${index}`, fuzzCase.geometry, fuzzCase.constraints)
|
|
const before = JSON.stringify(snapshot)
|
|
const options = { tolerance: 1e-6, maxIterations: random() % 32 + 1 }
|
|
const started = performance.now()
|
|
const first = solveSketch(snapshot, options)
|
|
const second = solveSketch(snapshot, options)
|
|
durations.push(performance.now() - started)
|
|
statuses[first.status] += 1
|
|
assert.equal(JSON.stringify(first), JSON.stringify(second), `Sketch solver fuzz category ${fuzzCase.name} is non-deterministic.`)
|
|
assert.equal(JSON.stringify(snapshot), before, `Sketch solver fuzz category ${fuzzCase.name} mutated its input Snapshot.`)
|
|
assert.equal(first.status, fuzzCase.expectedStatus, `Sketch solver fuzz category ${fuzzCase.name} returned ${first.status}.`)
|
|
assert.ok(first.iterations <= options.maxIterations, `Sketch solver fuzz category ${fuzzCase.name} exceeded its iteration limit.`)
|
|
assert.ok(!Number.isNaN(first.residual), `Sketch solver fuzz category ${fuzzCase.name} produced NaN residual.`)
|
|
if (fuzzCase.expectedCode) assert.ok(first.diagnostics.some((diagnostic) => diagnostic.code === fuzzCase.expectedCode), `Sketch solver fuzz category ${fuzzCase.name} omitted ${fuzzCase.expectedCode}.`)
|
|
if (first.status === 'conflicting') assert.ok(first.diagnostics.some((diagnostic) => diagnostic.code === 'SOLVER_NOT_CONVERGED'))
|
|
if (first.status === 'invalid') assert.ok(first.diagnostics.some((diagnostic) => diagnostic.code !== 'REDUNDANT_CONSTRAINT'))
|
|
}
|
|
|
|
durations.sort((left, right) => left - right)
|
|
const p95Ms = durations[Math.floor(durations.length * 0.95)]
|
|
assert.ok(p95Ms < 10, `Sketch solver fuzz p95 exceeded 10 ms: ${p95Ms.toFixed(3)} ms.`)
|
|
const report = {
|
|
status: 'sketch-solver-fuzz-pass',
|
|
seed: '0xa5a5f00d',
|
|
models: durations.length,
|
|
solves: durations.length * 2,
|
|
categories: Object.fromEntries([...categories].sort(([left], [right]) => left.localeCompare(right))),
|
|
statuses,
|
|
timing: { p95Ms: Number(p95Ms.toFixed(3)), maxMs: Number(durations.at(-1)?.toFixed(3)) },
|
|
}
|
|
await writeFile(new URL('../config/qa04-sketch-fuzz-verification.json', import.meta.url), `${JSON.stringify(report, null, 2)}\n`)
|
|
console.log(JSON.stringify(report, null, 2))
|