@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.
- package/CHANGELOG.md +8 -0
- package/HARNESS.md +1 -1
- package/dist/adapters.js +4 -0
- package/dist/adapters.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/package.json +4 -4
- package/src/adapters.ts +11 -0
- 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/swe-arena/gepa-seat.mts +1 -1
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCAD-CQ adapter — the v2 runner that closes v1's format deviation.
|
|
3
|
+
*
|
|
4
|
+
* `mcad-tasks.ts` states the deviation in its header: the upstream text-to-cad
|
|
5
|
+
* benchmark asks for STEP, and OpenSCAD cannot emit it, so v1 grades an STL that
|
|
6
|
+
* OpenSCAD compiled. This adapter runs the SAME ten dimensioned parts against the
|
|
7
|
+
* SAME spec assertions, but the worker's artifact is a Python CadQuery script that
|
|
8
|
+
* must export BOTH `part.step` (the format upstream actually asks for) and
|
|
9
|
+
* `part.stl` (ASCII — what the geometry engine measures).
|
|
10
|
+
*
|
|
11
|
+
* Everything downstream of the mesh is v1's engine, IMPORTED not copied:
|
|
12
|
+
* `parseAsciiStl` / `measureMesh` / `pointInSolid` / `scoreAgainstSpec` /
|
|
13
|
+
* `stripCodeFence` all come from `./mcad-bench`, and the specs come from
|
|
14
|
+
* `./mcad-tasks`. Only two things differ:
|
|
15
|
+
* 1. the deliverable preamble (Python + CadQuery, not OpenSCAD) — applied by
|
|
16
|
+
* mapping at load time, so v1's prompt strings are never mutated;
|
|
17
|
+
* 2. one EXTRA scored check, `stepEmitted`: `part.step` exists and begins with
|
|
18
|
+
* `ISO-10303-21` (the ISO 10303-21 exchange-file magic every STEP file
|
|
19
|
+
* opens with). It is scored like any other assertion, so a script that
|
|
20
|
+
* emits only the mesh cannot reach 1.0 no matter how correct its geometry.
|
|
21
|
+
*
|
|
22
|
+
* Pipeline, in order:
|
|
23
|
+
* 1. run : `<python> model.py` in a fresh temp dir, 120 s hard deadline
|
|
24
|
+
* 2. deliver : `part.stl` exists and is ASCII (a binary STL is a miss, not a
|
|
25
|
+
* parse problem — the prompt pins the format)
|
|
26
|
+
* 3. watertight: every undirected edge shared by exactly two faces
|
|
27
|
+
* 4. measure : bbox / volume / connected components / triangle count
|
|
28
|
+
* 5. probe : ray-parity point-in-solid at the spec's pinned coordinates
|
|
29
|
+
* 6. step : the `stepEmitted` check above
|
|
30
|
+
* A non-watertight mesh scores 0 for the same reason as v1: an unclosed surface
|
|
31
|
+
* has no well-defined interior, so every downstream number would be fiction.
|
|
32
|
+
*
|
|
33
|
+
* SAFETY, stated once and deliberately. The judge EXECUTES the worker's Python on
|
|
34
|
+
* the host with no sandbox. That is the same trust level as v1 handing arbitrary
|
|
35
|
+
* source to the OpenSCAD kernel, and it carries the same rule: run this judge only
|
|
36
|
+
* on artifacts you would be willing to run by hand. The child process is given a
|
|
37
|
+
* scrubbed environment — no inherited variables beyond `PATH`, `HOME`/`TMPDIR`
|
|
38
|
+
* pointed at the scratch directory, and proxy variables pointed at a closed port —
|
|
39
|
+
* which DISCOURAGES network access and keeps host secrets out of the script's
|
|
40
|
+
* reach. It is not a network jail and does not claim to be one; put the judge in a
|
|
41
|
+
* container if you need that guarantee.
|
|
42
|
+
*
|
|
43
|
+
* Requires an interpreter that can `import cadquery` at JUDGE time only.
|
|
44
|
+
* Resolution order: `MCAD_CQ_PYTHON` (absolute path) → `bench/.venv-cadquery/bin/python`
|
|
45
|
+
* → throw with the exact install command (see `CADQUERY_INSTALL_FIX`).
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { execFile } from 'node:child_process'
|
|
49
|
+
import { existsSync } from 'node:fs'
|
|
50
|
+
import { mkdtemp, open, readFile, writeFile } from 'node:fs/promises'
|
|
51
|
+
import { tmpdir } from 'node:os'
|
|
52
|
+
import { isAbsolute, join } from 'node:path'
|
|
53
|
+
import { promisify } from 'node:util'
|
|
54
|
+
import { venvPythonAt } from './_harness'
|
|
55
|
+
import {
|
|
56
|
+
type McadCheck,
|
|
57
|
+
type McadGeometry,
|
|
58
|
+
type McadScoring,
|
|
59
|
+
type McadTaskMeta,
|
|
60
|
+
type Tri,
|
|
61
|
+
measureMesh,
|
|
62
|
+
parseAsciiStl,
|
|
63
|
+
scoreAgainstSpec,
|
|
64
|
+
stripCodeFence,
|
|
65
|
+
} from './mcad-bench'
|
|
66
|
+
import { MCAD_CQ_GOLDS, MCAD_CQ_UNCALIBRATED } from './mcad-cq-golds'
|
|
67
|
+
import { MCAD_DELIVERABLE, MCAD_DELIVERABLE_MULTIBODY, MCAD_TASKS, type McadTask } from './mcad-tasks'
|
|
68
|
+
import type { BenchmarkAdapter, BenchScore, BenchTask, LoadOptions } from './types'
|
|
69
|
+
|
|
70
|
+
const execFileAsync = promisify(execFile)
|
|
71
|
+
|
|
72
|
+
/** Package-relative venv, resolved through `_harness`'s bench-root lookup. */
|
|
73
|
+
export const CADQUERY_VENV = '.venv-cadquery'
|
|
74
|
+
|
|
75
|
+
/** Files the worker's script must leave in its working directory. */
|
|
76
|
+
export const STL_NAME = 'part.stl'
|
|
77
|
+
export const STEP_NAME = 'part.step'
|
|
78
|
+
|
|
79
|
+
/** Every ISO 10303-21 exchange file (a "STEP file") starts with this token. */
|
|
80
|
+
export const STEP_MAGIC = 'ISO-10303-21'
|
|
81
|
+
|
|
82
|
+
/** Hard deadline for one worker script. A hang is a miss, not a stall. */
|
|
83
|
+
export const MCAD_CQ_TIMEOUT_MS = 120_000
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The exact, VERIFIED install command. Two pins here are load-bearing and neither
|
|
87
|
+
* is optional on a current host:
|
|
88
|
+
* - `--python 3.12`: cadquery 2.4.0 resolves to `cadquery-ocp` 7.7.2, which ships
|
|
89
|
+
* wheels for cp38–cp312 only. On a default CPython 3.13 the resolve fails.
|
|
90
|
+
* - `'numpy<2'`: cadquery 2.4.0 pins `nptyping==2.0.1`, whose module body reads
|
|
91
|
+
* `np.bool8` — removed in numpy 2. Without the pin `import cadquery` raises
|
|
92
|
+
* `AttributeError: module 'numpy' has no attribute 'bool8'` at import time.
|
|
93
|
+
*/
|
|
94
|
+
export const CADQUERY_INSTALL_FIX =
|
|
95
|
+
'Fix: create the isolated CadQuery venv (needs `uv`):\n' +
|
|
96
|
+
' cd bench && uv venv --python 3.12 .venv-cadquery \\\n' +
|
|
97
|
+
" && uv pip install --python .venv-cadquery/bin/python 'cadquery==2.4.0' 'numpy<2'\n" +
|
|
98
|
+
'Both pins are required: cadquery-ocp 7.7.2 has no cp313 wheel, and cadquery 2.4.0 pins ' +
|
|
99
|
+
"nptyping 2.0.1, which reads the numpy-1.x-only `np.bool8` at import.\n" +
|
|
100
|
+
'Or set MCAD_CQ_PYTHON to the ABSOLUTE path of any interpreter that can `import cadquery`.'
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the CadQuery interpreter. Never falls back to a system `python`: a
|
|
104
|
+
* silent fallback would either fail deep inside a worker script or — worse — run
|
|
105
|
+
* against a different CadQuery than the golds were calibrated on.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveCadqueryPython(
|
|
108
|
+
env: Readonly<{ MCAD_CQ_PYTHON?: string }> = process.env,
|
|
109
|
+
): string {
|
|
110
|
+
const configured = env.MCAD_CQ_PYTHON
|
|
111
|
+
if (configured === undefined) return venvPythonAt(CADQUERY_VENV)
|
|
112
|
+
if (!isAbsolute(configured)) throw new Error(`MCAD_CQ_PYTHON must be an absolute path (got ${JSON.stringify(configured)})`)
|
|
113
|
+
return configured
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The one message shape for "there is no usable interpreter" — HARNESS.md style. */
|
|
117
|
+
export function cadqueryMissingError(python: string, cause?: string): Error {
|
|
118
|
+
return new Error(
|
|
119
|
+
`mcad-cq: no CadQuery interpreter at ${python}${cause ? `\n${cause}` : ''}\n${CADQUERY_INSTALL_FIX}`,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Prompt mapping (v1's prompt strings are read, never written)
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
const CQ_EXPORT_STEP = `cq.exporters.export(result, "${STEP_NAME}")`
|
|
128
|
+
const CQ_EXPORT_STL =
|
|
129
|
+
`cq.exporters.export(result, "${STL_NAME}", exportType="STL", opt={"ascii": True}, ` +
|
|
130
|
+
'tolerance=0.01, angularTolerance=0.05)'
|
|
131
|
+
|
|
132
|
+
/** Shared tail: what the script must WRITE, which is the whole deliverable contract. */
|
|
133
|
+
const CQ_DELIVERABLE_TAIL =
|
|
134
|
+
'The script must run standalone under `python model.py` and write BOTH of these files into its ' +
|
|
135
|
+
`current working directory: \`${STEP_NAME}\` (STEP) and \`${STL_NAME}\` (ASCII STL, not binary). ` +
|
|
136
|
+
'Export with exactly these two lines:\n' +
|
|
137
|
+
` ${CQ_EXPORT_STEP}\n` +
|
|
138
|
+
` ${CQ_EXPORT_STL}\n` +
|
|
139
|
+
'Those tessellation tolerances are what make round features accurate — do not coarsen them. ' +
|
|
140
|
+
'Reply with ONLY the Python code.'
|
|
141
|
+
|
|
142
|
+
/** v2 replacement for `MCAD_DELIVERABLE`. */
|
|
143
|
+
export const MCAD_CQ_DELIVERABLE =
|
|
144
|
+
'Author a Python script using CadQuery (units: mm) that builds exactly this part as one fused solid ' +
|
|
145
|
+
'and binds it to a variable named `result`. ' +
|
|
146
|
+
CQ_DELIVERABLE_TAIL
|
|
147
|
+
|
|
148
|
+
/** v2 replacement for `MCAD_DELIVERABLE_MULTIBODY` — task 10 only, same reason as v1. */
|
|
149
|
+
export const MCAD_CQ_DELIVERABLE_MULTIBODY =
|
|
150
|
+
'Author a Python script using CadQuery (units: mm) that builds exactly this assembly as the separate ' +
|
|
151
|
+
'solid bodies listed below and binds them to a variable named `result` (a `cq.Compound` of those bodies, ' +
|
|
152
|
+
'or a Workplane holding all of them). The bodies must stay disjoint — no two of them may touch or ' +
|
|
153
|
+
'intersect. ' +
|
|
154
|
+
CQ_DELIVERABLE_TAIL
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Swap the deliverable preamble for the CadQuery one, leaving the upstream
|
|
158
|
+
* dimensional text byte-identical. Fails loud rather than guessing: a task whose
|
|
159
|
+
* prompt does not open with one of v1's two known preambles means v1 changed
|
|
160
|
+
* shape, and silently shipping a prompt with two contradictory deliverables is
|
|
161
|
+
* exactly the failure this function exists to prevent.
|
|
162
|
+
*/
|
|
163
|
+
export function toCadQueryPrompt(task: McadTask): string {
|
|
164
|
+
for (const [v1, v2] of [
|
|
165
|
+
[MCAD_DELIVERABLE_MULTIBODY, MCAD_CQ_DELIVERABLE_MULTIBODY],
|
|
166
|
+
[MCAD_DELIVERABLE, MCAD_CQ_DELIVERABLE],
|
|
167
|
+
] as const) {
|
|
168
|
+
if (task.prompt.startsWith(v1)) return v2 + task.prompt.slice(v1.length)
|
|
169
|
+
}
|
|
170
|
+
throw new Error(
|
|
171
|
+
`mcad-cq: task ${task.id} does not open with a known mcad deliverable preamble; ` +
|
|
172
|
+
'mcad-tasks.ts changed shape and the CadQuery prompt mapping must be updated with it',
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Running the worker's script
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The child's whole environment. Nothing is inherited except `PATH` (the
|
|
182
|
+
* interpreter needs its own toolchain on it) and `LANG`. `HOME`/`TMPDIR` point at
|
|
183
|
+
* the scratch dir so the script cannot write to the operator's home, and the proxy
|
|
184
|
+
* variables point at port 9 (discard), which every mainstream HTTP client honours.
|
|
185
|
+
* DISCOURAGEMENT, not a jail — see the file header.
|
|
186
|
+
*/
|
|
187
|
+
function scriptEnv(dir: string): NodeJS.ProcessEnv {
|
|
188
|
+
const blackhole = 'http://127.0.0.1:9'
|
|
189
|
+
return {
|
|
190
|
+
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
191
|
+
LANG: process.env.LANG ?? 'C.UTF-8',
|
|
192
|
+
HOME: dir,
|
|
193
|
+
TMPDIR: dir,
|
|
194
|
+
PYTHONDONTWRITEBYTECODE: '1',
|
|
195
|
+
PYTHONHASHSEED: '0',
|
|
196
|
+
http_proxy: blackhole,
|
|
197
|
+
https_proxy: blackhole,
|
|
198
|
+
HTTP_PROXY: blackhole,
|
|
199
|
+
HTTPS_PROXY: blackhole,
|
|
200
|
+
no_proxy: '',
|
|
201
|
+
NO_PROXY: '',
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Reject anything that is not an ASCII STL. A binary STL also opens with the
|
|
207
|
+
* bytes `solid` often enough that the header alone is not a test, so the NUL scan
|
|
208
|
+
* carries the decision: ASCII STL is printable text end to end.
|
|
209
|
+
*/
|
|
210
|
+
export function asciiStlProblem(buf: Buffer): string | undefined {
|
|
211
|
+
if (buf.length === 0) return `${STL_NAME} is empty`
|
|
212
|
+
if (buf.includes(0)) return `${STL_NAME} is not ASCII (contains NUL bytes — it looks like a binary STL)`
|
|
213
|
+
const head = buf.subarray(0, 64).toString('latin1')
|
|
214
|
+
if (!/^\s*solid\b/.test(head)) return `${STL_NAME} does not start with "solid" (got ${JSON.stringify(head.slice(0, 32))})`
|
|
215
|
+
if (!/\bfacet\s+normal\b/.test(buf.toString('utf8'))) return `${STL_NAME} contains no "facet normal" records`
|
|
216
|
+
return undefined
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** `part.step` exists AND opens with the ISO 10303-21 magic. Reads 12 bytes, not the file. */
|
|
220
|
+
export async function stepFileEmitted(path: string): Promise<boolean> {
|
|
221
|
+
let handle: Awaited<ReturnType<typeof open>>
|
|
222
|
+
try {
|
|
223
|
+
handle = await open(path, 'r')
|
|
224
|
+
} catch {
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
const buf = Buffer.alloc(STEP_MAGIC.length)
|
|
229
|
+
const { bytesRead } = await handle.read(buf, 0, buf.length, 0)
|
|
230
|
+
return bytesRead === buf.length && buf.toString('latin1') === STEP_MAGIC
|
|
231
|
+
} finally {
|
|
232
|
+
await handle.close()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export type McadCqBuild =
|
|
237
|
+
| { ok: true; geo: McadGeometry; tris: Tri[]; dir: string; stlPath: string; stepEmitted: boolean }
|
|
238
|
+
| { ok: false; detail: string; dir: string; stepEmitted: boolean }
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Run one CadQuery script and measure what it delivered. Judge-time only.
|
|
242
|
+
* Throws (never scores) when there is no usable interpreter — an absent toolchain
|
|
243
|
+
* is an operator defect, and scoring it 0 would silently poison a whole corpus.
|
|
244
|
+
*/
|
|
245
|
+
export async function runAndMeasureCadQuery(src: string, timeoutMs = MCAD_CQ_TIMEOUT_MS): Promise<McadCqBuild> {
|
|
246
|
+
const python = resolveCadqueryPython()
|
|
247
|
+
if (!existsSync(python)) throw cadqueryMissingError(python)
|
|
248
|
+
|
|
249
|
+
const dir = await mkdtemp(join(tmpdir(), 'mcad-cq-'))
|
|
250
|
+
const stlPath = join(dir, STL_NAME)
|
|
251
|
+
const stepPath = join(dir, STEP_NAME)
|
|
252
|
+
await writeFile(join(dir, 'model.py'), `${src}\n`)
|
|
253
|
+
|
|
254
|
+
const fail = async (detail: string): Promise<McadCqBuild> => ({
|
|
255
|
+
ok: false,
|
|
256
|
+
detail,
|
|
257
|
+
dir,
|
|
258
|
+
stepEmitted: await stepFileEmitted(stepPath),
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
await execFileAsync(python, ['model.py'], {
|
|
263
|
+
cwd: dir,
|
|
264
|
+
env: scriptEnv(dir),
|
|
265
|
+
timeout: timeoutMs,
|
|
266
|
+
killSignal: 'SIGKILL',
|
|
267
|
+
maxBuffer: 1024 * 1024 * 64,
|
|
268
|
+
})
|
|
269
|
+
} catch (err) {
|
|
270
|
+
const e = err as NodeJS.ErrnoException & { killed?: boolean; stderr?: string }
|
|
271
|
+
if (e.killed) return fail(`script timed out after ${timeoutMs} ms and was killed`)
|
|
272
|
+
const stderr = (e.stderr ?? e.message ?? '').trim()
|
|
273
|
+
return fail(`script failed: ${stderr.slice(-800)}`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let stl: Buffer
|
|
277
|
+
try {
|
|
278
|
+
stl = await readFile(stlPath)
|
|
279
|
+
} catch {
|
|
280
|
+
return fail(`script wrote no ${STL_NAME} in its working directory`)
|
|
281
|
+
}
|
|
282
|
+
const bad = asciiStlProblem(stl)
|
|
283
|
+
if (bad) return fail(bad)
|
|
284
|
+
|
|
285
|
+
const tris = parseAsciiStl(stl.toString('utf8'))
|
|
286
|
+
if (tris.length === 0) return fail(`${STL_NAME} parsed to zero triangles (empty geometry)`)
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
ok: true,
|
|
290
|
+
geo: measureMesh(tris),
|
|
291
|
+
tris,
|
|
292
|
+
dir,
|
|
293
|
+
stlPath,
|
|
294
|
+
stepEmitted: await stepFileEmitted(stepPath),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Scoring
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Append the STEP-delivery check to a v1 scoring and recompute. It is one check
|
|
304
|
+
* among the spec's, deliberately: the upstream benchmark's deliverable IS a STEP
|
|
305
|
+
* file, so failing to produce one costs exactly what failing a dimension costs.
|
|
306
|
+
*/
|
|
307
|
+
export function withStepCheck(scored: McadScoring, stepEmitted: boolean): McadScoring {
|
|
308
|
+
const check: McadCheck = {
|
|
309
|
+
name: 'stepEmitted',
|
|
310
|
+
ok: stepEmitted,
|
|
311
|
+
measured: stepEmitted ? `${STEP_NAME} starts with ${STEP_MAGIC}` : `no ${STEP_NAME} starting with ${STEP_MAGIC}`,
|
|
312
|
+
expected: `${STEP_NAME} exists and starts with ${STEP_MAGIC}`,
|
|
313
|
+
}
|
|
314
|
+
const checks = [...scored.checks, check]
|
|
315
|
+
const failed = checks.filter((c) => !c.ok)
|
|
316
|
+
return { checks, failed, score: (checks.length - failed.length) / checks.length, resolved: failed.length === 0 }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Adapter
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
export function createMcadCqAdapter(): BenchmarkAdapter {
|
|
324
|
+
return {
|
|
325
|
+
name: 'mcad-cq',
|
|
326
|
+
|
|
327
|
+
async preflight() {
|
|
328
|
+
const python = resolveCadqueryPython()
|
|
329
|
+
if (!existsSync(python)) throw cadqueryMissingError(python)
|
|
330
|
+
try {
|
|
331
|
+
await execFileAsync(python, ['-c', 'import cadquery; print(cadquery.__version__)'], {
|
|
332
|
+
timeout: 300_000,
|
|
333
|
+
env: scriptEnv(tmpdir()),
|
|
334
|
+
})
|
|
335
|
+
} catch (err) {
|
|
336
|
+
const e = err as NodeJS.ErrnoException & { stderr?: string }
|
|
337
|
+
throw cadqueryMissingError(python, `\`import cadquery\` failed: ${(e.stderr ?? e.message ?? '').trim().slice(-800)}`)
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
async loadTasks(opts: LoadOptions = {}) {
|
|
342
|
+
let tasks: McadTask[] = MCAD_TASKS
|
|
343
|
+
if (opts.ids) tasks = tasks.filter((t) => opts.ids?.includes(t.id))
|
|
344
|
+
if (opts.limit != null) tasks = tasks.slice(0, opts.limit)
|
|
345
|
+
return tasks.map(
|
|
346
|
+
(t): BenchTask => ({
|
|
347
|
+
id: t.id,
|
|
348
|
+
prompt: toCadQueryPrompt(t),
|
|
349
|
+
metadata: {
|
|
350
|
+
spec: t.spec,
|
|
351
|
+
source: t.source,
|
|
352
|
+
// Calibration is per-ADAPTER: a task calibrated against the OpenSCAD
|
|
353
|
+
// gold says nothing about whether a CadQuery gold reaches 1.0 here.
|
|
354
|
+
calibrated: t.calibrated && !MCAD_CQ_UNCALIBRATED.has(t.id),
|
|
355
|
+
} satisfies McadTaskMeta,
|
|
356
|
+
}),
|
|
357
|
+
)
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
async goldArtifact(task: BenchTask) {
|
|
361
|
+
return MCAD_CQ_GOLDS[task.id]
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
async judge(task: BenchTask, artifact: string): Promise<BenchScore> {
|
|
365
|
+
const spec = (task.metadata as McadTaskMeta | undefined)?.spec
|
|
366
|
+
if (!spec) return { resolved: false, score: 0, detail: `task ${task.id} carries no mcad spec` }
|
|
367
|
+
|
|
368
|
+
const src = stripCodeFence(artifact)
|
|
369
|
+
if (!src) return { resolved: false, score: 0, detail: 'empty artifact' }
|
|
370
|
+
|
|
371
|
+
const built = await runAndMeasureCadQuery(src)
|
|
372
|
+
if (!built.ok) {
|
|
373
|
+
return { resolved: false, score: 0, detail: `${built.detail} [dir ${built.dir}, stepEmitted ${built.stepEmitted}]` }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const { geo, tris, stlPath, stepEmitted, dir } = built
|
|
377
|
+
if (!geo.watertight) {
|
|
378
|
+
return {
|
|
379
|
+
resolved: false,
|
|
380
|
+
score: 0,
|
|
381
|
+
detail: JSON.stringify({
|
|
382
|
+
gate: 'watertight',
|
|
383
|
+
failed: [
|
|
384
|
+
{
|
|
385
|
+
name: 'watertight',
|
|
386
|
+
measured: `${geo.openEdges} edge(s) not shared by exactly two faces`,
|
|
387
|
+
expected: '0 open edges (closed 2-manifold solid)',
|
|
388
|
+
},
|
|
389
|
+
],
|
|
390
|
+
geo: { triangles: geo.triangles, degenerateFaces: geo.degenerateFaces },
|
|
391
|
+
stepEmitted,
|
|
392
|
+
stlPath,
|
|
393
|
+
dir,
|
|
394
|
+
}),
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const scored = withStepCheck(scoreAgainstSpec(geo, tris, spec), stepEmitted)
|
|
399
|
+
return {
|
|
400
|
+
resolved: scored.resolved,
|
|
401
|
+
score: scored.score,
|
|
402
|
+
detail: JSON.stringify({
|
|
403
|
+
failed: scored.failed.map((c) => ({ name: c.name, measured: c.measured, expected: c.expected })),
|
|
404
|
+
checks: Object.fromEntries(scored.checks.map((c) => [c.name, c.ok])),
|
|
405
|
+
geo: {
|
|
406
|
+
triangles: geo.triangles,
|
|
407
|
+
solids: geo.solids,
|
|
408
|
+
volume: +geo.volume.toFixed(2),
|
|
409
|
+
degenerateFaces: geo.degenerateFaces,
|
|
410
|
+
bbox: {
|
|
411
|
+
x: +geo.bbox.size.x.toFixed(3),
|
|
412
|
+
y: +geo.bbox.size.y.toFixed(3),
|
|
413
|
+
z: +geo.bbox.size.z.toFixed(3),
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
stepEmitted,
|
|
417
|
+
stlPath,
|
|
418
|
+
dir,
|
|
419
|
+
}),
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
}
|
|
423
|
+
}
|