@tangle-network/agent-bench 0.8.0 → 0.8.2

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,455 @@
1
+ /**
2
+ * MCAD adapter tests, in two halves.
3
+ *
4
+ * PURE: hand-written ASCII STL fixtures exercise the geometry engine with no
5
+ * OpenSCAD anywhere — a unit cube, an open box, two disjoint cubes, and a cube
6
+ * with a square through-channel. The fixtures are deliberately lattice-aligned,
7
+ * because that is the case a naive axis-aligned ray-parity test gets wrong.
8
+ *
9
+ * LIVE (gated on `openscad` + `xvfb-run`): the real judge, in BOTH directions.
10
+ * Accept — every `calibrated: true` task scores 1.0 on its own gold. Reject — the
11
+ * task-01 gold with its four holes deleted must fail the bore probes, and the same
12
+ * gold scaled to 90 x 54 x 18 must fail all three bbox axes. A judge that has only
13
+ * been shown passing inputs is not calibrated, so the reject direction is not
14
+ * optional here.
15
+ *
16
+ * npx vitest run src/benchmarks/mcad-bench.test.mts
17
+ */
18
+
19
+ import { execFileSync } from 'node:child_process'
20
+ import { describe, expect, it } from 'vitest'
21
+ import {
22
+ createMcadBenchAdapter,
23
+ measureMesh,
24
+ parseAsciiStl,
25
+ pointInSolid,
26
+ scoreAgainstSpec,
27
+ stripCodeFence,
28
+ } from './mcad-bench'
29
+ import { MCAD_GOLDS } from './mcad-golds'
30
+ import { MCAD_TASKS } from './mcad-tasks'
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // ASCII STL fixture builders (outward-facing winding, 2-manifold by construction)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ type P = readonly [number, number, number]
37
+ type T = readonly [P, P, P]
38
+
39
+ /** A quad a-b-c-d as two triangles sharing the a-c diagonal. */
40
+ function quad(a: P, b: P, c: P, d: P): T[] {
41
+ return [
42
+ [a, b, c],
43
+ [a, c, d],
44
+ ]
45
+ }
46
+
47
+ function boxTris(min: P, max: P): T[] {
48
+ const [x0, y0, z0] = min
49
+ const [x1, y1, z1] = max
50
+ const p000: P = [x0, y0, z0]
51
+ const p100: P = [x1, y0, z0]
52
+ const p110: P = [x1, y1, z0]
53
+ const p010: P = [x0, y1, z0]
54
+ const p001: P = [x0, y0, z1]
55
+ const p101: P = [x1, y0, z1]
56
+ const p111: P = [x1, y1, z1]
57
+ const p011: P = [x0, y1, z1]
58
+ return [
59
+ ...quad(p000, p010, p110, p100), // bottom, -Z
60
+ ...quad(p001, p101, p111, p011), // top, +Z
61
+ ...quad(p000, p100, p101, p001), // front, -Y
62
+ ...quad(p010, p011, p111, p110), // back, +Y
63
+ ...quad(p000, p001, p011, p010), // left, -X
64
+ ...quad(p100, p110, p111, p101), // right, +X
65
+ ]
66
+ }
67
+
68
+ function toStl(tris: T[]): string {
69
+ const facets = tris
70
+ .map(([a, b, c]) =>
71
+ [
72
+ ' facet normal 0 0 0',
73
+ ' outer loop',
74
+ ` vertex ${a.join(' ')}`,
75
+ ` vertex ${b.join(' ')}`,
76
+ ` vertex ${c.join(' ')}`,
77
+ ' endloop',
78
+ ' endfacet',
79
+ ].join('\n'),
80
+ )
81
+ .join('\n')
82
+ return `solid fixture\n${facets}\nendsolid fixture\n`
83
+ }
84
+
85
+ const UNIT_CUBE = toStl(boxTris([0, 0, 0], [1, 1, 1]))
86
+ /** The unit cube with its two +Z triangles deleted — four edges left unpaired. */
87
+ const OPEN_BOX = toStl(boxTris([0, 0, 0], [1, 1, 1]).filter((_, i) => i !== 2 && i !== 3))
88
+ const TWO_CUBES = toStl([...boxTris([0, 0, 0], [1, 1, 1]), ...boxTris([3, 0, 0], [4, 1, 1])])
89
+
90
+ /**
91
+ * A 6 x 6 x 6 block with a 2 x 2 square channel running through it in Z. Both end
92
+ * faces are annuli split into four trapezoids whose corner diagonals are shared, so
93
+ * the mesh stays 2-manifold. Enclosed volume: 216 - 24 = 192.
94
+ */
95
+ function channelCubeTris(): T[] {
96
+ const o = 6
97
+ const a = 2
98
+ const b = 4
99
+ const tris: T[] = []
100
+ // outer side walls
101
+ const outer: Array<[P, P]> = [
102
+ [[0, 0, 0], [o, 0, 0]],
103
+ [[o, 0, 0], [o, o, 0]],
104
+ [[o, o, 0], [0, o, 0]],
105
+ [[0, o, 0], [0, 0, 0]],
106
+ ]
107
+ for (const [s, e] of outer) {
108
+ tris.push(...quad(s, e, [e[0], e[1], o], [s[0], s[1], o]))
109
+ }
110
+ // end-face annuli: four trapezoids per face, outer edge -> inner edge
111
+ const strips: Array<[P, P, P, P]> = [
112
+ [[0, 0, 0], [o, 0, 0], [b, a, 0], [a, a, 0]],
113
+ [[o, 0, 0], [o, o, 0], [b, b, 0], [b, a, 0]],
114
+ [[o, o, 0], [0, o, 0], [a, b, 0], [b, b, 0]],
115
+ [[0, o, 0], [0, 0, 0], [a, a, 0], [a, b, 0]],
116
+ ]
117
+ for (const [p, q, r, s] of strips) {
118
+ const up = (v: P): P => [v[0], v[1], o]
119
+ tris.push(...quad(up(p), up(q), up(r), up(s))) // +Z face
120
+ tris.push(...quad(s, r, q, p)) // -Z face, reversed winding
121
+ }
122
+ // channel walls, outward normals point INTO the void
123
+ tris.push(...quad([a, a, 0], [a, b, 0], [a, b, o], [a, a, o])) // +X
124
+ tris.push(...quad([b, a, 0], [b, a, o], [b, b, o], [b, b, 0])) // -X
125
+ tris.push(...quad([a, a, 0], [a, a, o], [b, a, o], [b, a, 0])) // +Y
126
+ tris.push(...quad([a, b, 0], [b, b, 0], [b, b, o], [a, b, o])) // -Y
127
+ return tris
128
+ }
129
+
130
+ const CHANNEL_CUBE = toStl(channelCubeTris())
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Pure geometry tests
134
+ // ---------------------------------------------------------------------------
135
+
136
+ describe('mcad geometry engine (pure ASCII STL fixtures)', () => {
137
+ it('measures a unit cube: watertight, one solid, volume 1', () => {
138
+ const g = measureMesh(parseAsciiStl(UNIT_CUBE))
139
+ expect(g.triangles).toBe(12)
140
+ expect(g.watertight).toBe(true)
141
+ expect(g.openEdges).toBe(0)
142
+ expect(g.degenerateFaces).toBe(0)
143
+ expect(g.solids).toBe(1)
144
+ expect(g.volume).toBeCloseTo(1, 9)
145
+ expect(g.bbox.size).toEqual({ x: 1, y: 1, z: 1 })
146
+ })
147
+
148
+ it('rejects an open box: four unpaired edges, not watertight', () => {
149
+ const g = measureMesh(parseAsciiStl(OPEN_BOX))
150
+ expect(g.triangles).toBe(10)
151
+ expect(g.watertight).toBe(false)
152
+ expect(g.openEdges).toBe(4)
153
+ })
154
+
155
+ it('counts two disjoint cubes as two solids', () => {
156
+ const g = measureMesh(parseAsciiStl(TWO_CUBES))
157
+ expect(g.watertight).toBe(true)
158
+ expect(g.solids).toBe(2)
159
+ expect(g.volume).toBeCloseTo(2, 9)
160
+ expect(g.bbox.size.x).toBe(4)
161
+ })
162
+
163
+ it('measures the through-channel cube: one solid, volume 192', () => {
164
+ const g = measureMesh(parseAsciiStl(CHANNEL_CUBE))
165
+ expect(g.watertight).toBe(true)
166
+ expect(g.openEdges).toBe(0)
167
+ expect(g.solids).toBe(1)
168
+ expect(g.degenerateFaces).toBe(0)
169
+ expect(g.volume).toBeCloseTo(192, 6)
170
+ })
171
+
172
+ it('returns an empty measurement for a triangle-free STL', () => {
173
+ const g = measureMesh(parseAsciiStl('solid empty\nendsolid empty\n'))
174
+ expect(g.triangles).toBe(0)
175
+ expect(g.watertight).toBe(false)
176
+ })
177
+
178
+ it('places probes correctly in the through-channel cube', () => {
179
+ const tris = parseAsciiStl(CHANNEL_CUBE)
180
+ // channel centre is void; the surrounding material is solid
181
+ expect(pointInSolid(tris, { x: 3, y: 3, z: 3 })).toBe(false)
182
+ expect(pointInSolid(tris, { x: 1, y: 1, z: 3 })).toBe(true)
183
+ expect(pointInSolid(tris, { x: 5, y: 5, z: 3 })).toBe(true)
184
+ expect(pointInSolid(tris, { x: 1, y: 5, z: 3 })).toBe(true)
185
+ // outside the block entirely, including straight up the channel
186
+ expect(pointInSolid(tris, { x: 3, y: 3, z: 9 })).toBe(false)
187
+ expect(pointInSolid(tris, { x: -1, y: 3, z: 3 })).toBe(false)
188
+ })
189
+ })
190
+
191
+ describe('ray-parity robustness on lattice-aligned probes', () => {
192
+ const cube = parseAsciiStl(toStl(boxTris([0, 0, 0], [2, 2, 2])))
193
+ const channel = parseAsciiStl(CHANNEL_CUBE)
194
+
195
+ it('answers correctly for points sitting on the cube edge lines', () => {
196
+ // Every one of these shares two coordinates with a cube corner, so an
197
+ // axis-aligned ray from it runs exactly along an edge or inside a face plane.
198
+ expect(pointInSolid(cube, { x: 3, y: 0, z: 0 })).toBe(false)
199
+ expect(pointInSolid(cube, { x: 3, y: 2, z: 2 })).toBe(false)
200
+ expect(pointInSolid(cube, { x: -1, y: 0, z: 2 })).toBe(false)
201
+ expect(pointInSolid(cube, { x: 1, y: 3, z: 0 })).toBe(false)
202
+ expect(pointInSolid(cube, { x: 1, y: 1, z: 1 })).toBe(true)
203
+ })
204
+
205
+ it('answers correctly for interior points lying in a channel-wall plane', () => {
206
+ // y = 2 and x = 2 are the channel wall planes; these points are in material,
207
+ // and an axis-aligned ray from each would graze the channel wall's edge.
208
+ expect(pointInSolid(channel, { x: 1, y: 2, z: 3 })).toBe(true)
209
+ expect(pointInSolid(channel, { x: 2, y: 1, z: 3 })).toBe(true)
210
+ expect(pointInSolid(channel, { x: 5, y: 2, z: 3 })).toBe(true)
211
+ expect(pointInSolid(channel, { x: 4, y: 5, z: 3 })).toBe(true)
212
+ // and for outside points on the same degenerate lines
213
+ expect(pointInSolid(channel, { x: 7, y: 2, z: 3 })).toBe(false)
214
+ expect(pointInSolid(channel, { x: 3, y: 2, z: 7 })).toBe(false)
215
+ })
216
+
217
+ it('is deterministic: the same point and mesh always give the same answer', () => {
218
+ const pts = [
219
+ { x: 3, y: 3, z: 3 },
220
+ { x: 1, y: 2, z: 3 },
221
+ { x: 2, y: 1, z: 3 },
222
+ ]
223
+ for (const p of pts) {
224
+ const first = pointInSolid(channel, p)
225
+ for (let i = 0; i < 5; i++) expect(pointInSolid(channel, p)).toBe(first)
226
+ }
227
+ })
228
+ })
229
+
230
+ describe('spec scoring', () => {
231
+ const tris = parseAsciiStl(CHANNEL_CUBE)
232
+ const geo = measureMesh(tris)
233
+
234
+ it('scores 1.0 and resolves when every asserted check passes', () => {
235
+ const s = scoreAgainstSpec(geo, tris, {
236
+ bbox: { x: [5.9, 6.1], y: [5.9, 6.1], z: [5.9, 6.1] },
237
+ volume: [190, 194],
238
+ solids: [1, 1],
239
+ minTriangles: 20,
240
+ probesInsideSolid: [[1, 1, 3]],
241
+ probesOutsideSolid: [[3, 3, 3]],
242
+ })
243
+ expect(s.score).toBe(1)
244
+ expect(s.resolved).toBe(true)
245
+ expect(s.failed).toEqual([])
246
+ })
247
+
248
+ it('gives partial credit and names every failed check with measured vs expected', () => {
249
+ const s = scoreAgainstSpec(geo, tris, {
250
+ bbox: { x: [10, 12] },
251
+ volume: [190, 194],
252
+ solids: [1, 1],
253
+ probesOutsideSolid: [[1, 1, 3]],
254
+ })
255
+ expect(s.resolved).toBe(false)
256
+ expect(s.score).toBeCloseTo(2 / 4, 9)
257
+ const names = s.failed.map((c) => c.name)
258
+ expect(names).toContain('bboxX')
259
+ expect(names.some((n) => n.startsWith('probeOutside[0]'))).toBe(true)
260
+ const bbox = s.failed.find((c) => c.name === 'bboxX')
261
+ expect(bbox?.measured).toBe('6')
262
+ expect(bbox?.expected).toBe('[10, 12]')
263
+ })
264
+
265
+ it('counts each bbox axis, each band and each probe as one check', () => {
266
+ const s = scoreAgainstSpec(geo, tris, {
267
+ bbox: { x: [5, 7], y: [5, 7], z: [5, 7] },
268
+ volume: [190, 194],
269
+ solids: [1, 1],
270
+ minTriangles: 1,
271
+ probesInsideSolid: [
272
+ [1, 1, 3],
273
+ [5, 5, 3],
274
+ ],
275
+ probesOutsideSolid: [[3, 3, 3]],
276
+ })
277
+ expect(s.checks).toHaveLength(9)
278
+ })
279
+ })
280
+
281
+ describe('artifact handling', () => {
282
+ it('strips a markdown fence the model may have wrapped its answer in', () => {
283
+ expect(stripCodeFence('```openscad\ncube([1,1,1]);\n```')).toBe('cube([1,1,1]);')
284
+ expect(stripCodeFence('```\ncube([1,1,1]);\n```')).toBe('cube([1,1,1]);')
285
+ expect(stripCodeFence(' cube([1,1,1]); ')).toBe('cube([1,1,1]);')
286
+ })
287
+
288
+ it('loads tasks with spec/source/calibrated metadata and no openscad', async () => {
289
+ const a = createMcadBenchAdapter()
290
+ const tasks = await a.loadTasks()
291
+ expect(tasks).toHaveLength(MCAD_TASKS.length)
292
+ for (const t of tasks) {
293
+ const md = t.metadata as { spec: unknown; source: string; calibrated: boolean }
294
+ expect(md.spec).toBeTruthy()
295
+ expect(typeof md.source).toBe('string')
296
+ expect(typeof md.calibrated).toBe('boolean')
297
+ expect(typeof (await a.goldArtifact(t))).toBe('string')
298
+ }
299
+ })
300
+
301
+ it('honours ids and limit', async () => {
302
+ const a = createMcadBenchAdapter()
303
+ expect((await a.loadTasks({ ids: ['l-bracket'] })).map((t) => t.id)).toEqual(['l-bracket'])
304
+ expect(await a.loadTasks({ limit: 3 })).toHaveLength(3)
305
+ })
306
+
307
+ it('task 10 asks for separate bodies, never "one fused solid"', () => {
308
+ const t10 = MCAD_TASKS.find((t) => t.id === 'planetary-gear-stage')
309
+ expect(t10?.prompt).not.toContain('one fused solid')
310
+ expect(t10?.prompt).toContain('separate')
311
+ // every other task keeps the single-solid boilerplate
312
+ for (const t of MCAD_TASKS) {
313
+ if (t.id === 'planetary-gear-stage') continue
314
+ expect(t.prompt).toContain('one fused solid')
315
+ }
316
+ })
317
+
318
+ it('scores an empty artifact 0 without touching openscad', async () => {
319
+ const a = createMcadBenchAdapter()
320
+ const [t] = await a.loadTasks({ ids: ['calibration-block'] })
321
+ const s = await a.judge(t!, ' ')
322
+ expect(s.resolved).toBe(false)
323
+ expect(s.score).toBe(0)
324
+ expect(s.detail).toBe('empty artifact')
325
+ })
326
+ })
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Live judge (needs openscad + xvfb-run)
330
+ // ---------------------------------------------------------------------------
331
+
332
+ function openscadPresent(): boolean {
333
+ try {
334
+ execFileSync('xvfb-run', ['-a', 'openscad', '--version'], { stdio: 'ignore', timeout: 60_000 })
335
+ return true
336
+ } catch {
337
+ return false
338
+ }
339
+ }
340
+
341
+ const OPENSCAD = openscadPresent()
342
+ const JUDGE_TIMEOUT = 300_000
343
+
344
+ /** Task 01's gold with the four through-holes deleted — the geometry is otherwise identical. */
345
+ const BLOCK_WITHOUT_HOLES = `
346
+ $fn=96;
347
+ module chamfered_block(l, w, h, c) {
348
+ hull() {
349
+ translate([-l/2, -w/2, 0]) cube([l, w, h - c]);
350
+ translate([-l/2 + c, -w/2 + c, h - c]) cube([l - 2*c, w - 2*c, c]);
351
+ }
352
+ }
353
+ chamfered_block(100, 60, 20, 2);
354
+ `
355
+
356
+ /** Task 01's gold at 0.9 scale: 90 x 54 x 18, every feature otherwise correct.
357
+ * Wrapped in a module rather than a bare `scale(){...}` block because OpenSCAD
358
+ * rejects a module definition inside a transform's child block but accepts one
359
+ * inside a module body — this keeps the fixture derived from the gold itself. */
360
+ const BLOCK_SCALED_90 = `module gold() {\n${MCAD_GOLDS['calibration-block']}\n}\nscale([0.9, 0.9, 0.9]) gold();`
361
+
362
+ describe.skipIf(!OPENSCAD)('mcad judge, live openscad', () => {
363
+ it('preflight passes when the toolchain is present', async () => {
364
+ await expect(createMcadBenchAdapter().preflight()).resolves.toBeUndefined()
365
+ })
366
+
367
+ for (const task of MCAD_TASKS.filter((t) => t.calibrated)) {
368
+ it(
369
+ `gold for ${task.id} resolves at score 1.0`,
370
+ async () => {
371
+ const a = createMcadBenchAdapter()
372
+ const [t] = await a.loadTasks({ ids: [task.id] })
373
+ const gold = await a.goldArtifact(t!)
374
+ expect(typeof gold).toBe('string')
375
+ const s = await a.judge(t!, gold as string)
376
+ const failed = JSON.parse(s.detail ?? '{}').failed ?? []
377
+ expect(failed, `failed checks: ${JSON.stringify(failed)}`).toEqual([])
378
+ expect(s.score).toBe(1)
379
+ expect(s.resolved).toBe(true)
380
+ },
381
+ JUDGE_TIMEOUT,
382
+ )
383
+ }
384
+
385
+ it(
386
+ 'MUST REJECT: the calibration block with its four holes removed fails the bore probes',
387
+ async () => {
388
+ const a = createMcadBenchAdapter()
389
+ const [t] = await a.loadTasks({ ids: ['calibration-block'] })
390
+ const s = await a.judge(t!, BLOCK_WITHOUT_HOLES)
391
+ expect(s.resolved).toBe(false)
392
+ expect(s.score).toBeLessThan(1)
393
+ const failed = (JSON.parse(s.detail as string).failed as Array<{ name: string; measured: string }>).map(
394
+ (f) => f.name,
395
+ )
396
+ // all five "must be outside" probes now sit in solid material
397
+ const outside = failed.filter((n) => n.startsWith('probeOutside['))
398
+ const spec = MCAD_TASKS.find((x) => x.id === 'calibration-block')?.spec
399
+ expect(outside).toHaveLength(spec?.probesOutsideSolid?.length ?? 0)
400
+ expect(failed).toContain('volume')
401
+ // the block itself is still the right size, so bbox must NOT be blamed
402
+ expect(failed).not.toContain('bboxX')
403
+ },
404
+ JUDGE_TIMEOUT,
405
+ )
406
+
407
+ it(
408
+ 'MUST REJECT: the calibration block scaled to 90 x 54 x 18 fails every bbox axis',
409
+ async () => {
410
+ const a = createMcadBenchAdapter()
411
+ const [t] = await a.loadTasks({ ids: ['calibration-block'] })
412
+ const s = await a.judge(t!, BLOCK_SCALED_90)
413
+ expect(s.resolved).toBe(false)
414
+ const detail = JSON.parse(s.detail as string) as {
415
+ failed: Array<{ name: string; measured: string; expected: string }>
416
+ geo: { bbox: { x: number; y: number; z: number } }
417
+ }
418
+ expect(detail.geo.bbox).toEqual({ x: 90, y: 54, z: 18 })
419
+ const failed = detail.failed.map((f) => f.name)
420
+ expect(failed).toContain('bboxX')
421
+ expect(failed).toContain('bboxY')
422
+ expect(failed).toContain('bboxZ')
423
+ const x = detail.failed.find((f) => f.name === 'bboxX')
424
+ expect(x?.measured).toBe('90')
425
+ expect(x?.expected).toBe('[99, 101]')
426
+ },
427
+ JUDGE_TIMEOUT,
428
+ )
429
+
430
+ it(
431
+ 'MUST REJECT: a non-watertight surface is scored 0 by the hard gate',
432
+ async () => {
433
+ const a = createMcadBenchAdapter()
434
+ const [t] = await a.loadTasks({ ids: ['calibration-block'] })
435
+ const s = await a.judge(t!, 'polyhedron(points=[[0,0,0],[10,0,0],[0,10,0]], faces=[[0,1,2]]);')
436
+ expect(s.resolved).toBe(false)
437
+ expect(s.score).toBe(0)
438
+ expect(JSON.parse(s.detail as string).gate).toBe('watertight')
439
+ },
440
+ JUDGE_TIMEOUT,
441
+ )
442
+
443
+ it(
444
+ 'MUST REJECT: source that does not compile is scored 0',
445
+ async () => {
446
+ const a = createMcadBenchAdapter()
447
+ const [t] = await a.loadTasks({ ids: ['calibration-block'] })
448
+ const s = await a.judge(t!, 'this is not openscad {{{')
449
+ expect(s.resolved).toBe(false)
450
+ expect(s.score).toBe(0)
451
+ expect(s.detail).toMatch(/compile failed/)
452
+ },
453
+ JUDGE_TIMEOUT,
454
+ )
455
+ })