feat: unify CAD workflows on DesignIR 3.0
This commit is contained in:
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user