feat: unify CAD workflows on DesignIR 3.0
This commit is contained in:
@@ -0,0 +1,735 @@
|
||||
#!/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;
|
||||
});
|
||||
Reference in New Issue
Block a user