@tangle-network/agent-bench 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,561 @@
1
+ /**
2
+ * MCAD adapter. Worker artifact = OpenSCAD source for a dimensioned mechanical
3
+ * part; judge = the REAL OpenSCAD kernel (compile + ASCII-STL export) followed by
4
+ * pure-TS mesh measurement against the task's `McadSpec`. No LLM, no self-report:
5
+ * the part either has the stated extents, volume, body count and holes, or it does
6
+ * not.
7
+ *
8
+ * Pipeline, in order, and every stage is a hard fact about the produced mesh:
9
+ * 1. compile : `xvfb-run -a openscad --export-format=asciistl -o out.stl` exits 0
10
+ * 2. watertight : every undirected edge shared by exactly two faces (2-manifold)
11
+ * 3. measure : bbox extents, enclosed volume (divergence theorem), connected
12
+ * -component count ("solids"), triangle count
13
+ * 4. probe : ray-parity point-in-solid at the spec's pinned hole/material
14
+ * coordinates — this is what makes a missing or misplaced bore
15
+ * fail, which no aggregate measure can catch
16
+ *
17
+ * Scoring: compile failure or a non-watertight mesh is 0 / unresolved — a mesh that
18
+ * is not a closed solid has no well-defined volume or interior, so every downstream
19
+ * number would be fiction. Otherwise the score is the fraction of ASSERTED spec
20
+ * checks passed (each bbox axis, the volume band, the solids band, the triangle
21
+ * floor, and EACH probe count as one check), and `resolved` requires all of them.
22
+ *
23
+ * Requires `openscad` + `xvfb-run` on PATH at JUDGE time only; `loadTasks` and the
24
+ * geometry engine below are dependency-free and run anywhere.
25
+ *
26
+ * Geometry checks 1-3 are ported from supervisor-lab's `bench/cad-stl-gate.ts`
27
+ * (edge-key manifold parity + signed-tetrahedron volume). Ported, not imported:
28
+ * these packages do not share a dependency edge.
29
+ */
30
+
31
+ import { execFile } from 'node:child_process'
32
+ import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
33
+ import { tmpdir } from 'node:os'
34
+ import { join } from 'node:path'
35
+ import { promisify } from 'node:util'
36
+ import { MCAD_GOLDS } from './mcad-golds'
37
+ import { MCAD_TASKS, type McadSpec, type McadTask } from './mcad-tasks'
38
+ import type { BenchmarkAdapter, BenchScore, BenchTask, LoadOptions } from './types'
39
+
40
+ const execFileAsync = promisify(execFile)
41
+
42
+ export interface Vec3 {
43
+ x: number
44
+ y: number
45
+ z: number
46
+ }
47
+
48
+ /** One triangle as three vertices, in the STL's stated winding order. */
49
+ export type Tri = readonly [Vec3, Vec3, Vec3]
50
+
51
+ export interface McadGeometry {
52
+ triangles: number
53
+ /** Edges NOT shared by exactly two faces. 0 iff the surface is closed + 2-manifold. */
54
+ openEdges: number
55
+ watertight: boolean
56
+ /** Zero-area faces — bad geometry even when the topology closes. */
57
+ degenerateFaces: number
58
+ /** Enclosed volume (absolute) via the signed-tetrahedron sum. */
59
+ volume: number
60
+ /** Connected components of the triangle-adjacency graph = disconnected bodies. */
61
+ solids: number
62
+ bbox: { min: Vec3; max: Vec3; size: Vec3 }
63
+ }
64
+
65
+ /** Coincident-vertex quantisation, in model units (mm). */
66
+ const VERTEX_QUANTUM = 1e-6
67
+ const AREA_EPS = 1e-9
68
+ const VOLUME_EPS = 1e-9
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Mesh parsing + measurement (ported from supervisor-lab bench/cad-stl-gate.ts)
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /** Parse an ASCII STL into triangles by reading `vertex x y z` lines in groups of three. */
75
+ export function parseAsciiStl(stl: string): Tri[] {
76
+ const verts: Vec3[] = []
77
+ for (const line of stl.split('\n')) {
78
+ const m = line.trim().match(/^vertex\s+(\S+)\s+(\S+)\s+(\S+)/i)
79
+ if (!m) continue
80
+ const x = Number(m[1])
81
+ const y = Number(m[2])
82
+ const z = Number(m[3])
83
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) continue
84
+ verts.push({ x, y, z })
85
+ }
86
+ const tris: Tri[] = []
87
+ for (let i = 0; i + 2 < verts.length; i += 3) tris.push([verts[i]!, verts[i + 1]!, verts[i + 2]!])
88
+ return tris
89
+ }
90
+
91
+ /** Quantise a vertex so shared edges match despite float authoring noise. */
92
+ function vertexKey(v: Vec3): string {
93
+ const r = (n: number) => (Math.round(n / VERTEX_QUANTUM) * VERTEX_QUANTUM).toFixed(6)
94
+ return `${r(v.x)},${r(v.y)},${r(v.z)}`
95
+ }
96
+
97
+ function sub(a: Vec3, b: Vec3): Vec3 {
98
+ return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z }
99
+ }
100
+ function cross(a: Vec3, b: Vec3): Vec3 {
101
+ return { x: a.y * b.z - a.z * b.y, y: a.z * b.x - a.x * b.z, z: a.x * b.y - a.y * b.x }
102
+ }
103
+ function dot(a: Vec3, b: Vec3): number {
104
+ return a.x * b.x + a.y * b.y + a.z * b.z
105
+ }
106
+ function norm(a: Vec3): number {
107
+ return Math.sqrt(dot(a, a))
108
+ }
109
+ function normalize(a: Vec3): Vec3 {
110
+ const n = norm(a)
111
+ return { x: a.x / n, y: a.y / n, z: a.z / n }
112
+ }
113
+
114
+ /** Disjoint-set over triangle indices — the "how many separate bodies" primitive. */
115
+ function findRoot(parent: Int32Array, i: number): number {
116
+ let r = i
117
+ while (parent[r] !== r) r = parent[r]!
118
+ let c = i
119
+ while (parent[c] !== c) {
120
+ const next = parent[c]!
121
+ parent[c] = r
122
+ c = next
123
+ }
124
+ return r
125
+ }
126
+
127
+ /**
128
+ * Measure the closed-solid properties of a triangle soup. Pure: no I/O, no deps.
129
+ *
130
+ * `solids` is the number of connected components of the graph whose nodes are
131
+ * triangles and whose edges join two triangles that share a quantised mesh edge.
132
+ * On a watertight mesh that count IS the number of disconnected bodies (an
133
+ * enclosed internal cavity is its own shell and counts, which is the behaviour
134
+ * the specs want — a "hollow" body that is really two nested shells is not one
135
+ * fused solid).
136
+ */
137
+ export function measureMesh(tris: Tri[]): McadGeometry {
138
+ if (tris.length === 0) {
139
+ const zero = { x: 0, y: 0, z: 0 }
140
+ return {
141
+ triangles: 0,
142
+ openEdges: 0,
143
+ watertight: false,
144
+ degenerateFaces: 0,
145
+ volume: 0,
146
+ solids: 0,
147
+ bbox: { min: zero, max: zero, size: zero },
148
+ }
149
+ }
150
+
151
+ const min: Vec3 = { x: Number.POSITIVE_INFINITY, y: Number.POSITIVE_INFINITY, z: Number.POSITIVE_INFINITY }
152
+ const max: Vec3 = { x: Number.NEGATIVE_INFINITY, y: Number.NEGATIVE_INFINITY, z: Number.NEGATIVE_INFINITY }
153
+ const edgeFaces = new Map<string, number[]>()
154
+ const parent = new Int32Array(tris.length)
155
+ for (let i = 0; i < tris.length; i++) parent[i] = i
156
+ let degenerateFaces = 0
157
+ let volume6 = 0
158
+
159
+ for (let i = 0; i < tris.length; i++) {
160
+ const [a, b, c] = tris[i]!
161
+ for (const v of [a, b, c]) {
162
+ if (v.x < min.x) min.x = v.x
163
+ if (v.y < min.y) min.y = v.y
164
+ if (v.z < min.z) min.z = v.z
165
+ if (v.x > max.x) max.x = v.x
166
+ if (v.y > max.y) max.y = v.y
167
+ if (v.z > max.z) max.z = v.z
168
+ }
169
+ if (norm(cross(sub(b, a), sub(c, a))) < AREA_EPS) degenerateFaces += 1
170
+ volume6 += dot(a, cross(b, c))
171
+
172
+ const ka = vertexKey(a)
173
+ const kb = vertexKey(b)
174
+ const kc = vertexKey(c)
175
+ for (const [p, q] of [
176
+ [ka, kb],
177
+ [kb, kc],
178
+ [kc, ka],
179
+ ] as const) {
180
+ const key = p < q ? `${p}|${q}` : `${q}|${p}`
181
+ const seen = edgeFaces.get(key)
182
+ if (seen) seen.push(i)
183
+ else edgeFaces.set(key, [i])
184
+ }
185
+ }
186
+
187
+ let openEdges = 0
188
+ for (const faces of edgeFaces.values()) {
189
+ if (faces.length !== 2) openEdges += 1
190
+ // Union every face pair on this edge (a >2-face edge still connects bodies).
191
+ const first = findRoot(parent, faces[0]!)
192
+ for (let k = 1; k < faces.length; k++) {
193
+ const other = findRoot(parent, faces[k]!)
194
+ if (other !== first) parent[other] = first
195
+ }
196
+ }
197
+
198
+ const roots = new Set<number>()
199
+ for (let i = 0; i < tris.length; i++) roots.add(findRoot(parent, i))
200
+
201
+ return {
202
+ triangles: tris.length,
203
+ openEdges,
204
+ watertight: openEdges === 0,
205
+ degenerateFaces,
206
+ volume: Math.abs(volume6) / 6,
207
+ solids: roots.size,
208
+ bbox: {
209
+ min: { ...min },
210
+ max: { ...max },
211
+ size: { x: max.x - min.x, y: max.y - min.y, z: max.z - min.z },
212
+ },
213
+ }
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Point-in-solid by ray parity
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /**
221
+ * Three fixed, mutually linearly-independent directions built from irrational
222
+ * constants (sqrt(3)-1, sqrt(2)-1, 1/sqrt(5), 1/sqrt(3) and the plastic-number
223
+ * pair 0.7548777 / 0.5698403). Their 3x3 determinant is ~1.77, so no two are
224
+ * near-parallel and no plane contains all three.
225
+ */
226
+ const RAY_DIRECTIONS: readonly Vec3[] = [
227
+ normalize({ x: 1, y: 0.7548777, z: 0.5698403 }),
228
+ normalize({ x: -0.7320508, y: 1, z: 0.236068 }),
229
+ normalize({ x: 0.4472136, y: -0.5773503, z: 1 }),
230
+ ]
231
+
232
+ /** Barycentric / parametric tolerances for calling a crossing "on the boundary". */
233
+ const BARY_EPS = 1e-9
234
+ const DET_EPS = 1e-12
235
+ const T_EPS = 1e-9
236
+ const MAX_JITTERS = 8
237
+
238
+ /** Deterministic 32-bit PRNG (mulberry32) — seeded, so probes never use Math.random. */
239
+ function mulberry32(seed: number): () => number {
240
+ let a = seed >>> 0
241
+ return () => {
242
+ a = (a + 0x6d2b79f5) >>> 0
243
+ let t = a
244
+ t = Math.imul(t ^ (t >>> 15), t | 1)
245
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
246
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
247
+ }
248
+ }
249
+
250
+ /** Stable integer hash of a probe point so its jitter sequence is reproducible. */
251
+ function seedForPoint(p: Vec3, rayIndex: number): number {
252
+ let h = 2166136261 ^ rayIndex
253
+ for (const n of [p.x, p.y, p.z]) {
254
+ const bits = Math.round(n * 1e6) | 0
255
+ h = Math.imul(h ^ (bits & 0xffff), 16777619)
256
+ h = Math.imul(h ^ ((bits >>> 16) & 0xffff), 16777619)
257
+ }
258
+ return h >>> 0
259
+ }
260
+
261
+ type CastResult = { crossings: number } | { degenerate: true }
262
+
263
+ /**
264
+ * Moller-Trumbore crossing count along one ray. Returns `degenerate` when the ray
265
+ * touches a triangle edge/vertex, lies in a triangle's plane, or starts on a face
266
+ * — the three cases where a parity count is not trustworthy.
267
+ */
268
+ function castRay(tris: Tri[], origin: Vec3, dir: Vec3): CastResult {
269
+ let crossings = 0
270
+ for (const [v0, v1, v2] of tris) {
271
+ const e1 = sub(v1, v0)
272
+ const e2 = sub(v2, v0)
273
+ const h = cross(dir, e2)
274
+ const det = dot(e1, h)
275
+ const scale = norm(e1) * norm(e2)
276
+ if (Math.abs(det) <= DET_EPS * Math.max(scale, 1)) {
277
+ // Ray parallel to (or inside) this triangle's plane. Only a problem when the
278
+ // ray could actually meet the triangle; distinguishing that costs more than a
279
+ // re-cast, so treat it as degenerate whenever the origin is near the plane.
280
+ const n = cross(e1, e2)
281
+ const nl = norm(n)
282
+ if (nl > AREA_EPS && Math.abs(dot(sub(origin, v0), n)) / nl <= T_EPS) return { degenerate: true }
283
+ continue
284
+ }
285
+ const f = 1 / det
286
+ const s = sub(origin, v0)
287
+ const u = f * dot(s, h)
288
+ const q = cross(s, e1)
289
+ const v = f * dot(dir, q)
290
+ const w = 1 - u - v
291
+ if (u < -BARY_EPS || v < -BARY_EPS || w < -BARY_EPS) continue
292
+ const t = f * dot(e2, q)
293
+ if (t < -T_EPS) continue
294
+ // Inside the triangle (or within EPS of its border). Anything within EPS of a
295
+ // border, or of the origin plane, makes the parity ambiguous.
296
+ if (Math.abs(t) <= T_EPS) return { degenerate: true }
297
+ if (u <= BARY_EPS || v <= BARY_EPS || w <= BARY_EPS) return { degenerate: true }
298
+ crossings += 1
299
+ }
300
+ return { crossings }
301
+ }
302
+
303
+ /**
304
+ * Point-in-solid membership by ray parity, made robust three ways.
305
+ *
306
+ * ROBUSTNESS ARGUMENT. A parity test is exact except on a measure-zero set: rays
307
+ * that graze a triangle edge or vertex (the crossing is counted twice or zero
308
+ * times), rays coplanar with a face, and origins lying on the surface. Those cases
309
+ * are not merely rare here — they are SYSTEMATIC, because OpenSCAD emits
310
+ * axis-aligned meshes whose vertices land on the same round millimetre lattice the
311
+ * spec's probe coordinates come from, so an axis-aligned ray from a probe point
312
+ * hits shared edges constantly. Three defences, in order:
313
+ * 1. DIRECTIONS. The three fixed directions are irrational combinations, so a ray
314
+ * from a lattice point cannot stay in an axis-aligned face plane and cannot
315
+ * run along a lattice edge.
316
+ * 2. DETECTION + DETERMINISTIC RE-JITTER. Grazing is DETECTED (a barycentric
317
+ * coordinate within BARY_EPS of 0, |det| below DET_EPS with the origin in the
318
+ * plane, or |t| within T_EPS) rather than hoped away. A detected ray is re-cast
319
+ * with a small direction perturbation drawn from a mulberry32 PRNG seeded by
320
+ * the probe coordinates and ray index, so the whole judge stays deterministic:
321
+ * the same mesh and the same point always take the same sequence of re-casts.
322
+ * 3. MAJORITY VOTE. The verdict is the majority of three independent directions,
323
+ * so even an undetected miscount on one ray cannot flip the answer.
324
+ * A point sitting exactly ON the surface has no correct answer; every direction
325
+ * degenerates there and the vote falls back to whatever the jittered casts say.
326
+ * The specs therefore place probes with >=1 mm clearance from any surface.
327
+ */
328
+ export function pointInSolid(tris: Tri[], point: Vec3): boolean {
329
+ let inside = 0
330
+ let votes = 0
331
+ for (let i = 0; i < RAY_DIRECTIONS.length; i++) {
332
+ const base = RAY_DIRECTIONS[i]!
333
+ const rand = mulberry32(seedForPoint(point, i))
334
+ let dir = base
335
+ for (let attempt = 0; attempt <= MAX_JITTERS; attempt++) {
336
+ const r = castRay(tris, point, dir)
337
+ if (!('degenerate' in r)) {
338
+ votes += 1
339
+ if (r.crossings % 2 === 1) inside += 1
340
+ break
341
+ }
342
+ dir = normalize({
343
+ x: base.x + (rand() - 0.5) * 1e-3,
344
+ y: base.y + (rand() - 0.5) * 1e-3,
345
+ z: base.z + (rand() - 0.5) * 1e-3,
346
+ })
347
+ }
348
+ }
349
+ // Every direction degenerating means the point is on the surface; call it outside
350
+ // (fail-closed: a probe that must be inside the material will report a failure).
351
+ if (votes === 0) return false
352
+ return inside * 2 > votes
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Spec scoring
357
+ // ---------------------------------------------------------------------------
358
+
359
+ export interface McadCheck {
360
+ name: string
361
+ ok: boolean
362
+ measured: string
363
+ expected: string
364
+ }
365
+
366
+ export interface McadScoring {
367
+ checks: McadCheck[]
368
+ failed: McadCheck[]
369
+ score: number
370
+ resolved: boolean
371
+ }
372
+
373
+ function inBand(v: number, [lo, hi]: [number, number]): boolean {
374
+ return v >= lo && v <= hi
375
+ }
376
+
377
+ function fmt(n: number): string {
378
+ return String(Math.round(n * 1000) / 1000)
379
+ }
380
+
381
+ function pt(p: readonly [number, number, number]): string {
382
+ return `(${p[0]}, ${p[1]}, ${p[2]})`
383
+ }
384
+
385
+ /** Score a measured mesh against the task's spec — one named check per assertion. */
386
+ export function scoreAgainstSpec(geo: McadGeometry, tris: Tri[], spec: McadSpec): McadScoring {
387
+ const checks: McadCheck[] = []
388
+ const add = (name: string, ok: boolean, measured: string, expected: string) =>
389
+ checks.push({ name, ok, measured, expected })
390
+
391
+ for (const axis of ['x', 'y', 'z'] as const) {
392
+ const band = spec.bbox?.[axis]
393
+ if (!band) continue
394
+ const got = geo.bbox.size[axis]
395
+ add(`bbox${axis.toUpperCase()}`, inBand(got, band), fmt(got), `[${band[0]}, ${band[1]}]`)
396
+ }
397
+ if (spec.volume) add('volume', inBand(geo.volume, spec.volume), fmt(geo.volume), `[${spec.volume[0]}, ${spec.volume[1]}]`)
398
+ if (spec.solids) add('solids', inBand(geo.solids, spec.solids), String(geo.solids), `[${spec.solids[0]}, ${spec.solids[1]}]`)
399
+ if (spec.minTriangles != null)
400
+ add('minTriangles', geo.triangles >= spec.minTriangles, String(geo.triangles), `>= ${spec.minTriangles}`)
401
+
402
+ for (const [i, p] of (spec.probesInsideSolid ?? []).entries()) {
403
+ const got = pointInSolid(tris, { x: p[0], y: p[1], z: p[2] })
404
+ add(`probeInside[${i}] ${pt(p)}`, got, got ? 'inside' : 'outside', 'inside')
405
+ }
406
+ for (const [i, p] of (spec.probesOutsideSolid ?? []).entries()) {
407
+ const got = pointInSolid(tris, { x: p[0], y: p[1], z: p[2] })
408
+ add(`probeOutside[${i}] ${pt(p)}`, !got, got ? 'inside' : 'outside', 'outside')
409
+ }
410
+
411
+ const failed = checks.filter((c) => !c.ok)
412
+ const score = checks.length ? (checks.length - failed.length) / checks.length : 0
413
+ return { checks, failed, score, resolved: checks.length > 0 && failed.length === 0 }
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Adapter
418
+ // ---------------------------------------------------------------------------
419
+
420
+ /** Task metadata carried onto every `BenchTask`, so the judge needs no lookup table. */
421
+ export interface McadTaskMeta extends Record<string, unknown> {
422
+ spec: McadSpec
423
+ source: string
424
+ calibrated: boolean
425
+ }
426
+
427
+ /** Run openscad under xvfb (it wants a GL context even headless). */
428
+ async function openscad(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> {
429
+ return execFileAsync('xvfb-run', ['-a', 'openscad', ...args], {
430
+ cwd,
431
+ maxBuffer: 1024 * 1024 * 64,
432
+ timeout: 600_000,
433
+ })
434
+ }
435
+
436
+ /**
437
+ * Strip a markdown fence if the model wrapped its answer in one. The prompt asks
438
+ * for bare source; a fenced answer is a formatting slip, not a geometry failure,
439
+ * and the geometric gate downstream is unchanged either way.
440
+ */
441
+ export function stripCodeFence(artifact: string): string {
442
+ const trimmed = artifact.trim()
443
+ const fenced = /^```[a-zA-Z]*\n([\s\S]*?)\n?```$/.exec(trimmed)
444
+ return (fenced ? fenced[1]! : trimmed).trim()
445
+ }
446
+
447
+ /** Compile OpenSCAD source and measure the resulting mesh. Judge-time only. */
448
+ export async function compileAndMeasure(
449
+ src: string,
450
+ ): Promise<{ ok: true; geo: McadGeometry; tris: Tri[]; stlPath: string } | { ok: false; detail: string }> {
451
+ const dir = await mkdtemp(join(tmpdir(), 'mcad-'))
452
+ const scadPath = join(dir, 'model.scad')
453
+ const stlPath = join(dir, 'model.stl')
454
+ await writeFile(scadPath, `${src}\n`)
455
+
456
+ try {
457
+ await openscad(['--export-format=asciistl', '-o', stlPath, scadPath], dir)
458
+ } catch (err) {
459
+ const msg = err instanceof Error ? err.message : String(err)
460
+ return { ok: false, detail: `compile failed: ${msg.slice(0, 400)}` }
461
+ }
462
+
463
+ let stl: string
464
+ try {
465
+ stl = await readFile(stlPath, 'utf8')
466
+ } catch {
467
+ return { ok: false, detail: 'compiled but produced no STL (empty geometry)' }
468
+ }
469
+ const tris = parseAsciiStl(stl)
470
+ if (tris.length === 0) return { ok: false, detail: 'STL parsed to zero triangles (empty geometry)' }
471
+ return { ok: true, geo: measureMesh(tris), tris, stlPath }
472
+ }
473
+
474
+ export function createMcadBenchAdapter(): BenchmarkAdapter {
475
+ return {
476
+ name: 'mcad',
477
+
478
+ async preflight() {
479
+ try {
480
+ await execFileAsync('xvfb-run', ['-a', 'openscad', '--version'], { timeout: 30_000 })
481
+ } catch (err) {
482
+ const msg = err instanceof Error ? err.message : String(err)
483
+ throw new Error(
484
+ `mcad preflight failed: ${msg}\n` +
485
+ 'Fix: install OpenSCAD + Xvfb (Debian/Ubuntu: sudo apt-get install -y openscad xvfb). ' +
486
+ 'The judge runs `xvfb-run -a openscad --export-format=asciistl -o out.stl model.scad` — both must be on PATH.',
487
+ )
488
+ }
489
+ },
490
+
491
+ async loadTasks(opts: LoadOptions = {}) {
492
+ let tasks: McadTask[] = MCAD_TASKS
493
+ if (opts.ids) tasks = tasks.filter((t) => opts.ids?.includes(t.id))
494
+ if (opts.limit != null) tasks = tasks.slice(0, opts.limit)
495
+ return tasks.map(
496
+ (t): BenchTask => ({
497
+ id: t.id,
498
+ prompt: t.prompt,
499
+ metadata: { spec: t.spec, source: t.source, calibrated: t.calibrated } satisfies McadTaskMeta,
500
+ }),
501
+ )
502
+ },
503
+
504
+ async goldArtifact(task: BenchTask) {
505
+ return MCAD_GOLDS[task.id]
506
+ },
507
+
508
+ async judge(task: BenchTask, artifact: string): Promise<BenchScore> {
509
+ const spec = (task.metadata as McadTaskMeta | undefined)?.spec
510
+ if (!spec) return { resolved: false, score: 0, detail: `task ${task.id} carries no mcad spec` }
511
+
512
+ const src = stripCodeFence(artifact)
513
+ if (!src) return { resolved: false, score: 0, detail: 'empty artifact' }
514
+
515
+ const built = await compileAndMeasure(src)
516
+ if (!built.ok) return { resolved: false, score: 0, detail: built.detail }
517
+
518
+ const { geo, tris, stlPath } = built
519
+ if (!geo.watertight) {
520
+ return {
521
+ resolved: false,
522
+ score: 0,
523
+ detail: JSON.stringify({
524
+ gate: 'watertight',
525
+ failed: [
526
+ {
527
+ name: 'watertight',
528
+ measured: `${geo.openEdges} edge(s) not shared by exactly two faces`,
529
+ expected: '0 open edges (closed 2-manifold solid)',
530
+ },
531
+ ],
532
+ geo: { triangles: geo.triangles, degenerateFaces: geo.degenerateFaces },
533
+ stlPath,
534
+ }),
535
+ }
536
+ }
537
+
538
+ const scored = scoreAgainstSpec(geo, tris, spec)
539
+ return {
540
+ resolved: scored.resolved,
541
+ score: scored.score,
542
+ detail: JSON.stringify({
543
+ failed: scored.failed.map((c) => ({ name: c.name, measured: c.measured, expected: c.expected })),
544
+ checks: Object.fromEntries(scored.checks.map((c) => [c.name, c.ok])),
545
+ geo: {
546
+ triangles: geo.triangles,
547
+ solids: geo.solids,
548
+ volume: +geo.volume.toFixed(2),
549
+ degenerateFaces: geo.degenerateFaces,
550
+ bbox: {
551
+ x: +geo.bbox.size.x.toFixed(3),
552
+ y: +geo.bbox.size.y.toFixed(3),
553
+ z: +geo.bbox.size.z.toFixed(3),
554
+ },
555
+ },
556
+ stlPath,
557
+ }),
558
+ }
559
+ },
560
+ }
561
+ }