feat: unify CAD workflows on DesignIR 3.0

This commit is contained in:
Jerry
2026-07-28 14:41:26 +08:00
parent 6e33bc0e11
commit b5738e9109
36 changed files with 9690 additions and 1305 deletions
+461 -48
View File
@@ -1,11 +1,17 @@
#!/usr/bin/env python3
"""Compile and validate DesignIR 2.0 without depending on teacher geometry."""
"""Compile and validate semantic DesignIR 3.0 without teacher geometry.
Legacy DesignIR 2.0 documents remain accepted and can be migrated explicitly.
Surface-parametric DesignIR 3.0 documents are handled by surfaceir_pipeline.py.
"""
from __future__ import annotations
import argparse
import ast
import copy
import hashlib
import importlib.util
import json
import math
import re
@@ -15,11 +21,23 @@ import tempfile
from pathlib import Path
from typing import Any
from build123d import Align, Box, CenterOf, Cylinder, Part, Pos, export_step, import_step
from build123d import (
Align,
Box,
CenterOf,
Cylinder,
Part,
Plane,
Pos,
export_step,
import_step,
)
SCHEMA_VERSION = "2.0"
DESIGNIR_KIND = "executable_parametric_design"
SCHEMA_VERSION = "3.0"
DESIGNIR_KIND = "independent_parametric_cad"
LEGACY_SCHEMA_VERSION = "2.0"
LEGACY_DESIGNIR_KIND = "executable_parametric_design"
SUPPORTED_OPERATIONS = {
"extrude_circle",
"extrude_rectangle",
@@ -138,14 +156,14 @@ def _vector3(value: Any, label: str) -> None:
raise DesignIRError(f"{label} must be a three-number vector")
def validate_designir(payload: dict[str, Any]) -> dict[str, Any]:
def _validate_compiler_payload(payload: dict[str, Any]) -> dict[str, Any]:
missing = sorted(REQUIRED_TOP_LEVEL - set(payload))
if missing:
raise DesignIRError("Missing DesignIR fields: " + ", ".join(missing))
if payload["schema_version"] != SCHEMA_VERSION:
if payload["schema_version"] != LEGACY_SCHEMA_VERSION:
raise DesignIRError("DesignIR schema_version must be 2.0")
if payload["designir_kind"] != DESIGNIR_KIND:
raise DesignIRError(f"designir_kind must be {DESIGNIR_KIND}")
if payload["designir_kind"] != LEGACY_DESIGNIR_KIND:
raise DesignIRError(f"designir_kind must be {LEGACY_DESIGNIR_KIND}")
if payload["units"] != "mm":
raise DesignIRError("Only millimetres are supported")
if payload["reconstruction_status"] not in {
@@ -196,14 +214,150 @@ def validate_designir(payload: dict[str, Any]) -> dict[str, Any]:
unknown = sorted(set(editable) - set(parameters))
if unknown:
raise DesignIRError("Unknown editable parameters: " + ", ".join(unknown))
disconnected = sorted(
name
for name in editable
if not parameters[name].get("editable")
or _parameter_reference_count(features, name) == 0
)
if disconnected:
raise DesignIRError(
"Editable parameters must drive at least one feature: "
+ ", ".join(disconnected)
)
for test in payload["validation_contract"].get("perturbations", []):
if test.get("parameter") not in parameters:
raise DesignIRError(
f"Perturbation references unknown parameter {test.get('parameter')}"
)
if not str(test.get("expected_change") or "").strip():
raise DesignIRError("Every perturbation requires expected_change")
return payload
def migrate_designir_2_to_3(payload: dict[str, Any]) -> dict[str, Any]:
"""Convert the legacy flat semantic contract into unified DesignIR 3.0."""
legacy = _validate_compiler_payload(copy.deepcopy(payload))
editable = list(legacy["edit_interface"].get("editable_parameters", []))
validation = legacy["validation_contract"]
semantic_layer = {
"reconstruction_status": legacy["reconstruction_status"],
"coordinate_system": legacy["coordinate_system"],
"datums": legacy["datums"],
"parameters": legacy["parameters"],
"expressions": legacy["expressions"],
"sketches": legacy["sketches"],
"constraints": legacy["constraints"],
"features": legacy["features"],
"patterns": legacy["patterns"],
"attachments": legacy["attachments"],
"construction_stages": legacy["construction_stages"],
"missing_capabilities": legacy.get("missing_capabilities", []),
"applied_experience_ids": legacy.get("applied_experience_ids", []),
}
return {
"schema_version": SCHEMA_VERSION,
"designir_kind": DESIGNIR_KIND,
"model_id": legacy["model_id"],
"family": legacy["family"],
"units": legacy["units"],
"document_status": "geometry_present",
"reconstruction_mode": "fully_semantic_parametric",
"authoring_mode": "semantic_feature_program",
"backend_hint": legacy.get("backend_hint", "build123d"),
"semantic_layer": semantic_layer,
"edit_interface": {
"semantic_parameters": editable,
"surface_parameter_groups": [],
"modification_levels": ["semantic_feature"],
"preserved_interfaces": legacy["edit_interface"].get(
"preserved_interfaces", []
),
},
"validation_contract": {
"source_independence": True,
"geometry_checks": validation.get(
"geometry_checks",
["solid_count", "bounding_box", "volume", "center_of_mass"],
),
"edit_checks": validation.get(
"edit_checks",
["parameter_perturbation", "constraint_preservation"],
),
"invariants": validation.get("invariants", []),
"perturbations": validation.get("perturbations", []),
"thresholds": validation.get("thresholds", {}),
},
}
def compiler_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Return the stable flat representation consumed by both CAD backends."""
version = payload.get("schema_version")
if version == LEGACY_SCHEMA_VERSION:
return _validate_compiler_payload(copy.deepcopy(payload))
if version != SCHEMA_VERSION or payload.get("designir_kind") != DESIGNIR_KIND:
raise DesignIRError(
"DesignIR must be legacy 2.0 or independent_parametric_cad 3.0"
)
mode = payload.get("reconstruction_mode")
if mode == "surface_parametric":
raise DesignIRError(
"Surface-parametric DesignIR must be rebuilt with surfaceir_pipeline.py"
)
if mode not in {
"fully_semantic_parametric",
"hybrid_semantic_surface_parametric",
}:
raise DesignIRError(f"Unsupported DesignIR 3.0 reconstruction_mode: {mode}")
if payload.get("units") != "mm":
raise DesignIRError("Only millimetres are supported")
forbidden = find_forbidden(payload.get("semantic_layer", {}))
if forbidden:
raise DesignIRError(f"Teacher geometry dependency is forbidden at {forbidden}")
semantic = payload.get("semantic_layer")
if not isinstance(semantic, dict):
raise DesignIRError("semantic_layer must be an object")
edit_interface = payload.get("edit_interface", {})
validation = payload.get("validation_contract", {})
flat = {
"schema_version": LEGACY_SCHEMA_VERSION,
"designir_kind": LEGACY_DESIGNIR_KIND,
"model_id": payload.get("model_id"),
"family": payload.get("family", "general_mechanical_part"),
"units": payload.get("units"),
"reconstruction_status": semantic.get("reconstruction_status", "ready"),
"coordinate_system": semantic.get("coordinate_system"),
"datums": semantic.get("datums", {}),
"parameters": semantic.get("parameters", {}),
"expressions": semantic.get("expressions", {}),
"sketches": semantic.get("sketches", []),
"constraints": semantic.get("constraints", []),
"features": semantic.get("features", []),
"patterns": semantic.get("patterns", []),
"attachments": semantic.get("attachments", []),
"construction_stages": semantic.get("construction_stages", []),
"edit_interface": {
"editable_parameters": edit_interface.get("semantic_parameters", []),
"preserved_interfaces": edit_interface.get("preserved_interfaces", []),
},
"validation_contract": {
"invariants": validation.get("invariants", []),
"perturbations": validation.get("perturbations", []),
"thresholds": validation.get("thresholds", {}),
},
"backend_hint": payload.get("backend_hint", "build123d"),
"missing_capabilities": semantic.get("missing_capabilities", []),
"applied_experience_ids": semantic.get("applied_experience_ids", []),
}
return _validate_compiler_payload(flat)
def validate_designir(payload: dict[str, Any]) -> dict[str, Any]:
"""Validate either supported version and return its compiler representation."""
return compiler_payload(payload)
def _safe_expression(expression: str, values: dict[str, float]) -> float:
try:
tree = ast.parse(expression, mode="eval")
@@ -261,13 +415,69 @@ def resolve_value(value: Any, values: dict[str, float], label: str) -> float:
raise DesignIRError(f"{label} must be a number, parameter, or expression")
def _axis_is_z(feature: dict[str, Any]) -> None:
if feature.get("axis", "primary_axis") not in {"z", "primary_axis"}:
raise DesignIRError("The initial compiler supports only the primary Z axis")
def resolve_center(
value: Any, values: dict[str, float], label: str
) -> list[float]:
if not isinstance(value, list) or len(value) != 3:
raise DesignIRError(f"{label} must contain three values")
return [
resolve_value(component, values, f"{label}[{index}]")
for index, component in enumerate(value)
]
def feature_axis(payload: dict[str, Any], feature: dict[str, Any]) -> list[float]:
reference = feature.get("axis", "primary_axis")
if isinstance(reference, list):
axis = reference
elif reference in {"x", "y", "z"}:
axis = {
"x": [1.0, 0.0, 0.0],
"y": [0.0, 1.0, 0.0],
"z": [0.0, 0.0, 1.0],
}[reference]
else:
datum = payload.get("datums", {}).get(str(reference), {})
axis = datum.get("axis")
if isinstance(axis, str) and axis in {"x", "y", "z"}:
axis = {
"x": [1.0, 0.0, 0.0],
"y": [0.0, 1.0, 0.0],
"z": [0.0, 0.0, 1.0],
}[axis]
_vector3(axis, f"{feature.get('id', 'feature')}.axis")
length = math.sqrt(sum(float(value) ** 2 for value in axis))
if length <= 1e-12:
raise DesignIRError(f"{feature.get('id', 'feature')}.axis is zero")
return [float(value) / length for value in axis]
def _axis_is_z(axis: list[float]) -> bool:
return abs(axis[0]) <= 1e-9 and abs(axis[1]) <= 1e-9 and axis[2] > 0.0
def feature_base_center(
feature: dict[str, Any],
center: list[float],
axis: list[float],
height: float,
) -> list[float]:
"""Resolve the primitive's bottom-face center from an explicit anchor."""
anchor = str(feature.get("anchor", "base_center"))
if anchor == "base_center":
return center
if anchor == "geometric_center":
return [
float(center[index]) - float(axis[index]) * height / 2.0
for index in range(3)
]
raise DesignIRError(
f"{feature.get('id', 'feature')}.anchor must be base_center or geometric_center"
)
def build_shape(payload: dict[str, Any]) -> Part:
validate_designir(payload)
payload = validate_designir(payload)
if payload["reconstruction_status"] != "ready":
missing = ", ".join(payload.get("missing_capabilities", [])) or "unspecified"
raise DesignIRError(
@@ -278,32 +488,39 @@ def build_shape(payload: dict[str, Any]) -> Part:
for feature in payload["features"]:
operation = feature["operation"]
_axis_is_z(feature)
axis = feature_axis(payload, feature)
if operation in {"extrude_circle", "add_cylinder"}:
radius = resolve_value(feature["radius"], values, f"{feature['id']}.radius")
height = resolve_value(feature["height"], values, f"{feature['id']}.height")
center = feature.get("center", [0, 0, 0])
_vector3(center, f"{feature['id']}.center")
tool = Pos(*map(float, center)) * Cylinder(
radius,
height,
align=(Align.CENTER, Align.CENTER, Align.MIN),
center = resolve_center(
feature.get("center", [0, 0, 0]),
values,
f"{feature['id']}.center",
)
base_center = feature_base_center(feature, center, axis, height)
tool = Plane(origin=base_center, z_dir=axis) * Cylinder(
radius, height, align=(Align.CENTER, Align.CENTER, Align.MIN)
)
shape = tool if shape is None else shape + tool
elif operation == "extrude_rectangle":
width = resolve_value(feature["width"], values, f"{feature['id']}.width")
depth = resolve_value(feature["depth"], values, f"{feature['id']}.depth")
height = resolve_value(feature["height"], values, f"{feature['id']}.height")
center = feature.get("center", [0, 0, 0])
_vector3(center, f"{feature['id']}.center")
tool = Pos(*map(float, center)) * Box(
width,
depth,
height,
align=(Align.CENTER, Align.CENTER, Align.MIN),
center = resolve_center(
feature.get("center", [0, 0, 0]),
values,
f"{feature['id']}.center",
)
base_center = feature_base_center(feature, center, axis, height)
tool = Plane(origin=base_center, z_dir=axis) * Box(
width, depth, height, align=(Align.CENTER, Align.CENTER, Align.MIN)
)
shape = tool if shape is None else shape + tool
elif operation in {"through_hole", "polar_hole_pattern"}:
if not _axis_is_z(axis):
raise DesignIRError(
f"{feature['id']} hole cutting currently requires positive Z axis"
)
if shape is None:
raise DesignIRError(f"{feature['id']} requires a host solid")
diameter = resolve_value(
@@ -314,8 +531,11 @@ def build_shape(payload: dict[str, Any]) -> Part:
cutter_z = float(bounds.min.Z) - 1.0
centers: list[tuple[float, float]]
if operation == "through_hole":
center = feature.get("center", [0, 0, 0])
_vector3(center, f"{feature['id']}.center")
center = resolve_center(
feature.get("center", [0, 0, 0]),
values,
f"{feature['id']}.center",
)
centers = [(float(center[0]), float(center[1]))]
else:
count = int(
@@ -352,7 +572,7 @@ def build_shape(payload: dict[str, Any]) -> Part:
def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]:
validate_designir(payload)
payload = validate_designir(payload)
if payload["reconstruction_status"] != "ready":
missing = ", ".join(payload.get("missing_capabilities", [])) or "unspecified"
raise DesignIRError(
@@ -373,7 +593,7 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]:
with scad.GraphSession() as session:
for feature in payload["features"]:
operation = feature["operation"]
_axis_is_z(feature)
axis = feature_axis(payload, feature)
if operation in {"extrude_circle", "add_cylinder"}:
radius = resolve_value(
feature["radius"], values, f"{feature['id']}.radius"
@@ -381,14 +601,17 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]:
height = resolve_value(
feature["height"], values, f"{feature['id']}.height"
)
center = feature.get("center", [0, 0, 0])
_vector3(center, f"{feature['id']}.center")
point = tuple(map(float, center))
center = resolve_center(
feature.get("center", [0, 0, 0]),
values,
f"{feature['id']}.center",
)
point = tuple(feature_base_center(feature, center, axis, height))
tool = scad.make_cylinder_rsolid(
radius=radius,
height=height,
bottom_face_center=point,
axis=(0.0, 0.0, 1.0),
axis=tuple(axis),
)
shape = (
tool
@@ -402,6 +625,10 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]:
else max(z_max, point[2] + height)
)
elif operation == "extrude_rectangle":
if not _axis_is_z(axis):
raise DesignIRError(
f"{feature['id']} SimpleCAD box currently requires positive Z axis"
)
width = resolve_value(
feature["width"], values, f"{feature['id']}.width"
)
@@ -411,9 +638,12 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]:
height = resolve_value(
feature["height"], values, f"{feature['id']}.height"
)
center = feature.get("center", [0, 0, 0])
_vector3(center, f"{feature['id']}.center")
point = tuple(map(float, center))
center = resolve_center(
feature.get("center", [0, 0, 0]),
values,
f"{feature['id']}.center",
)
point = tuple(feature_base_center(feature, center, axis, height))
tool = scad.make_box_rsolid(
width=width,
height=depth,
@@ -432,14 +662,21 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]:
else max(z_max, point[2] + height)
)
elif operation in {"through_hole", "polar_hole_pattern"}:
if not _axis_is_z(axis):
raise DesignIRError(
f"{feature['id']} SimpleCAD hole currently requires positive Z axis"
)
if shape is None or z_min is None or z_max is None:
raise DesignIRError(f"{feature['id']} requires a host solid")
diameter = resolve_value(
feature["diameter"], values, f"{feature['id']}.diameter"
)
if operation == "through_hole":
center = feature.get("center", [0, 0, 0])
_vector3(center, f"{feature['id']}.center")
center = resolve_center(
feature.get("center", [0, 0, 0]),
values,
f"{feature['id']}.center",
)
centers = [(float(center[0]), float(center[1]))]
else:
count = int(
@@ -532,10 +769,69 @@ def compile_designir(
}
def materialize_hybrid_designir(
payload: dict[str, Any], generated_step: Path
) -> dict[str, Any]:
"""Attach a compiled SurfaceIR snapshot to a semantic DesignIR 3.0 model.
The semantic feature program remains authoritative for future edits. The
SurfaceIR snapshot is extracted only from the newly generated STEP, never
from teacher geometry.
"""
semantic = (
migrate_designir_2_to_3(payload)
if payload.get("schema_version") == LEGACY_SCHEMA_VERSION
else copy.deepcopy(payload)
)
compiler_payload(semantic)
surface_script = Path(__file__).with_name("surfaceir_pipeline.py")
spec = importlib.util.spec_from_file_location(
"_designir_surfaceir_pipeline", surface_script
)
if spec is None or spec.loader is None:
raise DesignIRError("Cannot load SurfaceIR compiler")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
extracted = module.extract_surfaceir(generated_step.expanduser().resolve())
semantic["surface_layer"] = extracted["surface_layer"]
semantic["reconstruction_strategy"] = extracted.get(
"reconstruction_strategy",
{
"boundary_strategy": "exact_3d",
"selection_status": "default",
},
)
semantic["document_status"] = extracted.get(
"document_status", "geometry_present"
)
semantic["reconstruction_mode"] = "hybrid_semantic_surface_parametric"
edit_interface = semantic["edit_interface"]
edit_interface["surface_parameter_groups"] = extracted.get(
"edit_interface", {}
).get("surface_parameter_groups", [])
edit_interface["modification_levels"] = list(
dict.fromkeys(
list(edit_interface.get("modification_levels", []))
+ ["semantic_feature", "analytic_surface", "spline_control"]
)
)
semantic["compiled_surface_provenance"] = {
"source": "generated_step",
"teacher_geometry_used": False,
"semantic_layer_authoritative": True,
}
return semantic
def anti_cheat(payload: dict[str, Any], generator: Path | None = None) -> dict[str, Any]:
validate_designir(payload)
violations: list[str] = []
forbidden = find_forbidden(payload)
inspected = (
payload.get("semantic_layer", {})
if payload.get("schema_version") == SCHEMA_VERSION
else payload
)
forbidden = find_forbidden(inspected)
if forbidden:
violations.append(f"DesignIR teacher dependency at {forbidden}")
if generator:
@@ -594,14 +890,30 @@ def _symmetric_difference_volume(first: Part, second: Part) -> float:
raise DesignIRError(f"Symmetric-difference boolean failed: {exc}") from exc
def _parameter_reference_count(value: Any, parameter: str) -> int:
if isinstance(value, dict):
direct = 1 if value.get("parameter") == parameter else 0
return direct + sum(
_parameter_reference_count(child, parameter)
for child in value.values()
)
if isinstance(value, list):
return sum(_parameter_reference_count(child, parameter) for child in value)
return 0
def acceptance_report(
teacher_path: Path,
rebuilt_path: Path,
payload: dict[str, Any],
) -> dict[str, Any]:
validate_designir(payload)
teacher = import_step(teacher_path.expanduser().resolve())
rebuilt = import_step(rebuilt_path.expanduser().resolve())
payload = validate_designir(payload)
teacher_resolved = teacher_path.expanduser().resolve()
rebuilt_resolved = rebuilt_path.expanduser().resolve()
if teacher_resolved == rebuilt_resolved:
raise DesignIRError("Teacher and rebuilt STEP must be different files")
teacher = import_step(teacher_resolved)
rebuilt = import_step(rebuilt_resolved)
teacher_facts = geometry_facts(teacher)
rebuilt_facts = geometry_facts(rebuilt)
volume_base = max(teacher_facts["volume_mm3"], 1e-12)
@@ -640,6 +952,38 @@ def acceptance_report(
try:
output = Path(temporary) / f"perturbation-{index}.step"
result = compile_designir(modified, output)
modified_shape = import_step(output)
changed_volume = _symmetric_difference_volume(
rebuilt, modified_shape
)
change_ratio = changed_volume / max(
rebuilt_facts["volume_mm3"], 1e-12
)
reference_count = _parameter_reference_count(
payload.get("features", []), name
)
preserved = {
preserved_name: (
preserved_name in payload["parameters"]
and preserved_name in modified["parameters"]
and payload["parameters"][preserved_name]["value"]
== modified["parameters"][preserved_name]["value"]
)
for preserved_name in test.get("preserve", [])
}
expected_change = str(test.get("expected_change") or "")
expected_change_observed = change_ratio > 1e-9
if expected_change == "overall_height_increases":
expected_change_observed = (
result["facts"]["size_mm"][2]
> rebuilt_facts["size_mm"][2] + 1e-9
)
success = (
result["facts"]["solid_count"] > 0
and reference_count > 0
and expected_change_observed
and all(preserved.values())
)
perturbations.append(
{
"parameter": name,
@@ -647,7 +991,11 @@ def acceptance_report(
"modified_value": modified["parameters"][name]["value"],
"expected_change": test.get("expected_change"),
"preserve": test.get("preserve", []),
"success": result["facts"]["solid_count"] > 0,
"parameter_reference_count": reference_count,
"geometry_change_ratio": change_ratio,
"expected_change_observed": expected_change_observed,
"preserved_parameter_checks": preserved,
"success": success,
"facts": result["facts"],
}
)
@@ -678,6 +1026,14 @@ def acceptance_report(
"schema_version": "1.0",
"report_kind": "independent_designir_acceptance",
"model_id": payload["model_id"],
"teacher_sha256": hashlib.sha256(teacher_resolved.read_bytes()).hexdigest(),
"applied_experience_ids": sorted(
{
str(item)
for item in payload.get("applied_experience_ids", [])
if str(item).strip()
}
),
"geometry": geometry,
"feature_semantics": {
"declared_feature_count": len(payload["features"]),
@@ -700,15 +1056,34 @@ def acceptance_report(
def promotion_check(reports: list[Path], policy_path: Path) -> dict[str, Any]:
policy = read_json(policy_path)
loaded = [read_json(path) for path in reports]
model_ids = {str(item.get("model_id")) for item in loaded}
passing = [item for item in loaded if item.get("next_state") == "replay_validated"]
minimum = int(policy["minimum_independent_cases"])
eligible = len(passing) >= minimum and len(model_ids) >= minimum
methods: dict[str, set[str]] = {}
for item in passing:
source_hash = str(item.get("teacher_sha256") or "")
if not source_hash:
continue
for experience_id in item.get("applied_experience_ids", []):
methods.setdefault(str(experience_id), set()).add(source_hash)
validated_methods = sorted(
experience_id
for experience_id, source_hashes in methods.items()
if len(source_hashes) >= minimum
)
independent_sources = len(
{
source_hash
for source_hashes in methods.values()
for source_hash in source_hashes
}
)
eligible = bool(validated_methods)
return {
"eligible": eligible,
"independent_case_count": len(model_ids),
"independent_case_count": independent_sources,
"passing_case_count": len(passing),
"required_case_count": minimum,
"validated_experience_ids": validated_methods,
"next_state": "promoted" if eligible else "candidate",
}
@@ -720,6 +1095,10 @@ def build_parser() -> argparse.ArgumentParser:
validate = commands.add_parser("validate")
validate.add_argument("designir", type=Path)
migrate = commands.add_parser("migrate")
migrate.add_argument("designir", type=Path)
migrate.add_argument("--output", type=Path, required=True)
guard = commands.add_parser("anti-cheat")
guard.add_argument("designir", type=Path)
guard.add_argument("--generator", type=Path)
@@ -736,6 +1115,11 @@ def build_parser() -> argparse.ArgumentParser:
isolated.add_argument("--output", type=Path, required=True)
isolated.add_argument("--backend", choices=("build123d", "simplecadapi"))
materialize = commands.add_parser("materialize-hybrid")
materialize.add_argument("designir", type=Path)
materialize.add_argument("--step", type=Path, required=True)
materialize.add_argument("--output", type=Path, required=True)
accept = commands.add_parser("accept")
accept.add_argument("--teacher", type=Path, required=True)
accept.add_argument("--designir", type=Path, required=True)
@@ -752,12 +1136,29 @@ def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "validate":
payload = validate_designir(read_json(args.designir))
original = read_json(args.designir)
payload = validate_designir(original)
result = {
"valid": True,
"model_id": payload["model_id"],
"input_schema_version": original.get("schema_version"),
"supported_operations": sorted(SUPPORTED_OPERATIONS),
}
elif args.command == "migrate":
original = read_json(args.designir)
migrated = (
migrate_designir_2_to_3(original)
if original.get("schema_version") == LEGACY_SCHEMA_VERSION
else original
)
compiler_payload(migrated)
write_json(args.output, migrated)
result = {
"valid": True,
"output": str(args.output.expanduser().resolve()),
"schema_version": migrated["schema_version"],
"reconstruction_mode": migrated["reconstruction_mode"],
}
elif args.command == "anti-cheat":
result = anti_cheat(read_json(args.designir), args.generator)
elif args.command == "compile":
@@ -768,6 +1169,18 @@ def main(argv: list[str] | None = None) -> int:
result = isolated_rebuild(
args.designir, args.output, backend=args.backend
)
elif args.command == "materialize-hybrid":
materialized = materialize_hybrid_designir(
read_json(args.designir), args.step
)
write_json(args.output, materialized)
result = {
"valid": True,
"output": str(args.output.expanduser().resolve()),
"schema_version": materialized["schema_version"],
"reconstruction_mode": materialized["reconstruction_mode"],
"semantic_layer_authoritative": True,
}
elif args.command == "accept":
result = acceptance_report(
args.teacher, args.rebuilt, read_json(args.designir)
File diff suppressed because it is too large Load Diff
@@ -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;
});