@tangle-network/agent-bench 0.7.1 → 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.
- package/CHANGELOG.md +12 -0
- package/HARNESS.md +2 -2
- package/dist/adapters.js +6 -2
- package/dist/adapters.js.map +1 -1
- package/dist/benchmarks/appworld.d.ts +61 -2
- package/dist/benchmarks/appworld.js +111 -40
- package/dist/benchmarks/appworld.js.map +1 -1
- package/dist/benchmarks/cadbench.js +1 -1
- package/dist/benchmarks/cadgenbench.js +1 -1
- package/dist/benchmarks/finresearchbench.js +16 -22
- package/dist/benchmarks/finresearchbench.js.map +1 -1
- package/dist/benchmarks/finsearchcomp.js +16 -22
- package/dist/benchmarks/finsearchcomp.js.map +1 -1
- package/dist/benchmarks/frames.js +18 -20
- package/dist/benchmarks/frames.js.map +1 -1
- package/dist/benchmarks/mcad-bench.d.ts +106 -0
- package/dist/benchmarks/mcad-bench.js +569 -0
- package/dist/benchmarks/mcad-bench.js.map +1 -0
- package/dist/benchmarks/mcad-cq-bench.d.ts +82 -0
- package/dist/benchmarks/mcad-cq-bench.js +339 -0
- package/dist/benchmarks/mcad-cq-bench.js.map +1 -0
- package/dist/benchmarks/mcad-cq-golds.d.ts +36 -0
- package/dist/benchmarks/mcad-cq-golds.js +342 -0
- package/dist/benchmarks/mcad-cq-golds.js.map +1 -0
- package/dist/benchmarks/mcad-golds.d.ts +20 -0
- package/dist/benchmarks/mcad-golds.js +318 -0
- package/dist/benchmarks/mcad-golds.js.map +1 -0
- package/dist/benchmarks/mcad-tasks.d.ts +66 -0
- package/dist/benchmarks/mcad-tasks.js +508 -0
- package/dist/benchmarks/mcad-tasks.js.map +1 -0
- package/dist/benchmarks/simpleqa.js +15 -19
- package/dist/benchmarks/simpleqa.js.map +1 -1
- package/dist/benchmarks/trata-hedge.js +20 -23
- package/dist/benchmarks/trata-hedge.js.map +1 -1
- package/dist/{cadbench-BrpwOU6A.js → cadbench-BLSyxR1N.js} +21 -20
- package/dist/cadbench-BLSyxR1N.js.map +1 -0
- package/dist/{cadgenbench-DF7hYHdl.js → cadgenbench-x2OFkf8y.js} +2 -2
- package/dist/cadgenbench-x2OFkf8y.js.map +1 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +10 -9
- package/dist/index.js.map +1 -1
- package/dist/router-turn-C2wMiDoo.js +31 -0
- package/dist/router-turn-C2wMiDoo.js.map +1 -0
- package/package.json +7 -7
- package/scripts/appworld_driver.py +1 -170
- package/scripts/run-package-tests.mjs +56 -36
- package/scripts/run-package-tests.test.mjs +33 -0
- package/scripts/trata-hedge/README.md +3 -3
- package/scripts/trata-hedge/run.sh +6 -2
- package/src/adapters.ts +11 -0
- package/src/aec-gate.mts +30 -9
- package/src/agent-graphs-gen2.mts +523 -0
- package/src/agent-graphs-gen3.mts +660 -0
- package/src/agent-graphs-improve/offline-seams.mts +128 -0
- package/src/agent-graphs-improve.mts +747 -0
- package/src/atom-humaneval.mts +76 -55
- package/src/atom-mcp-e2e.mts +27 -13
- package/src/benchmarks/appworld.test.mts +119 -1
- package/src/benchmarks/appworld.ts +156 -33
- package/src/benchmarks/cadbench.ts +21 -8
- package/src/benchmarks/finresearchbench.ts +20 -16
- package/src/benchmarks/finsearchcomp.ts +20 -20
- package/src/benchmarks/frames.ts +19 -18
- package/src/benchmarks/mcad-bench.test.mts +455 -0
- package/src/benchmarks/mcad-bench.ts +561 -0
- package/src/benchmarks/mcad-cq-bench.ts +423 -0
- package/src/benchmarks/mcad-cq-golds.ts +374 -0
- package/src/benchmarks/mcad-cq.test.mts +386 -0
- package/src/benchmarks/mcad-golds.ts +359 -0
- package/src/benchmarks/mcad-tasks.ts +490 -0
- package/src/benchmarks/simpleqa.ts +19 -17
- package/src/benchmarks/trata-hedge.ts +26 -18
- package/src/browser/ui-reviewer.ts +1 -1
- package/src/clbench-context-gate.mts +45 -9
- package/src/cloud-loop.mts +24 -6
- package/src/commit0-env-run.mts +6 -3
- package/src/commit0-env.ts +2 -3
- package/src/commit0-gate.mts +53 -24
- package/src/corpus.ts +3 -3
- package/src/david-attribution.mts +28 -9
- package/src/david-goliath.mts +31 -12
- package/src/egress-probe.mts +3 -3
- package/src/examples/lean-proof-gate.mts +10 -3
- package/src/examples/math-demo.mts +10 -3
- package/src/examples/strategy-demo.mts +10 -4
- package/src/fleet.mts +30 -8
- package/src/gate-cli.mts +9 -4
- package/src/gate.test.mts +7 -2
- package/src/gate.ts +23 -75
- package/src/generate-eval/certify.ts +13 -5
- package/src/hev-eval.mts +39 -10
- package/src/hev-improve.mts +28 -16
- package/src/hev-structural.mts +28 -20
- package/src/humaneval-gate.mts +24 -8
- package/src/humaneval-object-ablation.mts +52 -14
- package/src/humaneval-repair-gate.mts +53 -14
- package/src/mbpp-structural.mts +28 -20
- package/src/mcp-mount-probe.mts +23 -11
- package/src/official-optimizer-config.mts +61 -12
- package/src/official-optimizer-config.test.mts +72 -3
- package/src/quant-arena/profiles/quant-researcher.profile.json +6 -1
- package/src/quant-arena/quant-loop.mts +88 -61
- package/src/research-shot.ts +21 -11
- package/src/resolve-client.ts +6 -8
- package/src/rollout-ledger/backfill-swe-arena.test.mts +7 -2
- package/src/router-executor.ts +1 -1
- package/src/router-turn.ts +135 -0
- package/src/run-benchmarks.ts +7 -2
- package/src/sandbox-run.ts +23 -41
- package/src/search-bench/bridge.ts +66 -8
- package/src/search-bench/parametric-check.mts +19 -4
- package/src/search-bench/profiles.ts +24 -9
- package/src/search-bench/run.mts +3 -4
- package/src/smoke-structural-rollout.mts +21 -5
- package/src/supervisor-arena.mts +28 -20
- package/src/swe-arena/arms.ts +73 -15
- package/src/swe-arena/capacity.ts +44 -41
- package/src/swe-arena/diagnosis-ensemble.ts +51 -48
- package/src/swe-arena/execution.test.mts +12 -12
- package/src/swe-arena/gepa-seat.mts +12 -5
- package/src/swe-arena/gepa-seat.test.mts +45 -33
- package/src/swe-arena/outer-loop.mts +94 -70
- package/src/swe-arena/premeasured-from-cells.mts +16 -1
- package/src/swe-arena/premeasured-from-cells.test.mts +21 -1
- package/src/swe-arena/profiles/deepseek-author.profile.json +12 -0
- package/src/swe-arena/profiles/default-author.profile.json +9 -1
- package/src/swe-arena/proposer-fanout.mts +52 -99
- package/src/swe-arena/proposer-fanout.test.mts +92 -57
- package/src/swe-arena/proposer-provenance.mts +1 -1
- package/src/swe-code-improve.mts +30 -52
- package/src/swe-emit-patch.mts +26 -5
- package/src/swe-improve.mts +27 -8
- package/src/swe-jail.test.ts +128 -0
- package/src/swe-jail.ts +106 -22
- package/src/swe-local-proof.mts +27 -5
- package/src/swe-repro-calibrate.mts +5 -0
- package/src/swe-self-improve.mts +45 -10
- package/src/swe-stream.mts +68 -6
- package/src/swe-structural.mts +46 -7
- package/src/tb-container-executor.mts +38 -6
- package/src/tb-container-executor.test.mts +11 -3
- package/src/tb-supervisor-sidecar.mts +11 -27
- package/src/terminal-compare.ts +38 -18
- package/src/trata-gate.mts +26 -26
- package/src/trata-gepa.mts +25 -16
- package/src/trata-hedge-solve.mts +80 -0
- package/src/worker-blender.ts +21 -11
- package/src/worker-browser.ts +18 -5
- package/src/worker-build123d.ts +17 -8
- package/src/worker-cad.ts +31 -12
- package/src/worker.ts +1 -1
- package/tb_agents/opencode_router_agent.py +84 -42
- package/dist/cadbench-BrpwOU6A.js.map +0 -1
- package/dist/cadgenbench-DF7hYHdl.js.map +0 -1
- package/scripts/trata-hedge/solve.py +0 -83
|
@@ -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
|
+
}
|