Files
cadSet/designir-pipeline/scripts/trajectory_pipeline.mjs
2026-07-28 14:41:26 +08:00

736 lines
25 KiB
JavaScript

#!/usr/bin/env node
/**
* Build execution-validated STEP -> DesignIR trajectories.
*
* Agent A is an OpenAI-compatible language model. Agent B is the deterministic
* CAD-kernel acceptance program. The teacher STEP is never included in Agent A
* input and is used only by Agent B after isolated reconstruction.
*/
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PIPELINE = path.resolve(HERE, "..");
const REPO = path.resolve(PIPELINE, "..");
const requireFromStudio = createRequire(
path.join(REPO, "cad-agent-studio", "package.json"),
);
const YAML = requireFromStudio("yaml");
const CONFIG_PATH = path.join(REPO, "llm.config.yaml");
const EVIDENCE_DIR = path.join(PIPELINE, "runs", "evidence");
const TRAJECTORY_DIR = path.join(PIPELINE, "runs", "trajectory");
const SPLIT_PATH = path.join(TRAJECTORY_DIR, "splits.json");
const PYTHON = path.join(REPO, "text-to-cad", ".venv", "bin", "python");
const DESIGNIR_CLI = path.join(PIPELINE, "scripts", "designir_pipeline.py");
const EXAMPLE_PATH = path.join(
PIPELINE,
"examples",
"raised_hub_flange.designir.json",
);
function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function writeJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
}
function argsObject(argv) {
const result = { _: [] };
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (!value.startsWith("--")) {
result._.push(value);
continue;
}
const key = value.slice(2);
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
result[key] = true;
} else {
result[key] = next;
index += 1;
}
}
return result;
}
function evidenceFiles() {
return fs
.readdirSync(EVIDENCE_DIR)
.filter((name) => name.endsWith(".case.json"))
.map((name) => path.join(EVIDENCE_DIR, name))
.sort();
}
function allocate(total, counts) {
const population = Object.values(counts).reduce((sum, value) => sum + value, 0);
const rows = Object.entries(counts).map(([family, count]) => {
const exact = (count * total) / population;
return { family, value: Math.floor(exact), remainder: exact % 1 };
});
let remaining = total - rows.reduce((sum, row) => sum + row.value, 0);
rows.sort((left, right) => right.remainder - left.remainder);
for (const row of rows) {
if (remaining <= 0) break;
row.value += 1;
remaining -= 1;
}
return Object.fromEntries(rows.map((row) => [row.family, row.value]));
}
function initSplits() {
const unique = new Map();
for (const file of evidenceFiles()) {
const payload = readJson(file);
const geometry = payload.geometry_evidence || {};
if (!geometry.reconstruction_eligible) continue;
const id = String(payload.provenance?.source_sha256 || payload.case_id);
if (unique.has(id)) continue;
unique.set(id, {
id,
family: String(payload.design_ir?.part_family || "unknown"),
evidence: file,
teacher: String(payload.provenance?.source_path || ""),
face_count: Number(geometry.face_count || 0),
});
}
const byFamily = {};
for (const item of unique.values()) {
(byFamily[item.family] ||= []).push(item);
}
for (const items of Object.values(byFamily)) {
items.sort((left, right) => left.id.localeCompare(right.id));
}
const counts = Object.fromEntries(
Object.entries(byFamily).map(([family, items]) => [family, items.length]),
);
const testQuota = allocate(100, counts);
const validationQuota = allocate(75, counts);
const splits = { train: [], validation: [], test: [] };
for (const [family, items] of Object.entries(byFamily)) {
const testCount = testQuota[family] || 0;
const validationCount = validationQuota[family] || 0;
splits.test.push(...items.slice(0, testCount));
splits.validation.push(
...items.slice(testCount, testCount + validationCount),
);
splits.train.push(...items.slice(testCount + validationCount));
}
for (const values of Object.values(splits)) {
values.sort(
(left, right) =>
left.face_count - right.face_count || left.id.localeCompare(right.id),
);
}
const payload = {
schema_version: "1.0",
split_kind: "sha256_isolated_stratified_trajectory_split",
policy: {
train_count: splits.train.length,
validation_count: splits.validation.length,
test_count: splits.test.length,
teacher_step_visible_to_reconstruction_agent: false,
heldout_cases_allowed_in_experience_induction: false,
},
splits,
};
writeJson(SPLIT_PATH, payload);
return payload;
}
function loadProvider(requestedProvider, requestedModel) {
const config = YAML.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
const providerId = requestedProvider || config.defaultProvider;
const provider = config.providers[providerId];
if (!provider) throw new Error(`Unknown provider ${providerId}`);
const apiKey =
String(provider.apiKey || "").trim() ||
process.env[String(provider.apiKeyEnv || "")] ||
"";
if (!apiKey) throw new Error(`No API key configured for ${providerId}`);
const defaultModel = String(config.defaultModel || "").split(":").pop();
const model =
requestedModel || provider.models?.default || defaultModel;
return {
providerId,
model,
apiKey,
baseURL: String(provider.baseURL).replace(/\/+$/, ""),
};
}
function compactEvidence(payload, maxSurfaces = 80) {
const geometry = payload.geometry_evidence || {};
const design = payload.design_ir || {};
return {
evidence_kind: "final_brep_observation_not_source_history",
geometry: {
solid_count: geometry.solid_count,
face_count: geometry.face_count,
volume: geometry.volume,
bounding_box: geometry.bounding_box,
analytic_surface_counts: geometry.analytic_surface_counts,
analytic_surfaces: (geometry.analytic_surfaces || []).slice(0, maxSurfaces),
},
inferred_hypotheses: {
family: design.part_family,
features: design.features,
constraints: design.constraints,
semantic_parameter_candidates: design.semantic_parameter_candidates,
},
};
}
function reconstructionPrompt(evidence, previous, feedback, experience = []) {
const example = readJson(EXAMPLE_PATH);
return [
"You are Agent A, a STEP reverse-engineering program synthesizer.",
"Generate an independent executable DesignIR 2.0 JSON object from final-BRep evidence.",
"The source STEP is unavailable. Never reference, import, embed, or describe it as a base feature.",
"STEP does not contain source history. Use canonical reconstruction order and explicitly inferred semantic names.",
"Current executable operations are exactly: extrude_circle, extrude_rectangle, add_cylinder, through_hole, polar_hole_pattern.",
"Cylinder and extrusion primitives may use any observed axis. Hole cutters currently require the positive Z axis.",
"Preserve the observed absolute coordinate frame and orientation.",
"For cylinder and extrusion features, set anchor to geometric_center and center to the observed 3D bounding-box center unless there is strong evidence for another placement.",
"The legacy/default anchor is base_center; never rely on that default for reverse engineering.",
"A value reference object may contain exactly one key: parameter or expression. Do not add scale or arithmetic keys.",
"Do not expose an editable parameter unless at least one feature directly references it. Non-driving measurements must be editable false or omitted.",
"Use named editable parameters such as plate_thickness, hole_diameter, hole_count, bolt_circle_diameter, bore_diameter, overall_length, overall_width.",
"Every editable parameter must be referenced by a feature.",
"Every perturbation must contain parameter, scale, expected_change, and preserve.",
"Use an empty list for constraints when none are justified.",
"Return JSON only, without Markdown.",
"",
"REFERENCE FORMAT:",
JSON.stringify(example),
"",
"PRIVATE GEOMETRY EVIDENCE:",
JSON.stringify(evidence),
experience.length
? `\nVALIDATED CANDIDATE METHODS (apply only when relevant):\n${JSON.stringify(experience)}`
: "",
previous ? `\nPREVIOUS CANDIDATE:\n${JSON.stringify(previous)}` : "",
feedback ? `\nVALIDATOR FEEDBACK:\n${feedback}` : "",
]
.filter(Boolean)
.join("\n");
}
async function callModel(provider, prompt) {
const response = await fetch(`${provider.baseURL}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: provider.model,
temperature: 0.2,
messages: [
{
role: "system",
content:
"Produce normalized, editable parametric CAD design states. Output strict JSON.",
},
{ role: "user", content: prompt },
],
response_format: { type: "json_object" },
}),
signal: AbortSignal.timeout(90_000),
});
if (!response.ok) {
const detail = (await response.text()).slice(0, 600);
throw new Error(`LLM HTTP ${response.status}: ${detail}`);
}
const payload = await response.json();
const content = payload.choices?.[0]?.message?.content;
if (!content) throw new Error("LLM returned no message content");
const cleaned = String(content)
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/, "");
return {
designir: JSON.parse(cleaned),
usage: payload.usage || {},
};
}
function runPython(arguments_) {
const result = spawnSync(PYTHON, [DESIGNIR_CLI, ...arguments_], {
cwd: REPO,
encoding: "utf8",
timeout: 120_000,
});
const output = `${result.stdout || ""}\n${result.stderr || ""}`.trim();
return { ok: result.status === 0, status: result.status, output };
}
async function runOne(job, provider, maxAttempts, options = {}) {
const shortId = job.id.slice(0, 16);
const root = path.join(
options.attemptsRoot || path.join(TRAJECTORY_DIR, "attempts"),
shortId,
);
fs.mkdirSync(root, { recursive: true });
const evidencePayload = readJson(job.evidence);
const evidence = compactEvidence(evidencePayload);
const unsupportedSurfaces = Object.entries(
evidence.geometry.analytic_surface_counts || {},
)
.filter(([kind, count]) => count > 0 && !["plane", "cylinder"].includes(kind))
.map(([kind]) => kind);
if (unsupportedSurfaces.length) {
const trajectory = {
schema_version: "1.0",
trajectory_kind: "execution_validated_step_to_designir",
case_id: job.id,
family: job.family,
provider: provider.providerId,
model: provider.model,
attempts: [],
state: "unsupported_feature_vocabulary",
missing_capabilities: unsupportedSurfaces.map(
(kind) => `reconstruct_${kind}_surface`,
),
};
writeJson(path.join(root, "trajectory.json"), trajectory);
return trajectory;
}
let previous = null;
let feedback = "";
const attempts = [];
for (let index = 1; index <= maxAttempts; index += 1) {
const started = Date.now();
try {
const generated = await callModel(
provider,
reconstructionPrompt(
evidence,
previous,
feedback,
options.experience || [],
),
);
const designir = generated.designir;
designir.model_id ||= `trajectory_${shortId}_${index}`;
designir.applied_experience_ids = (options.experience || []).map(
(method) => method.id,
);
const designirPath = path.join(root, `attempt-${index}.designir.json`);
const rebuiltPath = path.join(root, `attempt-${index}.step`);
const reportPath = path.join(root, `attempt-${index}.acceptance.json`);
writeJson(designirPath, designir);
const rebuild = runPython([
"isolated-rebuild",
designirPath,
"--output",
rebuiltPath,
"--backend",
"build123d",
]);
if (!rebuild.ok) {
feedback = `Isolated rebuild failed: ${rebuild.output}`;
attempts.push({
index,
state: "compile_failed",
duration_ms: Date.now() - started,
usage: generated.usage,
feedback,
});
previous = designir;
continue;
}
const acceptance = runPython([
"accept",
"--teacher",
job.teacher,
"--designir",
designirPath,
"--rebuilt",
rebuiltPath,
"--report",
reportPath,
]);
const report = fs.existsSync(reportPath) ? readJson(reportPath) : null;
const state =
acceptance.ok && report?.verdict === "pass"
? "replay_validated"
: "acceptance_failed";
attempts.push({
index,
state,
duration_ms: Date.now() - started,
usage: generated.usage,
report: reportPath,
});
if (state === "replay_validated") {
const trajectory = {
schema_version: "1.0",
trajectory_kind: "execution_validated_step_to_designir",
case_id: job.id,
split: job.split,
family: job.family,
provider: provider.providerId,
model: provider.model,
selected_attempt: index,
designir: designirPath,
rebuilt_step: rebuiltPath,
acceptance_report: reportPath,
attempts,
state,
};
writeJson(path.join(root, "trajectory.json"), trajectory);
return trajectory;
}
feedback = `Acceptance failed: ${JSON.stringify({
threshold_checks: report?.threshold_checks,
geometry: report?.geometry
? {
relative_volume_error: report.geometry.relative_volume_error,
bounding_box_axis_error_mm:
report.geometry.bounding_box_axis_error_mm,
symmetric_difference_ratio:
report.geometry.symmetric_difference_ratio,
teacher_facts: report.geometry.teacher,
rebuilt_facts: report.geometry.rebuilt,
}
: null,
editability: report?.editability,
})}`;
previous = designir;
} catch (error) {
feedback = error instanceof Error ? error.message : String(error);
attempts.push({
index,
state: "agent_failed",
duration_ms: Date.now() - started,
feedback,
});
}
}
const trajectory = {
schema_version: "1.0",
trajectory_kind: "execution_validated_step_to_designir",
case_id: job.id,
family: job.family,
provider: provider.providerId,
model: provider.model,
attempts,
state: "quarantine",
};
writeJson(path.join(root, "trajectory.json"), trajectory);
return trajectory;
}
function summarize() {
const root = path.join(TRAJECTORY_DIR, "attempts");
const rows = fs.existsSync(root)
? fs
.readdirSync(root)
.map((name) => path.join(root, name, "trajectory.json"))
.filter((file) => fs.existsSync(file))
.map(readJson)
: [];
const states = {};
for (const row of rows) states[row.state] = (states[row.state] || 0) + 1;
return {
schema_version: "1.0",
report_kind: "trajectory_run_summary",
case_count: rows.length,
states,
validated_trajectory_count: states.replay_validated || 0,
quarantined_count: states.quarantine || 0,
rows: rows.map((row) => ({
case_id: row.case_id,
family: row.family,
state: row.state,
attempt_count: row.attempts?.length || 0,
})),
};
}
function distillValidatedTrajectories() {
const root = path.join(TRAJECTORY_DIR, "attempts");
const trajectories = fs.existsSync(root)
? fs
.readdirSync(root)
.map((name) => path.join(root, name, "trajectory.json"))
.filter((file) => fs.existsSync(file))
.map(readJson)
.filter((row) => row.state === "replay_validated")
: [];
const groups = new Map();
for (const trajectory of trajectories) {
const designir = readJson(trajectory.designir);
const signature = designir.features
.map((feature) =>
["add_cylinder", "extrude_circle"].includes(feature.operation)
? "cylinder"
: feature.operation,
)
.sort()
.join("+");
const key = `${designir.family}::${signature}`;
const group = groups.get(key) || {
family: designir.family,
signature,
cases: [],
parameterNames: new Map(),
anchors: new Map(),
operations: new Set(),
};
group.cases.push(trajectory.case_id);
for (const name of Object.keys(designir.parameters || {})) {
group.parameterNames.set(name, (group.parameterNames.get(name) || 0) + 1);
}
for (const feature of designir.features || []) {
group.operations.add(feature.operation);
const anchor = feature.anchor || "base_center";
group.anchors.set(anchor, (group.anchors.get(anchor) || 0) + 1);
}
groups.set(key, group);
}
const candidates = [...groups.values()].map((group) => {
const simpleCylinder =
group.family === "simple_cylinder" && group.signature === "cylinder";
return {
id: `trajectory.${group.family}.${group.signature.replaceAll("+", "_")}`,
kind: "reconstruction_grammar",
promotion_state: "candidate",
applicability: simpleCylinder
? {
family: group.family,
evidence_conditions: [
"one dominant axis",
"one distinct coaxial radius",
"low topological complexity",
"solid volume approximately pi * radius^2 * axial_length",
],
}
: {
family: group.family,
operations: [...group.operations].sort(),
},
method: {
guidance: simpleCylinder
? "Interpret the single cylindrical surface as the outer body, not a bore. Build one cylinder from the observed outer radius, axial length, axis, and pose. Name the driving parameters outer_radius and overall_length. Preserve the absolute frame with an explicit center and anchor; do not expose duplicate width/thickness/inner-diameter parameters."
: "Reconstruct from analytic dimensions and axis evidence; preserve the observed absolute frame with an explicit center and anchor; expose only parameters that directly drive executable features.",
canonical_parameter_names: simpleCylinder
? ["outer_radius", "overall_length"]
: [],
rejected_parameter_hypotheses: simpleCylinder
? ["coaxial_inner_diameter", "duplicate_transverse_envelope_parameters"]
: [],
observed_parameter_names: Object.fromEntries(
[...group.parameterNames.entries()].sort(),
),
observed_anchor_modes: Object.fromEntries(
[...group.anchors.entries()].sort(),
),
},
validation: {
independent_replay_validated_case_count: new Set(group.cases).size,
source_hashes: [...new Set(group.cases)].sort(),
edit_contract_pass_required: true,
positive_heldout_ab_required: true,
heldout_ab_status: "not_run",
},
};
});
const payload = {
schema_version: "1.0",
library_kind: "execution_validated_trajectory_candidates",
status: candidates.length ? "candidates_only" : "empty",
policy: {
router_consumable: false,
automatic_publish_allowed: false,
minimum_independent_cases: 3,
positive_heldout_ab_required: true,
},
validated_trajectory_count: trajectories.length,
candidate_count: candidates.length,
candidates,
};
const output = path.join(TRAJECTORY_DIR, "candidate-experience.json");
writeJson(output, payload);
return { output, payload };
}
function sanitizedMethods(candidatePath) {
if (!candidatePath) return [];
return (readJson(candidatePath).candidates || []).map((candidate) => ({
id: candidate.id,
kind: candidate.kind,
applicability: candidate.applicability,
method: candidate.method,
}));
}
function benchmarkScore(rows) {
const passed = rows.filter((row) => row.state === "replay_validated").length;
return {
case_count: rows.length,
replay_validated_count: passed,
pass_rate: rows.length ? passed / rows.length : 0,
};
}
async function runBenchmark(args) {
const split = String(args.split || "validation");
if (!["validation", "test"].includes(split)) {
throw new Error("Benchmark split must be validation or test");
}
const limit = Math.max(1, Number(args.limit || 10));
const maxAttempts = Math.max(1, Number(args.attempts || 3));
const maxFaces = Math.max(1, Number(args["max-faces"] || 24));
const requestedFamily = String(args.family || "").trim();
const manifest = fs.existsSync(SPLIT_PATH) ? readJson(SPLIT_PATH) : initSplits();
const jobs = (manifest.splits?.[split] || [])
.filter((job) => !requestedFamily || job.family === requestedFamily)
.filter((job) => job.face_count <= maxFaces)
.slice(0, limit);
const provider = loadProvider(args.provider, args.model);
const candidatePath = String(
args.experience ||
path.join(TRAJECTORY_DIR, "candidate-experience.json"),
);
const experience = sanitizedMethods(candidatePath);
const benchmarkRoot = path.join(
TRAJECTORY_DIR,
"benchmarks",
`${split}-${requestedFamily || "all"}`,
);
const baseline = [];
const treatment = [];
for (const job of jobs) {
baseline.push(
await runOne({ ...job, split }, provider, maxAttempts, {
attemptsRoot: path.join(benchmarkRoot, "baseline"),
}),
);
treatment.push(
await runOne({ ...job, split }, provider, maxAttempts, {
attemptsRoot: path.join(benchmarkRoot, "experience"),
experience,
}),
);
}
const baselineScore = benchmarkScore(baseline);
const treatmentScore = benchmarkScore(treatment);
const report = {
schema_version: "1.0",
report_kind: "heldout_trajectory_ab",
split,
family: requestedFamily || "all",
provider: provider.providerId,
model: provider.model,
candidate_method_ids: experience.map((method) => method.id),
baseline: baselineScore,
experience: treatmentScore,
pass_rate_delta: treatmentScore.pass_rate - baselineScore.pass_rate,
positive: treatmentScore.pass_rate > baselineScore.pass_rate,
promotion_eligible:
jobs.length >= 10 && treatmentScore.pass_rate > baselineScore.pass_rate,
case_results: jobs.map((job, index) => ({
case_id: job.id,
baseline_state: baseline[index].state,
experience_state: treatment[index].state,
})),
};
writeJson(path.join(benchmarkRoot, "report.json"), report);
return report;
}
async function main() {
const args = argsObject(process.argv.slice(2));
const command = args._[0];
if (command === "init") {
const payload = initSplits();
process.stdout.write(
`${JSON.stringify({ split_path: SPLIT_PATH, policy: payload.policy }, null, 2)}\n`,
);
return;
}
if (command === "summary") {
const report = summarize();
writeJson(path.join(TRAJECTORY_DIR, "summary.json"), report);
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return;
}
if (command === "distill") {
const result = distillValidatedTrajectories();
process.stdout.write(
`${JSON.stringify(
{
output: result.output,
validated_trajectory_count: result.payload.validated_trajectory_count,
candidate_count: result.payload.candidate_count,
router_consumable: result.payload.policy.router_consumable,
},
null,
2,
)}\n`,
);
return;
}
if (command === "benchmark") {
const report = await runBenchmark(args);
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return;
}
if (command !== "run") {
throw new Error(
"Usage: trajectory_pipeline.mjs init|run|summary|distill|benchmark",
);
}
const split = String(args.split || "train");
const limit = Math.max(1, Number(args.limit || 5));
const maxAttempts = Math.max(1, Number(args.attempts || 3));
const maxFaces = Math.max(1, Number(args["max-faces"] || 24));
const requestedFamily = String(args.family || "").trim();
const rerun = Boolean(args.rerun);
const manifest = fs.existsSync(SPLIT_PATH) ? readJson(SPLIT_PATH) : initSplits();
const jobs = (manifest.splits?.[split] || [])
.filter((job) => !requestedFamily || job.family === requestedFamily)
.filter((job) => job.face_count <= maxFaces)
.filter(
(job) =>
rerun ||
!fs.existsSync(
path.join(
TRAJECTORY_DIR,
"attempts",
job.id.slice(0, 16),
"trajectory.json",
),
),
)
.slice(0, limit);
const provider = loadProvider(args.provider, args.model);
const results = [];
for (const job of jobs) {
const result = await runOne({ ...job, split }, provider, maxAttempts);
results.push({
case_id: job.id,
family: job.family,
face_count: job.face_count,
state: result.state,
});
process.stdout.write(`${JSON.stringify(results.at(-1))}\n`);
}
const report = summarize();
writeJson(path.join(TRAJECTORY_DIR, "summary.json"), report);
process.stdout.write(`${JSON.stringify({ completed: results.length, report }, null, 2)}\n`);
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
process.exitCode = 1;
});