添加cadfs_to_cdsl代码,优化cad生成

This commit is contained in:
2026-09-03 10:40:51 +08:00
parent f3eb5dff54
commit c25b73c71e
34 changed files with 3586 additions and 147 deletions
+1
View File
@@ -45,3 +45,4 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
text-to-cad-requirements/
+31
View File
@@ -0,0 +1,31 @@
# CAD Agent 运行原则
## 首要目标
产品的首要目标是稳定、可执行的 CAD 生成流程。任何已经获得可执行检查点的任务,系统都必须发布当前最完整、最可用的 CAD 模型。严格满足全部需求是期望目标,但绝不能因此进入无限修复循环,也不能丢弃本来可用的模型。
必须将以下两类结果严格区分:
- **执行可靠性:** 工作流只接收有效协议数据,只调用引擎支持的操作,保留有效工作;只要存在可执行模型,就必须以最佳可用结果终止。
- **需求符合度:** 最终模型可能满足全部、部分或不满足用户要求。未满足项必须作为警告和证据报告,不能成为销毁、隐藏可用模型的理由。
## 职责边界
- 服务端负责校验输出格式、schema、有限数值、操作可用性、selector/reference 有效性、Runtime 安全、持久化和状态转换。
- 服务端不得判断 LLM 是否“正确理解”了用户的设计语义。不得引入基于语义分歧而阻止建模的 reviewer 循环。
- 可以使用工程默认值补全常见但描述不足的零件,但未指定的值不得变成虚构的、严格的确定性验收尺寸。例如,仅因为 verifier 需要数值,不能把未指定的孔径编译成硬性的 `1 mm` claim。
- 不要求 LLM 输出内部运行时 ID,例如 task、revision、action、candidate、requirement、claim、evidence、source ID。所有这些值由服务端从当前状态生成和绑定。
- LLM 的规划、坐标、宿主面、操作选择或几何策略错误,通常属于模型质量问题,不是程序失败。必须保留诊断,并按下述恢复规则继续。
- schema 无效、引擎不支持的操作、非法状态转换、provider 协议不兼容、持久化失败或 Runtime 崩溃,属于程序、协议或服务问题,必须准确报告。
## 结果与用户沟通
- 始终保留并暴露最新可执行 STEP、GLB 和渲染工件。
- 完成结果必须区分:已满足目标、未解决目标、因依赖跳过的操作、Runtime/协议故障和 LLM 规划局限。
- 不得把未满足需求声称为已通过;同样不得因仍有未满足需求而隐藏可用模型。
- 需求文档和完成文档是面向用户的工程工件。需求文档可以用已接受的 CAD 默认规则完善描述不足的提示词;完成结果必须描述生成模型实际达成了什么。
## 改动评审指引
修改本系统时,优先保证执行确定性、部分结果可持久化、失败可诊断,以及终止行为有边界。避免加入语义审查关卡、要求复制 ID 的协议,或会在没有改善可执行模型的情况下无限消耗调用的重试机制。
+346 -21
View File
@@ -124,7 +124,7 @@ class ProfileCadRuntime:
result["ref_" + sha256(f"{document_hash}|{feature_id}".encode("utf-8")).hexdigest()[:16]] = feature_id
return result
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False) -> tuple[dict[str, Any], dict[str, Any]]:
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False, depends_on_feature_ids: tuple[str, ...] | list[str] = ()) -> tuple[dict[str, Any], dict[str, Any]]:
# ActionCommandHandler validates the exposed schema first, but this
# adapter is also used during crash recovery. Keep the runtime boundary
# self-contained so a corrupted/replayed staged payload cannot produce
@@ -168,7 +168,24 @@ class ProfileCadRuntime:
if not isinstance(supplied, list) or not all(token in reference_tokens for token in supplied):
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference token is absent or stale")
materialized["feature"]["params"][slot] = [reference_tokens[token] for token in supplied]
self._semantic_preflight(materialized, contract, selector_tokens, base_cdsl, require_through=require_through)
through_normalizations = self._normalize_required_through_cut_depth(
materialized,
contract,
selector_tokens,
require_through=require_through,
)
cut_support_normal = self._semantic_preflight(
materialized,
contract,
selector_tokens,
base_cdsl,
require_through=require_through,
)
direction_normalizations = self._normalize_extrude_cut_direction(
materialized,
contract,
cut_support_normal,
)
document = deepcopy(base_cdsl) if isinstance(base_cdsl, dict) else {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "agent_preflight", "geometry": {"sketches": []}, "features": [],
}
@@ -177,9 +194,17 @@ class ProfileCadRuntime:
features = document.setdefault("features", [])
if not isinstance(sketches, list) or not isinstance(features, list):
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: base CDSL collections are invalid")
existing_feature_ids = {
str(item.get("id") or "")
for item in features
if isinstance(item, dict) and str(item.get("id") or "")
}
direct_dependencies = tuple(str(value) for value in depends_on_feature_ids)
if len(direct_dependencies) != len(set(direct_dependencies)) or any(value not in existing_feature_ids for value in direct_dependencies):
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: plan dependency feature is absent from the active checkpoint")
index = len(features) + 1
feature = materialized["feature"]
output = {"id": f"feature_{index:03d}", "atomic_id": contract["atomic_id"], "params": deepcopy(feature["params"]), "depends_on": [str(features[-1].get("id"))] if features else []}
output = {"id": f"feature_{index:03d}", "atomic_id": contract["atomic_id"], "params": deepcopy(feature["params"]), "depends_on": list(direct_dependencies)}
if contract["fragment_shape"]["sketch"] == "required":
sketch_id = f"sketch_{len(sketches) + 1:03d}"
sketch = materialized["sketch"]
@@ -225,35 +250,69 @@ class ProfileCadRuntime:
"RUNTIME_CONTRACT_INVALID: materialized fragment violates the engine CDSL schema: "
+ message
) from error
return document, {"schema_version": "cad.v3.fragment-audit.v1", "atomic_id": contract["atomic_id"], "fragment_hash": canonical_hash(fragment), "contract_hash": contract["contract_hash"], "assigned_feature_ids": [output["id"]], "assigned_sketch_ids": [output["sketch_id"]] if output.get("sketch_id") else [], "selector_snapshot_id": next(iter(selector_tokens.values()), {}).get("snapshot_id", ""), "selector_tokens": list(feature.get("selector_tokens", [])), "reference_snapshot_id": canonical_hash(reference_tokens), "reference_tokens": list(fragment.get("feature", {}).get("params", {}).get(str(reference.get("slot") or "").removeprefix("params."), [])) if reference["mode"] == "snapshot_bound" else []}
return document, {"schema_version": "cad.v3.2.fragment-audit.v1", "atomic_id": contract["atomic_id"], "fragment_hash": canonical_hash(fragment), "contract_hash": contract["contract_hash"], "assigned_feature_ids": [output["id"]], "depends_on_feature_ids": list(direct_dependencies), "assigned_sketch_ids": [output["sketch_id"]] if output.get("sketch_id") else [], "selector_snapshot_id": next(iter(selector_tokens.values()), {}).get("snapshot_id", ""), "selector_tokens": list(feature.get("selector_tokens", [])), "reference_snapshot_id": canonical_hash(reference_tokens), "reference_tokens": list(fragment.get("feature", {}).get("params", {}).get(str(reference.get("slot") or "").removeprefix("params."), [])) if reference["mode"] == "snapshot_bound" else [], "server_normalizations": [*through_normalizations, *direction_normalizations]}
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]:
def build_checkpoint(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]:
"""Build the exact geometry checkpoint required by every DAG node.
Rendering is deliberately absent here: a renderer outage must never
make a geometrically valid atomic feature fail its local acceptance.
"""
root = Path(output_dir)
root.mkdir(parents=True, exist_ok=True)
cdsl_copy = deepcopy(cdsl)
cdsl_copy["part_id"] = task_id
step_path = root / "model.step"
glb_path = root / "model.glb"
self._write_json(root / "model.cdsl.json", cdsl_copy)
try:
self._validate_finite_tree(cdsl_copy)
validate_cdsl(cdsl_copy, self.engine)
except RuntimeAdapterError:
raise
except Exception as error:
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: persisted CDSL failed rebuild preflight: {error}") from error
try:
engine_result = self.engine.run_cdsl_only(cdsl_copy, step_path)
if str(engine_result.get("engine") or "") != "cdsl_only":
raise RuntimeAdapterError("engine did not execute CDSL-only rebuild")
preview = step_to_glb(step_path, glb_path)
health = self._health(engine_result, step_path, glb_path)
topology = topology_snapshot(engine_result, task_id=task_id, revision_id=revision_id, preview=preview)
health = self._health(engine_result, step_path)
topology = topology_snapshot(engine_result, task_id=task_id, revision_id=revision_id)
self._write_json(root / "model.topology.json", topology)
manifest = render_checkpoint(self.settings, step_path=step_path, output_dir=root / "renders")
report = {"engine_result": engine_result, "preview": preview, "health": health, "render_manifest": manifest}
report = {"engine_result": engine_result, "preview": {}, "health": health, "render_manifest": {}}
self._write_json(root / "rebuild-report.json", report)
return {"health": health, "topology": topology, "report": report, "render_manifest": manifest, "paths": {"cdsl": "model.cdsl.json", "step": "model.step", "glb": "model.glb", "topology": "model.topology.json", "report": "rebuild-report.json", "render_manifest": "renders/render-manifest.json"}}
return {"health": health, "topology": topology, "report": report, "render_manifest": {}, "paths": {"cdsl": "model.cdsl.json", "step": "model.step", "topology": "model.topology.json", "report": "rebuild-report.json"}}
except OSError:
raise
except RuntimeAdapterError:
raise
except Exception as error:
raise RuntimeAdapterError(f"RUNTIME_EXECUTION_FAILURE: {error}") from error
def create_preview(self, output_dir: str) -> dict[str, Any]:
"""Create an optional GLB preview for a completed checkpoint."""
root = Path(output_dir)
step_path = root / "model.step"
glb_path = root / "model.glb"
preview = step_to_glb(step_path, glb_path)
report_path = root / "rebuild-report.json"
report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {}
report["preview"] = preview
self._write_json(report_path, report)
return {"preview": preview, "path": "model.glb"}
def render_review_bundle(self, output_dir: str) -> dict[str, Any]:
root = Path(output_dir)
manifest = render_checkpoint(self.settings, step_path=root / "model.step", output_dir=root / "renders")
report_path = root / "rebuild-report.json"
report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {}
report["render_manifest"] = manifest
self._write_json(report_path, report)
return manifest
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]:
# Legacy v3.1 compatibility path. New DAG nodes use build_checkpoint.
built = self.build_checkpoint(cdsl, output_dir, task_id, revision_id)
root = Path(output_dir)
try:
preview = self.create_preview(output_dir)
manifest = self.render_review_bundle(output_dir)
report = json.loads((root / "rebuild-report.json").read_text(encoding="utf-8"))
return {**built, "report": report, "preview": preview["preview"], "render_manifest": manifest, "paths": {**built["paths"], "glb": "model.glb", "render_manifest": "renders/render-manifest.json"}}
except OSError:
# Artifact writes are a recoverable infrastructure outage. Let the
# application handler park the same candidate stage for replay.
@@ -330,7 +389,7 @@ class ProfileCadRuntime:
}
return document
def _semantic_preflight(self, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], base_cdsl: dict[str, Any] | None, *, require_through: bool) -> None:
def _semantic_preflight(self, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], base_cdsl: dict[str, Any] | None, *, require_through: bool) -> list[float] | None:
if fragment.get("feature", {}).get("atomic_id") != contract.get("atomic_id"):
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: atomic_id does not match active contract")
policy = contract["selector_policy"]
@@ -340,6 +399,83 @@ class ProfileCadRuntime:
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selector token is absent, stale, or has the wrong kind")
for name in contract["semantic_preflight"]:
self._semantic_preflight_handlers[name](fragment, selector_tokens, base_cdsl, require_through)
if contract.get("atomic_id") == "extrude_cut_blind":
return self._preflight_extrude_cut_contacts_material(fragment, selector_tokens)
return None
def _normalize_extrude_cut_direction(
self,
fragment: dict[str, Any],
contract: dict[str, Any],
support_normal: list[float] | None,
) -> list[dict[str, Any]]:
"""Aim a surface-attached cut into the measured material half-space.
A sketch extrusion has no host selector. Once preflight proves that its
profile lies on an oriented boundary face, the only executable blind
cut direction is the material side of that face. This is a coordinate
normalization, not a planning decision, and is recorded in the audit.
"""
if contract.get("atomic_id") != "extrude_cut_blind" or not self._valid_vector3(support_normal, require_nonzero=True):
return []
sketch = fragment.get("sketch") if isinstance(fragment, dict) else None
workplane = sketch.get("workplane") if isinstance(sketch, dict) else None
normal = workplane.get("normal") if isinstance(workplane, dict) else None
params = fragment.get("feature", {}).get("params") if isinstance(fragment.get("feature"), dict) else None
if not self._valid_vector3(normal, require_nonzero=True) or not isinstance(params, dict):
return []
unit_normal = [float(component) / self._norm(normal) for component in normal]
unit_support = [float(component) / self._norm(support_normal) for component in support_normal]
alignment = self._dot(unit_normal, unit_support)
if abs(abs(alignment) - 1.0) > 1e-6:
return []
materialized_reverse = alignment > 0
submitted_reverse = bool(params.get("reverse", False))
params["reverse"] = materialized_reverse
return [{
"path": "feature.params.reverse",
"submitted": submitted_reverse,
"materialized": materialized_reverse,
"reason": "surface-attached cut must travel into the measured material half-space",
"support_normal": unit_support,
}]
def _normalize_required_through_cut_depth(
self,
fragment: dict[str, Any],
contract: dict[str, Any],
selector_tokens: dict[str, dict[str, Any]],
*,
require_through: bool,
) -> list[dict[str, Any]]:
"""Add the minimum deterministic exit allowance for a through cut.
The author owns nominal feature geometry. For a through requirement,
however, the runtime owns the executable end condition: this engine
needs a strictly greater cut distance than the measured host span.
Recording the adjustment makes the operational allowance visible
without treating it as a user-specified blind-cut depth.
"""
if not require_through or contract.get("atomic_id") != "extrude_cut_blind":
return []
params = fragment.get("feature", {}).get("params", {})
sketch = fragment.get("sketch") if isinstance(fragment.get("sketch"), dict) else {}
workplane = sketch.get("workplane") if isinstance(sketch, dict) else {}
normal = workplane.get("normal") if isinstance(workplane, dict) else None
thickness = self._span_from_bbox(self._active_body_bbox(selector_tokens), normal)
distance = params.get("distance_mm") if isinstance(params, dict) else None
if not isinstance(distance, (int, float)) or thickness is None:
return []
required_distance = float(thickness) + 0.01
if float(distance) > float(thickness) + 1e-6:
return []
params["distance_mm"] = required_distance
return [{
"path": "/feature/params/distance_mm",
"submitted_mm": float(distance),
"materialized_mm": required_distance,
"reason": "required through-cut exit allowance",
}]
def _preflight_sketch_workplane(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
sketch = fragment.get("sketch")
@@ -362,6 +498,123 @@ class ProfileCadRuntime:
elif profile.get("type") == "analytic_contours":
self._preflight_analytic_contours(profile)
def _preflight_extrude_cut_contacts_material(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]]) -> list[float] | None:
"""Reject an extrude cut whose start profile floats above the solid.
Sketch cuts do not carry a host-face selector, so a model can place a
correct 2-D profile on the top of an unrelated boss. The engine then
rebuilds successfully but leaves the body unchanged. Detect the common
no-contact form using the current planar topology and return a useful
coordinate diagnosis before allocating a stage or running the kernel.
"""
sketch = fragment.get("sketch") if isinstance(fragment, dict) else None
workplane = sketch.get("workplane") if isinstance(sketch, dict) else None
profile = sketch.get("profile") if isinstance(sketch, dict) else None
origin = workplane.get("origin_mm") if isinstance(workplane, dict) else None
normal = workplane.get("normal") if isinstance(workplane, dict) else None
x_dir = workplane.get("x_dir") if isinstance(workplane, dict) else None
if not self._valid_vector3(origin) or not self._valid_vector3(normal, require_nonzero=True) or not self._valid_vector3(x_dir, require_nonzero=True) or not isinstance(profile, dict):
return None
unit_normal = [float(component) / self._norm(normal) for component in normal]
x_projection = self._dot(x_dir, unit_normal)
raw_x = [float(x_dir[index]) - x_projection * unit_normal[index] for index in range(3)]
if self._norm(raw_x) <= 1e-9:
return None
unit_x = [value / self._norm(raw_x) for value in raw_x]
unit_y = self._cross(unit_normal, unit_x)
local_points = self._profile_probe_points(profile)
if not local_points:
return None
world_points = [
[
float(origin[index]) + local[0] * unit_x[index] + local[1] * unit_y[index]
for index in range(3)
]
for local in local_points
]
tolerance = 1e-5
matching_faces: list[dict[str, Any]] = []
available_heights: list[float] = []
for value in selectors.values():
geometry = value.get("geometry") if isinstance(value, dict) else None
face_normal = geometry.get("normal") if isinstance(geometry, dict) else None
center = geometry.get("center_mm") if isinstance(geometry, dict) else None
loops = geometry.get("boundary_loops_mm") if isinstance(geometry, dict) else None
if (
not isinstance(geometry, dict)
or geometry.get("surface_type") != "plane"
or not self._valid_vector3(face_normal, require_nonzero=True)
or not self._valid_vector3(center)
or not isinstance(loops, list)
or not loops
):
continue
unit_face_normal = [float(component) / self._norm(face_normal) for component in face_normal]
if abs(abs(self._dot(unit_normal, unit_face_normal)) - 1.0) > 1e-6:
continue
available_heights.append(self._dot([float(center[index]) for index in range(3)], unit_normal))
if abs(self._dot([float(origin[index]) - float(center[index]) for index in range(3)], unit_normal)) <= tolerance:
matching_faces.append(geometry)
if not matching_faces:
return None
for face in matching_faces:
if any(
self._point_in_planar_face(point, face["boundary_loops_mm"], unit_normal, tolerance)
for point in world_points
):
face_normal = face.get("normal")
return [float(component) for component in face_normal] if self._valid_vector3(face_normal, require_nonzero=True) else None
plane_coordinate = self._dot([float(value) for value in origin], unit_normal)
heights = ", ".join(f"{value:g}" for value in sorted(set(round(value, 6) for value in available_heights))[:8])
raise RuntimeAdapterError(
"RUNTIME_PRECONDITION_FAILED: extrude-cut profile does not contact material on its start plane "
f"(plane coordinate {plane_coordinate:g}; available parallel planar faces: [{heights}]); "
"place the sketch on the material face containing the intended cut profile"
)
@staticmethod
def _profile_probe_points(profile: dict[str, Any]) -> list[tuple[float, float]]:
"""Return inexpensive local points sufficient for contact preflight."""
points: list[tuple[float, float]] = []
def append(value: Any) -> None:
point = ProfileCadRuntime._point2(value)
if point is not None:
points.append(point)
profile_type = profile.get("type")
if profile_type == "circle":
append(profile.get("center"))
elif profile_type == "rectangle":
append(profile.get("center"))
elif profile_type == "polygon":
vertices = profile.get("vertices")
if isinstance(vertices, list):
for vertex in vertices:
append(vertex)
elif profile_type == "analytic_contours":
contours = profile.get("contours")
if isinstance(contours, list):
for contour in contours:
segments = contour.get("segments") if isinstance(contour, dict) else None
if not isinstance(segments, list):
continue
contour_points: list[tuple[float, float]] = []
for segment in segments:
if not isinstance(segment, dict):
continue
for key in ("start", "end", "center"):
point = ProfileCadRuntime._point2(segment.get(key))
if point is not None:
points.append(point)
contour_points.append(point)
if contour_points:
points.append((
sum(point[0] for point in contour_points) / len(contour_points),
sum(point[1] for point in contour_points) / len(contour_points),
))
return points
def _preflight_host_face_exists(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
supplied = fragment.get("feature", {}).get("selector_tokens", [])
if len(supplied) != 1 or not isinstance(selectors.get(supplied[0]), dict) or selectors[supplied[0]].get("kind") != "face":
@@ -439,7 +692,79 @@ class ProfileCadRuntime:
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is outside the selected host-face bounds")
boundary_loops = geometry.get("boundary_loops_mm")
if isinstance(boundary_loops, list) and boundary_loops and not self._point_in_planar_face(point, boundary_loops, unit_normal, tolerance):
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is outside the selected host face's effective boundary")
if not self._counterbore_reuses_existing_pilot(fragment, selectors, point, unit_normal, tolerance):
host_z = float(center[2]) if isinstance(center, list) and len(center) == 3 else float("nan")
coordinate = ", ".join(f"{float(value):g}" for value in point)
raise RuntimeAdapterError(
"RUNTIME_PRECONDITION_FAILED: hole position "
f"[{coordinate}] lies outside the selected host face material boundary (host center z={host_z:g}); "
"select a planar host face that contains every requested hole center"
)
def _counterbore_reuses_existing_pilot(
self,
fragment: dict[str, Any],
selectors: dict[str, dict[str, Any]],
point: list[float],
host_normal: list[float],
tolerance: float,
) -> bool:
"""Allow a counterbore to start from an existing coaxial pilot bore.
A top face becomes annular after a through bore, so the pilot centre
is deliberately outside its material boundary. Counterboring that
pilot is nevertheless a standard valid operation. The exception is
deliberately narrow: it only applies to a matching inner cylindrical
bore whose axis is normal to the selected host plane and whose open
end contains the requested start point.
"""
feature = fragment.get("feature") if isinstance(fragment, dict) else None
params = feature.get("params") if isinstance(feature, dict) and isinstance(feature.get("params"), dict) else {}
pilot_diameter = params.get("diameter_mm")
counterbore_diameter = params.get("counterbore_diameter_mm")
if (
not isinstance(feature, dict)
or feature.get("atomic_id") != "hole_counterbore"
or not isinstance(pilot_diameter, (int, float))
or not isinstance(counterbore_diameter, (int, float))
or float(pilot_diameter) <= 0
or float(counterbore_diameter) <= float(pilot_diameter)
):
return False
diameter_tolerance = max(tolerance, abs(float(pilot_diameter)) * 1e-6)
for value in selectors.values():
geometry = value.get("geometry") if isinstance(value, dict) else None
if (
not isinstance(geometry, dict)
or geometry.get("surface_type") != "cylinder"
or geometry.get("cylinder_role") != "inner"
or not bool(geometry.get("through"))
):
continue
radius = geometry.get("radius_mm")
axis_origin = geometry.get("axis_origin_mm")
axis_direction = geometry.get("axis_direction")
bbox = geometry.get("bbox_mm")
if (
not isinstance(radius, (int, float))
or abs(2 * float(radius) - float(pilot_diameter)) > diameter_tolerance
or not self._valid_vector3(axis_origin)
or not self._valid_vector3(axis_direction, require_nonzero=True)
or not self._valid_bbox(bbox)
):
continue
unit_axis = [float(component) / self._norm(axis_direction) for component in axis_direction]
if abs(abs(self._dot(unit_axis, host_normal)) - 1.0) > 1e-6:
continue
offset = [float(point[index]) - float(axis_origin[index]) for index in range(3)]
axial = self._dot(offset, unit_axis)
radial = [offset[index] - axial * unit_axis[index] for index in range(3)]
if self._norm(radial) > tolerance:
continue
if any(float(point[index]) < float(bbox[index]) - tolerance or float(point[index]) > float(bbox[index + 3]) + tolerance for index in range(3)):
continue
return True
return False
def _preflight_analytic_contours(self, profile: dict[str, Any]) -> None:
contours = profile.get("contours")
@@ -750,9 +1075,9 @@ class ProfileCadRuntime:
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
@staticmethod
def _health(engine_result: dict[str, Any], step_path: Path, glb_path: Path) -> dict[str, Any]:
def _health(engine_result: dict[str, Any], step_path: Path, glb_path: Path | None = None) -> dict[str, Any]:
bbox = engine_result.get("bbox_mm") if isinstance(engine_result.get("bbox_mm"), dict) else {}
minimum, maximum = bbox.get("min"), bbox.get("max")
if not isinstance(minimum, list) or not isinstance(maximum, list) or len(minimum) != 3 or len(maximum) != 3 or not step_path.is_file() or not glb_path.is_file():
if not isinstance(minimum, list) or not isinstance(maximum, list) or len(minimum) != 3 or len(maximum) != 3 or not step_path.is_file() or (glb_path is not None and not glb_path.is_file()):
raise RuntimeAdapterError("rebuild did not produce complete deterministic artifacts")
return {"bbox_mm": {"min": [float(item) for item in minimum], "max": [float(item) for item in maximum], "dimensions": [float(maximum[index]) - float(minimum[index]) for index in range(3)]}, "volume_mm3": float(engine_result["volume_mm3"]), "solid_count": int(engine_result["solid_count"]), "feature_count": len(engine_result.get("feature_results") or [])}
@@ -35,12 +35,12 @@ class SqliteTaskRepository:
def _initialize(self) -> None:
with self._lock, self._connection() as connection:
# Protocol 3.1 intentionally has no migration path from the
# Protocol 3.2 intentionally has no migration path from the
# structured-only / review-loop task model. Deployment starts with
# an empty task database, as those tasks do not have immutable
# Markdown source artifacts to compile from.
existing = connection.execute("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tasks'").fetchone()
if existing is not None and "'3.1'" not in str(existing[0] or ""):
if existing is not None and "'3.2'" not in str(existing[0] or ""):
self.protocol_reset = True
connection.executescript("""
DROP TABLE IF EXISTS outbox;
@@ -55,7 +55,7 @@ class SqliteTaskRepository:
"""
CREATE TABLE IF NOT EXISTS tasks (
task_id TEXT PRIMARY KEY,
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.1'),
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.2'),
request TEXT NOT NULL,
phase TEXT NOT NULL,
state_version INTEGER NOT NULL,
@@ -70,6 +70,9 @@ class SqliteTaskRepository:
requirements_document_path TEXT NOT NULL DEFAULT '',
completion_target_path TEXT NOT NULL DEFAULT '',
modeling_plan_path TEXT NOT NULL DEFAULT '',
feature_plan_path TEXT NOT NULL DEFAULT '',
feature_plan_hash TEXT NOT NULL DEFAULT '',
feature_stage_id TEXT NOT NULL DEFAULT '',
clarification_path TEXT NOT NULL DEFAULT '',
requirements_contract_path TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -124,7 +127,7 @@ class SqliteTaskRepository:
def create_task(self, task_id: str, request: str) -> TaskState:
with self._lock, self._connection() as connection:
connection.execute(
"INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.1', ?, ?, 0)",
"INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.2', ?, ?, 0)",
(task_id, request, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT.value),
)
state = self.get_state(task_id)
@@ -145,10 +148,10 @@ class SqliteTaskRepository:
revisions = [
{
"revision_id": item["revision_id"], "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED and item["revision_id"] == state.active_revision else "checkpoint",
"cdsl_path": f"revisions/{item['revision_id']}/model.cdsl.json", "step_path": f"revisions/{item['revision_id']}/model.step", "glb_path": f"revisions/{item['revision_id']}/model.glb", "report_path": f"revisions/{item['revision_id']}/rebuild-report.json", "candidate_review_path": item.get("review_path", ""),
"cdsl_path": f"revisions/{item['revision_id']}/model.cdsl.json", "step_path": f"revisions/{item['revision_id']}/model.step", "glb_path": "" if item.get("preview_unavailable") else f"revisions/{item['revision_id']}/model.glb", "report_path": f"revisions/{item['revision_id']}/rebuild-report.json", "candidate_review_path": item.get("review_path", ""),
}
for item in events
if item.get("event") == "accepted" and isinstance(item.get("revision_id"), str)
if item.get("event") in {"accepted", "feature_node_verified"} and isinstance(item.get("revision_id"), str)
]
frozen = next((item for item in reversed(events) if item.get("event") == "requirements_compiled"), {})
verification_warnings = [
@@ -167,7 +170,7 @@ class SqliteTaskRepository:
questions = [str(item) for item in status_event.get("questions") or () if str(item)] if isinstance(status_event, dict) else []
issues = [str(item) for item in status_event.get("issues") or () if str(item)] if isinstance(status_event, dict) else []
return {
"schema_version": "3.1",
"schema_version": "3.2",
"task_id": state.task_id,
"phase": state.phase.value,
"lifecycle": self._lifecycle(state.phase),
@@ -184,6 +187,11 @@ class SqliteTaskRepository:
"requirements_document_path": state.requirements_document_path,
"completion_target_path": state.completion_target_path,
"modeling_plan_path": state.modeling_plan_path,
"feature_plan_path": state.feature_plan_path,
"feature_plan_hash": state.feature_plan_hash,
"current_feature_node_id": state.pending_feature.node_id if state.pending_feature else "",
"pending_feature": self._pending_payload(state.pending_feature),
"feature_nodes": self._feature_node_projection(events),
"clarification_path": state.clarification_path,
"requirements_contract_path": state.requirements_contract_path,
"verification_status": (
@@ -204,6 +212,25 @@ class SqliteTaskRepository:
"revisions": revisions,
}
@staticmethod
def _feature_node_projection(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Return ledger-backed execution evidence; API adds immutable plan fields."""
nodes: dict[str, dict[str, Any]] = {}
for event in events:
node_id = str(event.get("node_id") or "")
if not node_id:
continue
item = nodes.setdefault(node_id, {"node_id": node_id, "status": "pending", "attempt": 0})
if event.get("event") == "feature_node_scheduled":
item.update({"status": "running", "atomic_id": str(event.get("atomic_id") or ""), "priority": event.get("priority"), "claim_ids": event.get("claim_ids") or [], "depends_on": event.get("depends_on") or []})
elif event.get("event") == "feature_node_failed":
item.update({"status": "failed" if event.get("terminal") else "pending", "attempt": int(event.get("attempt") or 0), "failure_class": str(event.get("failure_class") or ""), "error": str(event.get("message") or "")})
elif event.get("event") == "feature_node_verified":
item.update({"status": "done", "revision_id": str(event.get("revision_id") or ""), "feature_id": str(event.get("feature_id") or ""), "evidence": event.get("claim_results") or []})
elif event.get("event") == "feature_node_invalidated" and item.get("status") != "done":
item["status"] = "invalidated"
return list(nodes.values())
def ledger_events(self, task_id: str) -> list[dict[str, Any]]:
with self._lock, self._connection() as connection:
rows = connection.execute("SELECT sequence, event_json, created_at FROM ledger WHERE task_id = ? ORDER BY sequence", (task_id,)).fetchall()
@@ -243,7 +270,7 @@ class SqliteTaskRepository:
cursor = connection.execute(
"""UPDATE tasks SET phase = ?, state_version = ?, active_revision = ?, pending_action_json = ?,
candidate_id = ?, candidate_stage_id = ?, repair_required = ?, last_error = ?, retry_from_phase = ?, requirements_spec_path = ?,
requirements_document_path = ?, completion_target_path = ?, modeling_plan_path = ?, clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
requirements_document_path = ?, completion_target_path = ?, modeling_plan_path = ?, feature_plan_path = ?, feature_plan_hash = ?, feature_stage_id = ?, clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
WHERE task_id = ? AND state_version = ?""",
(
state.phase.value, state.version, state.active_revision,
@@ -252,6 +279,7 @@ class SqliteTaskRepository:
int(state.repair_required), state.last_error.value if state.last_error else None,
state.retry_from_phase.value if state.retry_from_phase else "", state.requirements_spec_path,
state.requirements_document_path, state.completion_target_path, state.modeling_plan_path,
state.feature_plan_path, state.feature_plan_hash, state.feature_stage_id,
state.clarification_path, state.requirements_contract_path,
state.task_id, previous_version,
),
@@ -393,6 +421,8 @@ class SqliteTaskRepository:
action_id=raw_pending["action_id"], working_head=raw_pending["working_head"], intent=raw_pending["intent"],
requirement_ids=tuple(raw_pending["requirement_ids"]), atomic_id=raw_pending["atomic_id"],
expected_change=raw_pending["expected_change"], contract_hash=raw_pending["contract_hash"], idempotency_key=raw_pending["idempotency_key"],
node_id=str(raw_pending.get("node_id") or ""), plan_hash=str(raw_pending.get("plan_hash") or ""),
claim_ids=tuple(raw_pending.get("claim_ids") or ()), depends_on_node_ids=tuple(raw_pending.get("depends_on_node_ids") or ()),
) if isinstance(raw_pending, dict) else None
return TaskState(
task_id=str(row["task_id"]), phase=TaskPhase(str(row["phase"])), version=int(row["state_version"]),
@@ -405,6 +435,9 @@ class SqliteTaskRepository:
requirements_document_path=str(row["requirements_document_path"] or ""),
completion_target_path=str(row["completion_target_path"] or ""),
modeling_plan_path=str(row["modeling_plan_path"] or ""),
feature_plan_path=str(row["feature_plan_path"] or ""),
feature_plan_hash=str(row["feature_plan_hash"] or ""),
feature_stage_id=str(row["feature_stage_id"] or ""),
clarification_path=str(row["clarification_path"] or ""),
requirements_contract_path=str(row["requirements_contract_path"] or ""),
)
@@ -413,7 +446,7 @@ class SqliteTaskRepository:
def _pending_payload(pending: PendingAction | None) -> dict[str, Any] | None:
if pending is None:
return None
return {"action_id": pending.action_id, "working_head": pending.working_head, "intent": pending.intent, "requirement_ids": list(pending.requirement_ids), "atomic_id": pending.atomic_id, "expected_change": pending.expected_change, "contract_hash": pending.contract_hash, "idempotency_key": pending.idempotency_key}
return {"action_id": pending.action_id, "working_head": pending.working_head, "intent": pending.intent, "requirement_ids": list(pending.requirement_ids), "atomic_id": pending.atomic_id, "expected_change": pending.expected_change, "contract_hash": pending.contract_hash, "idempotency_key": pending.idempotency_key, "node_id": pending.node_id, "plan_hash": pending.plan_hash, "claim_ids": list(pending.claim_ids), "depends_on_node_ids": list(pending.depends_on_node_ids)}
@staticmethod
def _invocation(row: sqlite3.Row) -> InvocationRecord:
@@ -7,6 +7,7 @@ import json
import secrets
from typing import Any
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash
from app.cad_agent.application.llm_contracts import (
CandidateReview,
FinalReview,
@@ -55,6 +56,236 @@ class ActionCommandHandler:
available.append(atomic_id)
return tuple(available)
def schedule_next_feature(self, task_id: str) -> Accepted | Rejected:
"""Select the server-owned next ready node by fixed plan priority."""
state = self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.SCHEDULING_FEATURE:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Feature scheduling is not expected in the current workflow state."))
plan = self._feature_plan(task_id, state)
if plan is None:
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan is unavailable.", retryable=True))
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
if scheduler.all_done():
claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision))
deterministic_failures = [
item for item in claim_results
if item.get("deterministic") and item.get("status") != "pass"
]
if deterministic_failures:
next_state = transition(
state,
"feature_replan",
error=ErrorCode.CLAIM_VERIFICATION_FAILED,
)
result = {
"status": "replan_required",
"phase": next_state.phase.value,
"claim_results": claim_results,
}
if not self.repository.compare_and_swap(next_state, events=[{
"event": "feature_plan_completion_failed",
"plan_hash": state.feature_plan_hash,
"revision_id": state.active_revision,
"claim_results": claim_results,
"failed_claim_ids": [str(item.get("claim_id") or "") for item in deterministic_failures],
"message": "All feature nodes completed, but final deterministic validation failed.",
}]):
return Rejected(self._stale())
return Accepted(result)
next_state = transition(state, "final_requested")
result = {"status": "final_validation", "phase": next_state.phase.value, "claim_results": claim_results}
if not self.repository.compare_and_swap(next_state, events=[{
"event": "feature_plan_complete",
"plan_hash": state.feature_plan_hash,
"revision_id": state.active_revision,
"claim_results": claim_results,
}], invocation_id=None, invocation_result=None):
return Rejected(self._stale())
return Accepted(result)
node = scheduler.next_ready()
if node is None:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan has no runnable node; revise its unresolved subgraph."))
contract = self.runtime.operation_contract(node.atomic_id)
requirement_ids = tuple(sorted(self._requirement_ids_for_claims(task_id, tuple(node.claim_ids))))
action_id = "feature_" + sha256(f"{state.feature_plan_hash}|{node.node_id}|{state.active_revision}".encode("utf-8")).hexdigest()[:16]
pending = PendingAction(
action_id=action_id,
working_head=state.working_head,
intent=node.intent,
requirement_ids=requirement_ids,
atomic_id=node.atomic_id,
expected_change=node.expected_change,
contract_hash=contract["contract_hash"],
idempotency_key=sha256(f"{task_id}|{action_id}|{state.working_head}".encode("utf-8")).hexdigest(),
node_id=node.node_id,
plan_hash=state.feature_plan_hash,
claim_ids=tuple(node.claim_ids),
depends_on_node_ids=tuple(node.depends_on),
)
next_state = transition(state, "feature_scheduled", pending_action=pending)
event = {
"event": "feature_node_scheduled", "node_id": node.node_id, "node_hash": node_hash(node),
"plan_hash": state.feature_plan_hash, "action_id": action_id, "atomic_id": node.atomic_id,
"depends_on": node.depends_on, "claim_ids": node.claim_ids, "priority": node.priority,
}
if not self.repository.compare_and_swap(next_state, events=[event]):
return Rejected(self._stale())
return Accepted({"status": "scheduled", "node_id": node.node_id, "atomic_id": node.atomic_id, "phase": next_state.phase.value})
def submit_feature_fragment(self, task_id: str, fragment: dict[str, Any], *, invocation_id: str) -> Accepted | Rejected:
state = self.repository.get_state(task_id)
action = state.pending_feature if state else None
if state is None or state.phase != TaskPhase.FEATURE_PENDING or action is None:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature fragment requires one scheduled feature node."))
plan = self._feature_plan(task_id, state)
if plan is None or action.plan_hash != state.feature_plan_hash:
return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The scheduled feature belongs to an obsolete plan."))
node = next((item for item in plan.nodes if item.node_id == action.node_id), None)
if node is None or node.atomic_id != action.atomic_id or tuple(node.claim_ids) != action.claim_ids:
return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The scheduled feature no longer matches the active plan."))
contract = self.runtime.operation_contract(action.atomic_id)
if contract["contract_hash"] != action.contract_hash:
return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The operation contract changed while the feature was scheduled."))
base = self.artifacts.read_active_cdsl(task_id, state.active_revision)
selectors = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision))
references = self.runtime.reference_tokens(base)
selector_policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {}
selector_kind = str(selector_policy.get("token_kind") or "")
allowed_selectors = [
token for token, value in selectors.items()
if str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") == "required"
and isinstance(value, dict) and value.get("kind") == selector_kind
]
errors = validate_fragment(contract, fragment, selector_tokens=allowed_selectors, reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))
if errors:
return self._feature_failure(task_id, state, action, ErrorCode.AUTHOR_FORMAT_INVALID, "fragment_preflight", "CDSL fragment violates the scheduled operation schema.", invocation_id=invocation_id, field_errors=tuple(errors))
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
feature_ids = scheduler.feature_ids()
direct_feature_ids = tuple(feature_ids.get(dependency, "") for dependency in action.depends_on_node_ids)
if any(not value for value in direct_feature_ids):
return self._feature_failure(task_id, state, action, ErrorCode.RUNTIME_PRECONDITION_FAILED, "dependency", "A direct dependency has no verified runtime feature.", invocation_id=invocation_id)
fragment_hash = canonical_hash(fragment)
key = self._key(task_id, "feature_fragment", state.working_head, {"node_id": action.node_id, "fragment": fragment})
invocation = self.repository.begin_invocation(task_id, invocation_id, key)
if invocation.status == "finished" and invocation.result is not None:
return Accepted(invocation.result)
try:
cdsl, audit = self.runtime.materialize_fragment(
base, fragment, contract, selectors, references,
require_through=self._claims_require_through(task_id, action.claim_ids),
depends_on_feature_ids=direct_feature_ids,
)
except Exception as error:
return self._feature_failure(task_id, state, action, self._runtime_error(error).code, "fragment_preflight", str(error), invocation_id=invocation_id, invocation=invocation)
stage_id = "feature_stage_" + sha256(key.encode("utf-8")).hexdigest()[:16]
try:
stage = self.artifacts.start_candidate_stage(task_id, key, {
"schema_version": "cad.v3.2.feature-input.v1", "stage_id": stage_id,
"node_id": action.node_id, "node_hash": node_hash(node), "plan_hash": state.feature_plan_hash,
"action_id": action.action_id, "fragment": fragment, "fragment_audit": audit,
})
except OSError as error:
return self._park_retry(state, ErrorCode.STORAGE_FAILURE, event="feature_stage_storage_failure", message=str(error), details={"node_id": action.node_id})
building = transition(state, "feature_started", feature_stage_id=stage.stage_id)
if not self.repository.compare_and_swap(building, events=[{
"event": "feature_node_building", "node_id": action.node_id, "node_hash": node_hash(node),
"plan_hash": state.feature_plan_hash, "stage_id": stage.stage_id, "fragment_hash": fragment_hash,
}]):
return Rejected(self._stale())
try:
rebuilt = self.runtime.build_checkpoint(cdsl, stage.output_dir, task_id, stage_id)
try:
preview = self.runtime.create_preview(stage.output_dir)
rebuilt["paths"]["glb"] = preview["path"]
except Exception as preview_error:
preview = {"preview_unavailable": str(preview_error)[:500]}
claim_results = self._evaluate_claims(task_id, rebuilt, claim_ids=set(action.claim_ids))
operation_results = self._operation_candidate_results(
action, contract, cdsl, rebuilt, parent_facts=self._facts(task_id, state.active_revision),
require_through=self._claims_require_through(task_id, action.claim_ids),
)
global_failures = [item for item in self._evaluate_claims(task_id, rebuilt) if item.get("claim_kind") in {"solid_count_equals", "single_connected_body"} and item.get("status") != "pass"]
blockers = [
# A claim is assigned to exactly one runtime-atomic node in
# the Feature Plan. Unlike unassigned future-work claims,
# an assigned claim may not remain pending when that node is
# published: doing so would turn a missing feature into a
# permanent, apparently successful checkpoint.
*[item for item in claim_results if item.get("status") != "pass"],
*[item for item in operation_results if item.get("status") != "pass"],
*global_failures,
]
if blockers:
self.artifacts.write_stage_json(task_id, stage.stage_id, "node-verification.json", {
"schema_version": "cad.v3.2.node-verification.v1", "node_id": action.node_id,
"claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers,
})
return self._feature_failure(task_id, building, action, ErrorCode.CLAIM_VERIFICATION_FAILED, "node_validation", "The feature did not satisfy its local deterministic acceptance.", invocation_id=invocation_id, invocation=invocation, details={"stage_id": stage.stage_id, "blockers": blockers})
verification = {
"schema_version": "cad.v3.2.node-verification.v1", "node_id": action.node_id,
"node_hash": node_hash(node), "plan_hash": state.feature_plan_hash,
"feature_id": audit["assigned_feature_ids"][0], "claim_results": claim_results,
"operation_verifier_results": operation_results, "health": rebuilt["health"], "preview": preview,
}
self.artifacts.write_stage_json(task_id, stage.stage_id, "node-verification.json", verification)
revision_id = self._next_revision(task_id)
paths = self.artifacts.publish_candidate(task_id, stage.stage_id, revision_id)
next_state = transition(building, "feature_verified", active_revision=revision_id)
result = {"status": "verified", "node_id": action.node_id, "revision_id": revision_id, "paths": paths, "claim_results": claim_results}
event = {
"event": "feature_node_verified", "node_id": action.node_id, "node_hash": node_hash(node),
"plan_hash": state.feature_plan_hash, "feature_id": audit["assigned_feature_ids"][0],
"atomic_id": action.atomic_id,
"revision_id": revision_id, "parent_revision": state.active_revision, "stage_id": stage.stage_id,
"claim_results": claim_results, "preview_unavailable": preview.get("preview_unavailable", ""),
}
if not self._commit_invocation(next_state, [event], invocation, result):
return Rejected(self._stale())
return Accepted(result)
except OSError as error:
return self._park_retry(building, ErrorCode.STORAGE_FAILURE, event="feature_build_storage_failure", message=str(error), details={"node_id": action.node_id, "stage_id": stage.stage_id})
except Exception as error:
return self._feature_failure(task_id, building, action, self._runtime_error(error).code, "engine_build", str(error), invocation_id=invocation_id, invocation=invocation, details={"stage_id": stage.stage_id})
def recover_feature_build(self, task_id: str) -> Accepted | Rejected:
"""Make a crashed build retryable without losing its scheduled node."""
state = self.repository.get_state(task_id)
action = state.pending_feature if state else None
if state is None or state.phase != TaskPhase.FEATURE_BUILDING or action is None:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "There is no recoverable feature build."))
next_state = transition(state, "feature_retry")
if not self.repository.compare_and_swap(next_state, events=[{
"event": "feature_build_recovered", "node_id": action.node_id, "plan_hash": action.plan_hash,
"stage_id": state.feature_stage_id, "message": "Interrupted node build returned to the same scheduled feature.",
}]):
return Rejected(self._stale())
return Accepted({"status": "retry", "node_id": action.node_id, "phase": next_state.phase.value})
def _feature_failure(self, task_id: str, state: TaskState, action: PendingAction, code: ErrorCode, failure_class: str, message: str, *, invocation_id: str, field_errors: tuple[dict[str, Any], ...] = (), invocation: Any = None, details: dict[str, Any] | None = None) -> Rejected:
plan = self._feature_plan(task_id, state)
node = next((item for item in (plan.nodes if plan else []) if item.node_id == action.node_id), None)
if node is None:
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature node is unavailable.", retryable=True))
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
attempt = scheduler.failure_count(action.node_id, failure_class) + 1
terminal = attempt >= 2
event = {
"event": "feature_node_failed", "node_id": action.node_id, "node_hash": node_hash(node),
"plan_hash": state.feature_plan_hash, "failure_class": failure_class, "attempt": attempt,
"terminal": terminal, "code": code.value, "normalized_error_code": code.value,
"atomic_id": action.atomic_id, "checkpoint_revision": state.active_revision,
"message": message[:1000], **(details or {}),
}
next_state = transition(state, "feature_replan" if terminal else "feature_retry", error=code)
result = {"status": "replan_required" if terminal else "retry", "node_id": action.node_id, "attempt": attempt, "failure_class": failure_class}
if invocation is not None:
committed = self._commit_invocation(next_state, [event], invocation, result)
else:
committed = self.repository.compare_and_swap(next_state, events=[event])
if not committed:
return Rejected(self._stale())
return Rejected(WorkflowError(code, message, field_errors=field_errors, details={**(details or {}), "node_id": action.node_id, "attempt": attempt, "replan_required": terminal}))
def propose_next_action(self, task_id: str, proposal: NextAction, *, invocation_id: str) -> Accepted | Rejected:
state = self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None:
@@ -388,6 +619,8 @@ class ActionCommandHandler:
def submit_cdsl_fragment(self, task_id: str, fragment: dict[str, Any], *, invocation_id: str) -> Accepted | Rejected:
state = self.repository.get_state(task_id)
if state is not None and state.phase == TaskPhase.FEATURE_PENDING:
return self.submit_feature_fragment(task_id, fragment, invocation_id=invocation_id)
action = state.pending_action if state else None
if state is None or state.phase != TaskPhase.ACTION_PENDING or action is None:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A CDSL fragment requires one pending action."))
@@ -922,11 +1155,18 @@ class ActionCommandHandler:
"atomic_id": atomic_id,
"fragment_hash": accepted_fragment,
}) if accepted_fragment and atomic_id else ""
next_state = transition(state, "final_repair", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED)
is_dag = bool(state.feature_plan_hash)
next_state = transition(
state,
"feature_replan" if is_dag else "final_repair",
repair_required=not is_dag,
error=ErrorCode.CLAIM_VERIFICATION_FAILED if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED,
)
result = {"status": "repair", "revision_id": state.active_revision}
if not self._commit_invocation(next_state, [{
"event": "final_review_repair",
"event": "final_visual_reviewed" if is_dag else "final_review_repair",
"revision_id": state.active_revision,
**({"plan_hash": state.feature_plan_hash} if is_dag else {}),
"claim_results": claim_results,
"review_claim_coverage": [item.model_dump(mode="json") for item in review.claim_coverage],
"visual_not_passed": [str(item.get("claim_id") or "") for item in visual_not_passed],
@@ -947,15 +1187,48 @@ class ActionCommandHandler:
return Accepted(result)
next_state = transition(state, "final_accepted")
result = {"status": "completed", "revision_id": state.active_revision}
if not self._commit_invocation(next_state, [{"event": "completed", "revision_id": state.active_revision, "final_review_path": final_review_path, "completion_result_path": "completion-result.md", "claim_results": claim_results}], invocation, result):
if not self._commit_invocation(next_state, [{"event": "final_visual_reviewed", "revision_id": state.active_revision, "final_review_path": final_review_path, "completion_result_path": "completion-result.md", "claim_results": claim_results}], invocation, result):
return Rejected(self._stale())
return Accepted(result)
def _evaluate_claims(self, task_id: str, facts: dict[str, Any]) -> list[dict[str, Any]]:
def _evaluate_claims(self, task_id: str, facts: dict[str, Any], *, claim_ids: set[str] | None = None) -> list[dict[str, Any]]:
contract = self._requirements_contract(task_id) or {}
claims = [claim for requirement in contract.get("requirements") or () if isinstance(requirement, dict) for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)]
claims = [
claim for requirement in contract.get("requirements") or () if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict) and (claim_ids is None or str(claim.get("claim_id") or "") in claim_ids)
]
return self.verifiers.evaluate(claims, facts)
def _feature_plan(self, task_id: str, state: TaskState | None = None) -> FeaturePlan | None:
state = state or self.repository.get_state(task_id)
if state is None or not state.feature_plan_path:
return None
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
try:
return FeaturePlan.model_validate(raw)
except ValueError:
return None
def _requirement_ids_for_claims(self, task_id: str, claim_ids: tuple[str, ...]) -> set[str]:
wanted = set(claim_ids)
contract = self._requirements_contract(task_id) or {}
return {
str(requirement.get("requirement_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
and any(str(claim.get("claim_id") or "") in wanted for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict))
}
def _claims_require_through(self, task_id: str, claim_ids: tuple[str, ...]) -> bool:
wanted = set(claim_ids)
contract = self._requirements_contract(task_id) or {}
return any(
str(claim.get("claim_id") or "") in wanted and claim.get("claim_kind") == "through_cylindrical_bore"
for requirement in contract.get("requirements") or () if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)
)
def claim_summary(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
"""Return bounded current claim facts for author context assembly."""
return [
@@ -1059,7 +1332,16 @@ class ActionCommandHandler:
continue
expected: dict[str, Any]
if claim_kind in {"cylindrical_bore", "through_cylindrical_bore"}:
diameter = params.get("diameter_mm")
# A counterbore can reuse an already-existing pilot bore. In
# that case the material change is the larger cylindrical
# recess, not an additional instance of the pilot diameter.
# Measuring the pilot would count the parent bore as a new
# feature and make a valid counterbore checkpoint fail.
diameter = (
params.get("counterbore_diameter_mm")
if action.atomic_id == "hole_counterbore" and claim_kind == "cylindrical_bore"
else params.get("diameter_mm")
)
positions = params.get("positions")
if not isinstance(diameter, (int, float)):
return [{"claim_id": f"operation_{action.action_id}_{claim_kind}", "claim_kind": claim_kind, "deterministic": True, "status": "unavailable", "evidence": {"reason": "operation has no measurable bore diameter"}}]
@@ -1152,7 +1434,7 @@ class ActionCommandHandler:
values = [
int(str(event.get("revision_id") or "").removeprefix("rev_"))
for event in self.repository.ledger_events(task_id)
if event.get("event") == "accepted"
if event.get("event") in {"accepted", "feature_node_verified"}
and str(event.get("revision_id") or "").startswith("rev_")
and str(event.get("revision_id") or "").removeprefix("rev_").isdigit()
]
@@ -18,6 +18,7 @@ from app.cad_agent.application.llm_contracts import (
stateless_final_review_schema,
stateless_next_action_schema,
)
from app.cad_agent.domain.feature_plan import FeaturePlan
from app.cad_agent.domain.operation_contract import fragment_schema
from app.cad_agent.domain.verifier_registry import default_registry
from app.cad_agent.ports import CadRuntime, ModelGateway
@@ -30,6 +31,8 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
if role == "reviewer":
return [
_tool("observe_images", ImageObservation.model_json_schema()),
# Compatibility conformance probe; regular DAG execution never
# calls a per-node reviewer.
_tool("review_candidate", StatelessCandidateReview.model_json_schema()),
_tool("review_final", stateless_final_review_schema(1)),
]
@@ -40,8 +43,9 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
_tool("write_requirements_document", MarkdownDocument.model_json_schema()),
_tool("write_completion_target", MarkdownDocument.model_json_schema()),
_tool("compile_requirements_spec", compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1)),
_tool("write_feature_plan", FeaturePlan.model_json_schema()),
# Compatibility probe only; production v3.2 workflow never exposes it.
_tool("write_modeling_plan", MarkdownDocument.model_json_schema()),
_tool("propose_next_action", stateless_next_action_schema(atomic_ids)),
_tool("inspect_topology", StatelessTopologyRequest.model_json_schema()),
_tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()),
_tool("rollback_checkpoint", StatelessRollbackCheckpoint.model_json_schema()),
@@ -57,7 +61,7 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str:
payload = {"protocol": "cad.v3.1.markdown-first", "role": role, "tools": tools}
payload = {"protocol": "cad.v3.2.feature-dag", "role": role, "tools": tools}
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
@@ -9,6 +9,7 @@ import re
from typing import Any, Callable
from app.cad_agent.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
from app.cad_agent.application.results import Accepted, Rejected
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
from app.cad_agent.domain.state import TaskPhase, TaskState, transition
@@ -18,6 +19,9 @@ from app.cad_agent.ports import ArtifactStore, TaskRepository
_CHECKBOX = re.compile(r"^\s*- \[ \]\s+(.+?)\s*$")
_RECORD_BOUND_CLAIMS = frozenset({"coaxial", "coplanar"})
_CENTERED_BORE_MARKERS = ("centered", "concentric", "coaxial", "中心", "同心", "同轴")
_BORE_MARKERS = ("bore", "hole", "")
_OBROUND_SLOT_MARKERS = ("oblong", "slot", "slotted", "腰形", "长圆", "调节槽")
class RequirementsCommandHandler:
@@ -27,10 +31,11 @@ class RequirementsCommandHandler:
service derives those values strictly from the immutable checklist.
"""
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry) -> None:
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry, *, atomic_ids: Callable[[], tuple[str, ...]] | None = None) -> None:
self.repository = repository
self.artifacts = artifacts
self.registry = registry
self.atomic_ids = atomic_ids or (lambda: ())
self._evaluation_contract_oracles: dict[str, list[dict[str, Any]]] = {}
self._evaluation_capability_gaps: dict[str, list[dict[str, str]]] = {}
@@ -59,6 +64,81 @@ class RequirementsCommandHandler:
len(self._checklist_items(task_id)),
)
def feature_plan_schema(self, task_id: str) -> dict[str, Any]:
"""Return the plan tool schema bound to the persisted planning state.
A model is allowed to choose node content, but it must not guess the
immutable lineage identifiers of a plan revision. Binding those values
as enums prevents a requirements-contract hash (or a stale plan hash)
from being mistaken for ``parent_plan_hash``.
"""
schema = FeaturePlan.model_json_schema()
properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
definitions = schema.get("$defs") if isinstance(schema.get("$defs"), dict) else {}
node = definitions.get("FeatureNode") if isinstance(definitions, dict) else None
node_properties = node.get("properties") if isinstance(node, dict) else None
atomic = node_properties.get("atomic_id") if isinstance(node_properties, dict) else None
if isinstance(atomic, dict):
atomic["enum"] = list(self.atomic_ids())
state = self.repository.get_state(task_id)
previous: FeaturePlan | None = None
if state is not None and state.feature_plan_path:
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
try:
previous = FeaturePlan.model_validate(raw)
except ValueError:
previous = None
parent_hash = plan_hash(previous) if previous is not None else ""
replacements = sorted(self._required_replacements(previous, task_id)) if previous is not None else []
parent = properties.get("parent_plan_hash") if isinstance(properties, dict) else None
if isinstance(parent, dict):
parent["enum"] = [parent_hash]
replaced = properties.get("replaces_node_ids") if isinstance(properties, dict) else None
if isinstance(replaced, dict):
replaced.update({
"type": "array",
"uniqueItems": True,
"minItems": len(replacements),
"maxItems": len(replacements),
"items": {"enum": replacements},
})
contract = self.artifacts.read_requirements_contract(
task_id,
state.requirements_contract_path if state is not None else "",
) or {}
deterministic_claim_ids = sorted(
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") == "deterministic"
and isinstance(claim.get("claim_id"), str)
and claim.get("claim_id")
)
visual_claim_ids = sorted(
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") != "deterministic"
and isinstance(claim.get("claim_id"), str)
and claim.get("claim_id")
)
node_claim_ids = node_properties.get("claim_ids") if isinstance(node_properties, dict) else None
if isinstance(node_claim_ids, dict):
# Authors occasionally repeat a visual claim on the node that
# creates the feature as well as in final_claim_ids. Accept that
# harmless reference at the tool boundary; submit_feature_plan()
# removes it before the immutable DAG is validated and written.
node_claim_ids["items"] = {"enum": [*deterministic_claim_ids, *visual_claim_ids]}
final_claim_ids = properties.get("final_claim_ids") if isinstance(properties, dict) else None
if isinstance(final_claim_ids, dict):
final_claim_ids["items"] = {"enum": visual_claim_ids}
final_claim_ids["maxItems"] = len(visual_claim_ids)
return schema
def submit_requirements_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, path="requirements.md", event="requirements_document_written", validator=self._validate_requirements_document)
@@ -66,7 +146,157 @@ class RequirementsCommandHandler:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_COMPLETION_TARGET, path="completion-target.md", event="completion_target_written", validator=self._validate_completion_target)
def submit_modeling_plan(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_MODELING_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
# Compatibility shim for callers compiled against v3.1. The v3.2
# coordinator never offers this method to an LLM.
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.COMPILING_FEATURE_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
def submit_feature_plan(self, task_id: str, plan: FeaturePlan, *, invocation_id: str) -> Accepted | Rejected:
"""Freeze a validated initial plan or full subgraph plan revision."""
replay = self._replay(task_id, invocation_id)
if replay is not None:
return replay
state = self.repository.get_state(task_id)
if state is None or state.phase not in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature plan is not expected in the current workflow phase."))
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
if not isinstance(contract, dict):
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements contract is unavailable for feature planning."))
plan = self._normalize_feature_plan_visual_references(plan, contract)
plan = self._assign_unowned_global_health_claims(plan, contract)
previous: FeaturePlan | None = None
completed: dict[str, str] = {}
if state.feature_plan_path:
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
try:
previous = FeaturePlan.model_validate(raw)
except ValueError:
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan artifact is invalid.", retryable=True))
completed = FeatureScheduler(previous, self.repository.ledger_events(task_id)).completed_node_hashes()
required_replacements = self._required_replacements(previous, task_id) if previous is not None else set()
errors = validate_feature_plan(plan, contract, self.atomic_ids(), previous_plan=previous, completed_node_hashes=completed, required_replacements=required_replacements)
if errors:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan does not satisfy the frozen contract.", field_errors=tuple(errors)))
digest = plan_hash(plan)
path = f"plans/feature-plan-{digest}.json"
event = "feature_plan_written" if previous is None else "feature_plan_revised"
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, plan.model_dump(mode="json")))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
try:
written = self.artifacts.write_json_once(task_id, path, plan.model_dump(mode="json"))
except OSError as error:
return self._park_for_storage_retry(state, str(error))
next_state = transition(state, event, feature_plan_path=written, feature_plan_hash=digest)
result = Accepted({"phase": next_state.phase.value, "path": written, "plan_hash": digest, "node_count": len(plan.nodes)})
events: list[dict[str, Any]] = [{
"event": event,
"invocation_id": invocation_id,
"plan_path": written,
"plan_hash": digest,
"parent_plan_hash": plan.parent_plan_hash,
"replaces_node_ids": plan.replaces_node_ids,
}]
if previous is not None:
old_nodes = {node.node_id: node for node in previous.nodes}
old_hash = plan_hash(previous)
events.extend({
"event": "feature_node_invalidated",
"node_id": node_id,
"node_hash": node_hash(old_nodes[node_id]),
"plan_hash": old_hash,
"replacement_plan_hash": digest,
} for node_id in plan.replaces_node_ids)
if not self._commit(next_state, events, invocation, result):
return Rejected(self._stale())
return result
@staticmethod
def _normalize_feature_plan_visual_references(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
"""Drop non-owning visual references from feature nodes.
A node's ``claim_ids`` drive synchronous deterministic acceptance.
Visual claims are owned solely by ``final_claim_ids`` and have no
node-local verifier. Retaining a repeated visual ID therefore adds
no behavior and turns an otherwise valid plan into a schema retry.
The contract validation below still requires every visual claim to be
present exactly once in ``final_claim_ids``.
"""
visual_claim_ids = {
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") != "deterministic"
and isinstance(claim.get("claim_id"), str)
}
if not visual_claim_ids or not any(
claim_id in visual_claim_ids
for node in plan.nodes
for claim_id in node.claim_ids
):
return plan
normalized = plan.model_copy(deep=True)
for node in normalized.nodes:
node.claim_ids = [claim_id for claim_id in node.claim_ids if claim_id not in visual_claim_ids]
return normalized
@staticmethod
def _assign_unowned_global_health_claims(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
"""Bind global solid-health claims to the unique root body feature.
``single_connected_body`` and ``solid_count_equals`` are checked as
global health on every feature checkpoint. When a plan has exactly
one root additive feature, their node owner is consequently
determined without choosing any geometry strategy. This prevents an
otherwise complete plan from failing merely because an author omitted
the redundant ownership annotation.
"""
claims = {
str(claim.get("claim_id") or ""): str(claim.get("claim_kind") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str)
}
assigned = {claim_id for node in plan.nodes for claim_id in node.claim_ids}
unowned = [
claim_id for claim_id, claim_kind in claims.items()
if claim_id not in assigned and claim_kind in {"single_connected_body", "solid_count_equals"}
]
roots = [
node for node in plan.nodes
if not node.depends_on and node.atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add", "sphere_add"}
]
if not unowned or len(roots) != 1:
return plan
normalized = plan.model_copy(deep=True)
root_id = roots[0].node_id
for node in normalized.nodes:
if node.node_id == root_id:
node.claim_ids = [*node.claim_ids, *unowned]
break
return normalized
def _required_replacements(self, plan: FeaturePlan, task_id: str) -> set[str]:
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
statuses = scheduler.statuses()
failed = {node_id for node_id, status in statuses.items() if status == "failed"}
if not failed:
return set()
children: dict[str, set[str]] = {node.node_id: set() for node in plan.nodes}
for node in plan.nodes:
for dependency in node.depends_on:
children.setdefault(dependency, set()).add(node.node_id)
result = set(failed)
pending = list(failed)
while pending:
current = pending.pop()
for child in children.get(current, set()):
if statuses.get(child) != "done" and child not in result:
result.add(child)
pending.append(child)
return result
def submit_compiled_spec(self, task_id: str, output: CompiledRequirementsSpec, *, invocation_id: str) -> Accepted | Rejected:
replay = self._replay(task_id, invocation_id)
@@ -79,7 +309,10 @@ class RequirementsCommandHandler:
if len(output.requirements) != len(targets):
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "The compiled requirements must contain exactly one entry for every frozen completion target.", field_errors=({"path": "/requirements", "message": f"Expected {len(targets)} entries, received {len(output.requirements)}."},)))
normalized_output, compiler_warnings = self._normalize_compiled_spec(output, targets)
field_errors = self._claim_errors(normalized_output)
field_errors = [
*self._claim_errors(normalized_output),
*self._relationship_claim_errors(normalized_output, targets),
]
if field_errors:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements compilation contains an unreadable or non-executable acceptance target.", field_errors=tuple(field_errors)))
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_compilation", state.working_head, normalized_output.model_dump(mode="json")))
@@ -101,7 +334,7 @@ class RequirementsCommandHandler:
claim_position += 1
requirements.append({"requirement_id": f"req_{position:03d}", "source_ids": source_ids, "statement": target, "assumptions": list(compiled.assumptions), "acceptance_claims": claims})
spec = {"schema_version": "cad.requirements-spec.v2", "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "image_observation_path": "documents/image-observation.json" if observation else "", "requirements": [item.model_dump(mode="json") for item in normalized_output.requirements]}
contract = {"schema_version": "cad.requirements-contract.v3.1", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings}
contract = {"schema_version": "cad.requirements-contract.v3.2", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings}
contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
try:
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec)
@@ -176,12 +409,57 @@ class RequirementsCommandHandler:
errors.extend({"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{item['path']}", "message": item["message"]} for item in messages)
return errors
@staticmethod
def _relationship_claim_errors(output: CompiledRequirementsSpec, targets: list[str]) -> list[dict[str, str]]:
"""Require explicit coverage for an unambiguous centered-bore target.
The compiler remains free to choose claims for open-ended CAD prose.
A checklist item that explicitly says a bore is centered/concentric is
different: dropping that relationship leaves a measurable user fact
with no acceptance owner. The service only checks its presence here;
the registry validates its numeric parameters independently.
"""
errors: list[dict[str, str]] = []
for index, (target, requirement) in enumerate(zip(targets, output.requirements, strict=True)):
lowered = target.casefold()
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
continue
if not any(marker in lowered for marker in _BORE_MARKERS):
continue
if any(
claim.claim_kind == "concentric_bore_to_outer_cylinder"
for claim in requirement.acceptance_claims
):
continue
errors.append({
"path": f"/requirements/{index}/acceptance_claims",
"message": "A centered or concentric bore requires concentric_bore_to_outer_cylinder coverage.",
})
return errors
def _normalize_compiled_spec(self, output: CompiledRequirementsSpec, targets: list[str]) -> tuple[CompiledRequirementsSpec, list[str]]:
normalized = output.model_copy(deep=True)
warnings: list[str] = []
for target, requirement in zip(targets, normalized.requirements, strict=True):
normalized_claims = []
for claim in requirement.acceptance_claims:
if self._is_slot_misclassified_as_corner_bore_pattern(claim, target):
warnings.append(
f"rectangular_corner_through_bore_pattern for checklist item '{target}' describes an obround slot, not four circular bores, so it was compiled as visual review."
)
claim.claim_kind = "visual"
claim.expected = {"description": target[:360]}
normalized_claims.append(claim)
continue
if self._is_unbacked_coaxial_bore_group(normalized, claim):
warnings.append(
f"coaxial_through_bore_group for checklist item '{target}' has no matching multi-bore target, so it was compiled as visual review. "
"Use concentric_bore_to_outer_cylinder for one central bore and one outer cylinder."
)
claim.claim_kind = "visual"
claim.expected = {"description": target[:360]}
normalized_claims.append(claim)
continue
if claim.claim_kind in _RECORD_BOUND_CLAIMS:
warnings.append(
f"{claim.claim_kind} verifier for checklist item '{target}' requires server-bound topology records, so it was compiled as visual review."
@@ -202,8 +480,104 @@ class RequirementsCommandHandler:
"claim_kind": "visual",
"expected": {"description": target[:360]},
})]
self._derive_centered_bore_claims(normalized, targets)
return normalized, list(dict.fromkeys(warnings))
@staticmethod
def _is_slot_misclassified_as_corner_bore_pattern(claim: Any, target: str) -> bool:
"""Keep a circular-hole verifier from accepting or rejecting a slot.
``rectangular_corner_through_bore_pattern`` measures four complete
cylindrical bores at equal edge offsets. An obround slot has two arc
ends and straight flanks; treating its stated length as an edge offset
produces an unsatisfiable contract even when the CAD is correct.
"""
return (
getattr(claim, "claim_kind", "") == "rectangular_corner_through_bore_pattern"
and any(marker in target.casefold() for marker in _OBROUND_SLOT_MARKERS)
)
@staticmethod
def _derive_centered_bore_claims(output: CompiledRequirementsSpec, targets: list[str]) -> None:
"""Attach a measurable concentricity claim when its inputs are frozen.
The requirements compiler receives a Markdown checklist, not runtime
geometry IDs. Once it has already compiled an external cylindrical
diameter and an explicitly centred bore diameter, their relationship
is a service-owned mechanical consequence. Requiring an author to
remember the internal verifier name makes a complete user request
fail for a bookkeeping omission rather than a CAD decision.
"""
outer_diameters: list[float] = []
for requirement in output.requirements:
for claim in requirement.acceptance_claims:
expected = claim.expected
diameter = expected.get("diameter_mm") if isinstance(expected, dict) else None
if claim.claim_kind == "outer_cylindrical_surface" and isinstance(diameter, (int, float)) and float(diameter) > 0:
outer_diameters.append(float(diameter))
if not outer_diameters:
return
outer_diameter = max(outer_diameters)
bore_claim_kinds = frozenset({"through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth"})
for target, requirement in zip(targets, output.requirements, strict=True):
lowered = target.casefold()
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
continue
if not any(marker in lowered for marker in _BORE_MARKERS):
continue
if any(claim.claim_kind == "concentric_bore_to_outer_cylinder" for claim in requirement.acceptance_claims):
continue
bore_diameter = next((
float(claim.expected["diameter_mm"])
for claim in requirement.acceptance_claims
if claim.claim_kind in bore_claim_kinds
and isinstance(claim.expected, dict)
and isinstance(claim.expected.get("diameter_mm"), (int, float))
and float(claim.expected["diameter_mm"]) > 0
), None)
if bore_diameter is None:
continue
requirement.acceptance_claims.append(AcceptanceClaimInput.model_validate({
"claim_kind": "concentric_bore_to_outer_cylinder",
"expected": {
"bore_diameter_mm": bore_diameter,
"outer_diameter_mm": outer_diameter,
"tolerance_mm": 0.01,
},
}))
@staticmethod
def _is_unbacked_coaxial_bore_group(output: CompiledRequirementsSpec, claim: Any) -> bool:
"""Reject a bore-group verifier when the contract has no such group.
``coaxial_through_bore_group`` measures multiple inner bores of one
diameter. It cannot prove a lone central bore is concentric with an
external cylindrical wall. This is a mechanical consistency check:
some through-bore claim must request at least the group count.
"""
if getattr(claim, "claim_kind", "") != "coaxial_through_bore_group":
return False
expected = getattr(claim, "expected", {})
if not isinstance(expected, dict):
return True
diameter = expected.get("diameter_mm")
count = expected.get("count")
if not isinstance(diameter, (int, float)) or not isinstance(count, int):
return True
for requirement in output.requirements:
for candidate in requirement.acceptance_claims:
candidate_expected = getattr(candidate, "expected", {})
if (
getattr(candidate, "claim_kind", "") == "through_cylindrical_bore"
and isinstance(candidate_expected, dict)
and isinstance(candidate_expected.get("diameter_mm"), (int, float))
and isinstance(candidate_expected.get("count"), int)
and abs(float(candidate_expected["diameter_mm"]) - float(diameter)) <= 1e-9
and int(candidate_expected["count"]) >= count
):
return False
return True
@staticmethod
def _is_local_cylindrical_span_bbox(claims: list[Any], claim: Any, target: str) -> bool:
if claim.claim_kind != "bbox_dimension_mm" or claim.expected.get("axis") != "z":
+502 -18
View File
@@ -1,8 +1,8 @@
"""LLM turn coordinator for protocol v3.
It selects only which schema is visible from the persisted state. It never
derives a CAD operation from requirements; that choice is always an author
tool call handled by ``ActionCommandHandler``.
It selects only the structured schema visible from persisted state. Feature
selection is server-owned: the scheduler chooses one atomic DAG node and the
author can submit only that node's fragment.
"""
from __future__ import annotations
@@ -29,6 +29,7 @@ from app.cad_agent.application.llm_contracts import (
)
from app.cad_agent.application.requirements import RequirementsCommandHandler
from app.cad_agent.application.results import Accepted, Rejected, Waiting
from app.cad_agent.domain.feature_plan import FeaturePlan, plan_hash
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
from app.cad_agent.domain.operation_contract import fragment_schema
from app.cad_agent.domain.state import TaskPhase, TaskState, retry_resume_event, transition
@@ -36,6 +37,7 @@ from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, M
T = TypeVar("T", bound=BaseModel)
_FEATURE_REPLAN_FAILURE_LIMIT = 3
@dataclass(frozen=True, slots=True)
@@ -248,6 +250,13 @@ class WorkflowCoordinator:
if state.phase in {TaskPhase.FAILED, TaskPhase.WAITING_RETRY}:
yield "task_terminal", self._projected_terminal(task_id, state)
return
if state.phase == TaskPhase.FEATURE_BUILDING:
recovered = self.actions.recover_feature_build(task_id)
yield "feature_result", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True}
if isinstance(recovered, Rejected):
yield self._service_failure(task_id, state, recovered.error)
return
continue
if state.phase == TaskPhase.CANDIDATE_BUILDING:
recovered = self.actions.recover_candidate_build(task_id)
yield "candidate_result", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True}
@@ -434,6 +443,130 @@ class WorkflowCoordinator:
yield "requirements_compiled", self._event(task_id, name, self._result_payload(command), "success", usage)
feedback = []
continue
if state.phase in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
exhausted = self._feature_replan_exhausted(task_id, state)
if exhausted is not None:
failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
self.repository.compare_and_swap(failed, events=[{
"event": "feature_plan_no_progress_limit",
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
"message": "The same atomic feature exhausted its cross-plan replan budget.",
"checkpoint_preserved": bool(state.active_revision),
"revision_id": state.active_revision,
**exhausted,
}])
yield "task_terminal", {
"taskId": task_id,
"lifecycle": "failed",
"revisionId": state.active_revision,
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
"message": "The same atomic feature repeatedly failed after local replanning; the last executable checkpoint remains available.",
}
return
plan_schema = self.requirements.feature_plan_schema(task_id)
tools = [self._tool("write_feature_plan", plan_schema)]
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
if terminal:
yield terminal
return
call_budget.record_attempt("author")
result = await self._author_turn(task_id, active_author, tools, feedback)
if isinstance(result, WorkflowError):
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
terminal = self._format_failure(task_id, state, "write_feature_plan", result, format_errors, feedback)
yield "feature_plan", {"taskId": task_id, "status": "error", "result": result.payload()}
if terminal:
yield terminal
return
continue
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
if terminal:
yield terminal
return
continue
name, raw, usage = result
validation = canonical_validate(raw, FeaturePlan)
dynamic_error = canonical_validate_schema(raw, plan_schema) if not isinstance(validation, WorkflowError) else None
if dynamic_error is not None:
validation = dynamic_error
if isinstance(validation, WorkflowError):
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
yield "feature_plan", self._event(task_id, name, validation.payload(), "error", usage)
if terminal:
yield terminal
return
continue
command = self.requirements.submit_feature_plan(task_id, validation, invocation_id=self._invocation_id(task_id))
if isinstance(command, Rejected):
terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback, tool="write_feature_plan")
yield "feature_plan", self._event(task_id, name, command.error.payload(), "error", usage)
if terminal:
yield terminal
return
continue
yield "feature_plan_ready", self._event(task_id, name, self._result_payload(command), "success", usage)
feedback = []
continue
if state.phase == TaskPhase.SCHEDULING_FEATURE:
command = self.actions.schedule_next_feature(task_id)
if isinstance(command, Rejected):
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": command.error.code.value, "message": command.error.message}
return
yield "feature_scheduled", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
continue
if state.phase == TaskPhase.FEATURE_PENDING:
observed = action_observations.setdefault(state.working_head, set())
tools = self._action_tools(task_id, state, observed)
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
if terminal:
yield terminal
return
call_budget.record_attempt("author")
result = await self._author_turn(task_id, active_author, tools, feedback)
if isinstance(result, WorkflowError):
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback)
yield "feature_result", {"taskId": task_id, "status": "error", "result": result.payload()}
if terminal:
yield terminal
return
continue
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
if terminal:
yield terminal
return
continue
name, raw, usage = result
if name == "inspect_topology":
validation = canonical_validate(raw, StatelessTopologyRequest)
if isinstance(validation, WorkflowError):
yield "feature_result", self._event(task_id, name, validation.payload(), "error", usage)
continue
payload = self._topology_payload(task_id, state, validation.kind, validation.limit)
observed.add("topology")
yield "tool_call", self._event(task_id, name, payload, "success", usage)
feedback = [{"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}]
continue
fragment = canonical_json_object(raw)
if isinstance(fragment, WorkflowError):
yield "feature_result", self._event(task_id, name, fragment.payload(), "error", usage)
feedback = [self._feedback(fragment)]
continue
command = self.actions.submit_feature_fragment(task_id, fragment, invocation_id=self._invocation_id(task_id))
if isinstance(command, Rejected):
after = self.repository.get_state(task_id)
yield "feature_result", self._event(task_id, name, command.error.payload(), "error", usage)
if after is not None and after.version != state.version:
feedback = [self._feedback(command.error)]
continue
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
if terminal:
yield terminal
return
continue
yield "feature_result", self._event(task_id, name, self._result_payload(command), "success", usage)
feedback = []
continue
if state.phase == TaskPhase.AWAITING_ACTION:
contract = self._requirements_contract(task_id, state) or {}
requirement_ids = [str(item.get("requirement_id") or "") for item in contract.get("requirements") or () if isinstance(item, dict) and item.get("requirement_id")]
@@ -688,6 +821,14 @@ class WorkflowCoordinator:
yield "candidate_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
continue
if state.phase == TaskPhase.FINAL_VALIDATION:
facts = self.actions._facts(task_id, state.active_revision)
if not (facts.get("report") or {}).get("render_manifest"):
try:
self.runtime.render_review_bundle(str(self.artifacts.artifact_path(task_id, f"revisions/{state.active_revision}")))
except Exception as error:
yield self._service_failure(task_id, state, WorkflowError(ErrorCode.RENDER_SERVICE_UNAVAILABLE, str(error)[:1000], retryable=True))
return
yield "final_render_ready", {"taskId": task_id, "revisionId": state.active_revision, "status": "success"}
recovered = self.actions.recover_final_review(task_id)
if recovered is not None:
if isinstance(recovered, Accepted) and recovered.payload.get("status") == "completed":
@@ -768,6 +909,15 @@ class WorkflowCoordinator:
yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
continue
state = self.repository.get_state(task_id)
if state is not None and state.feature_plan_hash:
failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
self.repository.compare_and_swap(failed, events=[{
"event": "feature_plan_no_progress_limit", "code": ErrorCode.NO_PROGRESS_LIMIT.value,
"message": "The feature DAG reached its bounded turn limit.",
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision,
}])
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision, "code": ErrorCode.NO_PROGRESS_LIMIT.value, "message": "The feature DAG reached its bounded turn limit; the last executable checkpoint remains available."}
return
terminal = self._best_effort_terminal(task_id, state, ErrorCode.NO_PROGRESS_LIMIT, "The workflow reached its bounded turn limit.") if state else None
if terminal:
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
@@ -781,6 +931,14 @@ class WorkflowCoordinator:
yield self._storage_failure(task_id, str(error))
except Exception as error:
state = self.repository.get_state(task_id)
if state is not None and state.feature_plan_hash and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL)
self.repository.compare_and_swap(failed, events=[{
"event": "feature_plan_internal_failure", "message": str(error)[:1000],
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision,
}])
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision, "code": ErrorCode.FAILED_INTERNAL.value, "message": str(error)[:1000]}
return
terminal = self._best_effort_terminal(task_id, state, ErrorCode.FAILED_INTERNAL, str(error)[:1000]) if state else None
if terminal:
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
@@ -814,6 +972,19 @@ class WorkflowCoordinator:
return None
details = budget.payload()
details["next_actor"] = actor
if state.feature_plan_hash:
failed = transition(state, "failed", error=ErrorCode.CALL_BUDGET_EXHAUSTED)
self.repository.compare_and_swap(failed, events=[{
"event": "call_budget_exhausted", "code": ErrorCode.CALL_BUDGET_EXHAUSTED.value,
"message": "Configured model-call budget is exhausted before the feature DAG converged.",
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision, **details,
}])
return "task_terminal", {
"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision,
"code": ErrorCode.CALL_BUDGET_EXHAUSTED.value,
"message": "Configured model-call budget is exhausted before the feature DAG converged; the last executable checkpoint remains available.",
"budget": details, "blockerType": "call_budget_exhausted", "userActionRequired": False,
}
terminal = self._best_effort_terminal(
task_id,
state,
@@ -1235,7 +1406,7 @@ class WorkflowCoordinator:
return []
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT:
content = {
"protocol": "cad.v3.1.markdown-first",
"protocol": "cad.v3.2.feature-dag",
"source_requirements": self.artifacts.read_source_requirements(task_id),
"image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"),
"user_clarifications": self._user_clarifications(task_id),
@@ -1246,31 +1417,85 @@ class WorkflowCoordinator:
}
elif state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET:
content = {
"protocol": "cad.v3.1.markdown-first",
"protocol": "cad.v3.2.feature-dag",
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
"instruction": "Write # Completion Target with unique - [ ] checklist items. Each item must describe one independently observable final feature or condition. Do not add requirements not present in the frozen requirements document and do not include runtime identifiers.",
}
elif state.phase == TaskPhase.COMPILING_REQUIREMENTS:
content = {
"protocol": "cad.v3.1.markdown-first",
"protocol": "cad.v3.2.feature-dag",
"source_requirements": self.artifacts.read_source_requirements(task_id),
"image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"),
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
"verifier_registry": self.requirements.registry.expected_one_of_schema(),
"instruction": "Compile exactly one ordered verifier bundle for each checklist item. The checklist text and all IDs are service-owned: output only assumptions and acceptance claims. Use deterministic verifiers for measurable defaults recorded in Markdown; use visual only for non-measurable appearance.",
"instruction": "Compile exactly one ordered verifier bundle for each checklist item. The checklist text and all IDs are service-owned: output only assumptions and acceptance claims. Use deterministic verifiers for measurable defaults recorded in Markdown; use visual only for non-measurable appearance. An obround, slotted, or long-slot feature is not a rectangular_corner_through_bore_pattern: keep it visual unless a dedicated slot verifier is available. Every explicit centered, concentric, or coaxial bore must have concentric_bore_to_outer_cylinder coverage. coaxial_through_bore_group is only for two or more same-diameter inner bores. For one central bore concentric with an external cylinder, use concentric_bore_to_outer_cylinder with bore_diameter_mm and outer_diameter_mm.",
}
elif state.phase == TaskPhase.DRAFTING_MODELING_PLAN:
elif state.phase in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
active_plan = self.artifacts.read_json(task_id, state.feature_plan_path) if state.feature_plan_path else None
contract = self._requirements_contract(task_id, state) or {}
deterministic_claim_ids = sorted(
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") == "deterministic"
and isinstance(claim.get("claim_id"), str)
and claim.get("claim_id")
)
visual_claim_ids = sorted(
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") != "deterministic"
and isinstance(claim.get("claim_id"), str)
and claim.get("claim_id")
)
required_replacements: list[str] = []
parent_plan_hash = ""
if isinstance(active_plan, dict):
try:
parsed_plan = FeaturePlan.model_validate(active_plan)
parent_plan_hash = plan_hash(parsed_plan)
required_replacements = sorted(self.requirements._required_replacements(parsed_plan, task_id))
except ValueError:
pass
content = {
"protocol": "cad.v3.1.markdown-first",
"protocol": "cad.v3.2.feature-dag",
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
"compiled_contract": self._requirements_contract(task_id, state),
"instruction": "Write # Modeling Plan with a short ordered list of feature-construction steps. It is a frozen execution guide only: do not add, remove, or reinterpret requirements and do not include runtime identifiers.",
"compiled_contract": contract,
"claim_ownership_binding": {
"node_claim_ids_must_be_drawn_only_from": deterministic_claim_ids,
"final_claim_ids_must_equal": visual_claim_ids,
"visual_only_nodes_must_use_empty_claim_ids": True,
},
"supported_atomic_ids": list(self.runtime.supported_atomic_ids()),
"active_feature_plan": active_plan,
"plan_lineage_binding": {
"parent_plan_hash": parent_plan_hash,
"replaces_node_ids": required_replacements,
},
"feature_node_statuses": self._feature_node_statuses(task_id, active_plan),
"replanning_evidence": self._replanning_evidence(task_id, state),
"instruction": (
"Write the complete feature-plan JSON. Each node is exactly one runtime atomic feature. "
"Every deterministic frozen claim must belong to exactly one node; every visual claim must be in final_claim_ids. "
"Never place a visual claim in any node claim_ids. Nodes for ribs, chamfers, slots, or other visual-only work are valid with claim_ids: []. "
"Use dependency edges only for direct geometric prerequisites and unique fixed priorities. "
"The tool schema binds parent_plan_hash and replaces_node_ids to the service values: copy those exact values, never use the requirements-contract hash. "
"For a revision, preserve completed nodes byte-for-byte and use new IDs for replacements."
),
}
else:
contract = self._requirements_contract(task_id, state) or {}
action = state.pending_feature
feature_node_turn = state.phase == TaskPhase.FEATURE_PENDING and action is not None
compact = [{
"requirement_id": item.get("requirement_id"),
"statement": item.get("statement"),
"acceptance_claims": [
{
@@ -1281,14 +1506,20 @@ class WorkflowCoordinator:
if isinstance(claim, dict)
],
} for item in contract.get("requirements") or () if isinstance(item, dict)]
action = state.pending_action
if feature_node_turn:
owned_requirements = set(action.requirement_ids)
compact = [
item for item in compact
if str(item.get("requirement_id") or "") in owned_requirements
]
operation = self.runtime.operation_contract(action.atomic_id) if action is not None else None
selector_shape = str((operation or {}).get("fragment_shape", {}).get("selector_tokens") or "forbidden")
instruction = "Choose only the next atomically verifiable action or complete when every requirement is proven. The service will not choose CAD operations for you."
instruction = "The service has scheduled one atomic feature from the immutable DAG. Submit exactly that feature's CDSL fragment."
if state.repair_required:
instruction = "A prior candidate or final review requires repair. Use the current server evidence to record a geometry conclusion, then either choose a new action or, after a rollback conclusion, request an earlier checkpoint. The service will not choose CAD operations for you."
operation_payload = self._operation_payload(task_id, state) if action is not None else None
selector_summary = []
sketch_workplane_candidates = []
if action is not None and selector_shape == "required":
tokens = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision))
allowed = set(self._selector_tokens_for_contract(operation or {}, tokens))
@@ -1297,7 +1528,35 @@ class WorkflowCoordinator:
for token, value in tokens.items() if token in allowed
][:16]
instruction = "The exact operation contract and eligible selector summary are attached. Submit one fragment; call inspect_topology only when the selector summary is marked truncated."
content = {"protocol": "cad.v3.1", "coordinate_protocol": self._coordinate_protocol(state), "phase": state.phase.value, "requirements_markdown": self._read_markdown(task_id, state.requirements_document_path), "completion_target_markdown": self._read_markdown(task_id, state.completion_target_path), "modeling_plan_markdown": self._read_markdown(task_id, state.modeling_plan_path), "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in self.actions.claim_summary(task_id, state)], "model_summary": self.actions.model_summary(task_id, state), "pending_action": self._public_pending_context(state), "operation_contract": self._public_operation_payload(operation_payload), "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "recent_failures": self._recent_failure_constraints(task_id, state), "repair_diagnostics": self.actions.repair_diagnostics(task_id, state), "rollback_checkpoints": list(self.actions.checkpoint_tokens(task_id, state)) if self.actions.rollback_available(task_id, state) else [], "instruction": instruction}
if (
feature_node_turn
and state.active_revision
and str((operation or {}).get("fragment_shape", {}).get("sketch") or "forbidden") == "required"
):
sketch_workplane_candidates = self._sketch_workplane_candidates(
self.artifacts.read_topology(task_id, state.active_revision)
)
if sketch_workplane_candidates:
instruction += (
" sketch_workplane_candidates are measured planar material faces. "
"For a blind cut, select a plane whose material region covers the intended profile; "
"do not choose a newer or higher face merely because it is the last feature."
)
current_claims = self.actions.claim_summary(task_id, state)
if feature_node_turn:
current_claims = [
item for item in current_claims
if str(item.get("claim_id") or "") in set(action.claim_ids)
]
content = {"protocol": "cad.v3.2.feature-dag", "coordinate_protocol": self._coordinate_protocol(state), "phase": state.phase.value, "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in current_claims], "model_summary": self.actions.model_summary(task_id, state), "pending_feature": self._public_pending_context(state), "direct_upstream_facts": self._direct_upstream_facts(task_id, state), "operation_contract": self._public_operation_payload(operation_payload), "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "sketch_workplane_candidates": sketch_workplane_candidates, "recent_failures": self._recent_failure_constraints(task_id, state), "instruction": instruction}
if not feature_node_turn:
content.update({
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
"feature_plan": self.artifacts.read_json(task_id, state.feature_plan_path) if state.feature_plan_path else None,
"feature_plan_hash": state.feature_plan_hash,
"feature_node_statuses": self._feature_node_statuses(task_id, None),
})
messages: list[dict[str, Any]] = [{"role": "system", "content": "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON."}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}]
return [*messages, *feedback[-2:]]
@@ -1322,6 +1581,160 @@ class WorkflowCoordinator:
except (OSError, ValueError):
return ""
def _feature_node_statuses(self, task_id: str, plan_payload: dict[str, Any] | None) -> dict[str, str]:
try:
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler
state = self.repository.get_state(task_id)
plan = FeaturePlan.model_validate(plan_payload) if isinstance(plan_payload, dict) else FeaturePlan.model_validate(self.artifacts.read_json(task_id, state.feature_plan_path) if state and state.feature_plan_path else None)
return FeatureScheduler(plan, self.repository.ledger_events(task_id)).statuses()
except (ValueError, TypeError, OSError):
return {}
def _direct_upstream_facts(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
"""Project only verified direct dependencies for a scheduled node.
The current model summary describes the working head. This additional
projection lets the author distinguish the exact prerequisite nodes
without exposing arbitrary historical topology or old failed stages.
"""
pending = state.pending_feature
if pending is None or not pending.depends_on_node_ids:
return []
verified: dict[str, dict[str, Any]] = {}
for event in self.repository.ledger_events(task_id):
if event.get("event") == "feature_node_verified":
node_id = str(event.get("node_id") or "")
if node_id in pending.depends_on_node_ids:
verified[node_id] = event
result: list[dict[str, Any]] = []
for node_id in pending.depends_on_node_ids:
event = verified.get(node_id)
if event is None:
continue
revision_id = str(event.get("revision_id") or "")
verification = self.artifacts.read_json(task_id, f"revisions/{revision_id}/node-verification.json") if revision_id else None
evidence = verification if isinstance(verification, dict) else {}
result.append({
"node_id": node_id,
"revision_id": revision_id,
"feature_id": str(event.get("feature_id") or evidence.get("feature_id") or ""),
"claim_results": [
{
"claim_id": str(item.get("claim_id") or ""),
"status": str(item.get("status") or ""),
"claim_kind": str(item.get("claim_kind") or ""),
"evidence": item.get("evidence") if isinstance(item.get("evidence"), dict) else {},
}
for item in evidence.get("claim_results") or ()
if isinstance(item, dict)
],
"operation_verifier_results": [
{
"claim_kind": str(item.get("claim_kind") or ""),
"status": str(item.get("status") or ""),
"evidence": item.get("evidence") if isinstance(item.get("evidence"), dict) else {},
}
for item in evidence.get("operation_verifier_results") or ()
if isinstance(item, dict)
],
"health": evidence.get("health") if isinstance(evidence.get("health"), dict) else {},
})
return result
@staticmethod
def _sketch_workplane_candidates(topology: dict[str, Any] | None, *, limit: int = 12) -> list[dict[str, Any]]:
"""Expose compact, measured planes for sketch operations without selectors.
Sketch extrusions deliberately do not use B-rep selector tokens. The
author still needs enough geometry to choose a material face instead of
placing a profile on the most recently created boss.
"""
records = topology.get("records") if isinstance(topology, dict) else None
if not isinstance(records, list):
return []
candidates: list[dict[str, Any]] = []
seen: set[tuple[float, ...]] = set()
for record in records:
geometry = record.get("geometry") if isinstance(record, dict) else None
center = geometry.get("center_mm") if isinstance(geometry, dict) else None
normal = geometry.get("normal") if isinstance(geometry, dict) else None
bbox = geometry.get("bbox_mm") if isinstance(geometry, dict) else None
if (
not isinstance(geometry, dict)
or geometry.get("surface_type") != "plane"
or not isinstance(center, list)
or not isinstance(normal, list)
or len(center) != 3
or len(normal) != 3
or not isinstance(bbox, list)
or len(bbox) != 6
):
continue
try:
point = [round(float(value), 4) for value in center]
direction = [round(float(value), 4) for value in normal]
bounds = [round(float(value), 4) for value in bbox]
except (TypeError, ValueError):
continue
key = tuple(direction + point + bounds)
if key in seen:
continue
seen.add(key)
footprint = abs((bounds[3] - bounds[0]) * (bounds[4] - bounds[1]))
candidates.append({
"point_mm": point,
"normal": direction,
"bbox_mm": bounds,
"footprint_bbox_area_mm2": round(footprint, 4),
})
# Favor outward horizontal faces, then the broadest support surface.
# A base top commonly supports an outer slot while a taller boss does
# not; ordering by Z would teach the opposite choice.
candidates.sort(key=lambda item: (
-float(item["normal"][2]),
-float(item["footprint_bbox_area_mm2"]),
-float(item["point_mm"][2]),
item["bbox_mm"],
))
return candidates[:limit]
def _replanning_evidence(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
"""Provide bounded server evidence that led to this local replan."""
evidence: list[dict[str, Any]] = []
for event in reversed(self.repository.ledger_events(task_id)):
if event.get("plan_hash") != state.feature_plan_hash:
continue
if event.get("event") == "feature_node_failed":
evidence.append({
"kind": "node_failure",
"node_id": str(event.get("node_id") or ""),
"failure_class": str(event.get("failure_class") or ""),
"attempt": int(event.get("attempt") or 0),
"message": str(event.get("message") or "")[:500],
"blockers": (event.get("blockers") or [])[:8] if isinstance(event.get("blockers"), list) else [],
})
elif event.get("event") == "final_visual_reviewed" and event.get("visual_not_passed"):
evidence.append({
"kind": "final_visual_review",
"claim_ids": [str(value) for value in event.get("visual_not_passed") or () if str(value)],
"issues": [str(value)[:500] for value in event.get("issues") or () if str(value)],
"evidence": [str(value)[:500] for value in event.get("evidence") or () if str(value)],
})
elif event.get("event") == "feature_plan_completion_failed":
evidence.append({
"kind": "final_deterministic_validation",
"claim_ids": [str(value) for value in event.get("failed_claim_ids") or () if str(value)],
"message": str(event.get("message") or "")[:500],
"claim_results": [
self._public_claim_result(item)
for item in event.get("claim_results") or ()
if isinstance(item, dict) and item.get("deterministic") and item.get("status") != "pass"
],
})
if len(evidence) == 8:
break
return evidence
def waiting_for_user_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
"""Expose the persisted requirement question when a task is parked.
@@ -1419,7 +1832,15 @@ class WorkflowCoordinator:
@staticmethod
def _public_pending_context(state: TaskState) -> dict[str, Any] | None:
action = state.pending_action
return {"intent": action.intent, "operation": action.atomic_id, "expected_change": action.expected_change} if action else None
return {
"node_id": action.node_id,
"plan_hash": action.plan_hash,
"intent": action.intent,
"operation": action.atomic_id,
"expected_change": action.expected_change,
"claim_ids": list(action.claim_ids),
"depends_on_node_ids": list(action.depends_on_node_ids),
} if action else None
@staticmethod
def _coordinate_protocol(state: TaskState) -> dict[str, str]:
@@ -1471,13 +1892,20 @@ class WorkflowCoordinator:
def _recent_failure_constraints(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
constraints: list[dict[str, Any]] = []
for event in reversed(self.repository.ledger_events(task_id)):
if event.get("checkpoint_revision") != state.active_revision:
is_current_feature_failure = (
event.get("event") == "feature_node_failed"
and event.get("plan_hash") == state.feature_plan_hash
and state.pending_feature is not None
and event.get("node_id") == state.pending_feature.node_id
)
if not is_current_feature_failure and event.get("checkpoint_revision") != state.active_revision:
continue
if not event.get("normalized_error_code"):
normalized_error_code = str(event.get("normalized_error_code") or event.get("code") or "")
if not normalized_error_code:
continue
constraints.append({
"atomic_id": str(event.get("atomic_id") or ""),
"normalized_error_code": str(event.get("normalized_error_code") or ""),
"normalized_error_code": normalized_error_code,
"fragment_hash": str(event.get("fragment_hash") or ""),
"prohibited_exact_fingerprint": str(event.get("failure_exact_fingerprint") or ""),
"attempt": int(event.get("attempt") or 1),
@@ -1487,6 +1915,42 @@ class WorkflowCoordinator:
break
return constraints
def _feature_replan_exhausted(self, task_id: str, state: TaskState) -> dict[str, Any] | None:
"""Bound repeated replacement plans at one immutable checkpoint.
Node IDs must change on every subgraph revision, so a node-local retry
counter alone cannot stop a planner from replacing the same failed
atomic operation forever. A successful feature creates a new revision;
therefore checkpoint + atomic operation is a stable, narrow boundary
for this cross-plan budget.
"""
if state.phase != TaskPhase.REPLANNING_FEATURE_SUBGRAPH:
return None
terminal = [
event for event in self.repository.ledger_events(task_id)
if event.get("event") == "feature_node_failed"
and bool(event.get("terminal"))
and str(event.get("checkpoint_revision") or "") == state.active_revision
]
if not terminal:
return None
atomic_id = str(terminal[-1].get("atomic_id") or "")
if not atomic_id:
return None
matching = [
event for event in terminal
if str(event.get("atomic_id") or "") == atomic_id
]
if len(matching) < _FEATURE_REPLAN_FAILURE_LIMIT:
return None
return {
"atomic_id": atomic_id,
"checkpoint_revision": state.active_revision,
"terminal_failure_count": len(matching),
"limit": _FEATURE_REPLAN_FAILURE_LIMIT,
"node_ids": [str(event.get("node_id") or "") for event in matching],
}
@staticmethod
def _selector_tokens_for_contract(contract: dict[str, Any], tokens: dict[str, dict[str, Any]]) -> list[str]:
"""Narrow dynamic selector enums to the current contract's kind."""
@@ -1697,6 +2161,18 @@ class WorkflowCoordinator:
feedback[:] = [self._feedback(error)]
if counters[name] < self.config.format_error_limit:
return None
if state.feature_plan_hash:
failed = transition(state, "failed", error=ErrorCode.FAILED_AUTHOR_FORMAT)
self.repository.compare_and_swap(failed, events=[{
"event": "failed_author_format", "tool": name, "field_errors": list(error.field_errors),
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision,
}])
return "task_terminal", {
"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision,
"code": ErrorCode.FAILED_AUTHOR_FORMAT.value,
"message": f"{actor.capitalize()} repeatedly failed the canonical schema; the last executable checkpoint remains available.",
"tool": name, "field_errors": list(error.field_errors),
}
terminal = self._best_effort_terminal(
task_id,
state,
@@ -1760,6 +2236,14 @@ class WorkflowCoordinator:
) -> tuple[str, dict[str, Any]] | None:
if error.retryable or error.code == ErrorCode.STORAGE_FAILURE:
return self._service_failure(task_id, state, error)
if tool == "write_feature_plan":
# A plan revision is authored against an already-frozen contract.
# Its field errors must use the plan tool's own retry budget, not
# the requirements compiler's shared counter. Otherwise one
# earlier requirements correction can make the first replan
# attempt terminal, even though the state remains perfectly
# recoverable in REPLANNING_FEATURE_SUBGRAPH.
return self._format_failure(task_id, state, tool, error, counters, feedback)
return self._requirements_format_failure(task_id, state, error, counters, feedback, tool=tool)
@staticmethod
+4 -1
View File
@@ -18,6 +18,7 @@ from app.cad_agent.application.requirements import RequirementsCommandHandler
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
from app.cad_agent.domain.verifier_registry import default_registry
from app.settings import Settings
from app.services.storage import WorkspaceStore
@dataclass(frozen=True, slots=True)
@@ -35,11 +36,13 @@ def compose_v3(settings: Settings) -> V3Services:
# Protocol 3.1 has no valid interpretation for structured-only task
# artifacts, so clear that task root together with its old database.
shutil.rmtree(settings.task_root)
if repository.protocol_reset:
WorkspaceStore(settings).clear_current_task_references()
artifacts = FileArtifactStore(settings.task_root)
runtime = ProfileCadRuntime(settings)
registry = default_registry()
verifier = RegistryVerifierExecutor(registry)
requirements = RequirementsCommandHandler(repository, artifacts, registry)
requirements = RequirementsCommandHandler(repository, artifacts, registry, atomic_ids=runtime.supported_atomic_ids)
actions = ActionCommandHandler(repository, artifacts, runtime, verifier)
fallbacks = tuple(
ModelIdentity(provider.id, model.id)
@@ -0,0 +1,265 @@
"""Immutable feature-DAG planning contracts and deterministic scheduling."""
from __future__ import annotations
from hashlib import sha256
import json
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
class _StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
class FeatureNode(_StrictModel):
node_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
priority: int = Field(ge=0, le=100_000)
intent: str = Field(min_length=1, max_length=360)
atomic_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
depends_on: list[str] = Field(default_factory=list, max_length=64)
claim_ids: list[str] = Field(default_factory=list, max_length=128)
expected_change: str = Field(min_length=1, max_length=360)
@model_validator(mode="after")
def _unique_references(self) -> "FeatureNode":
if len(self.depends_on) != len(set(self.depends_on)):
raise ValueError("depends_on must not contain duplicates")
if len(self.claim_ids) != len(set(self.claim_ids)):
raise ValueError("claim_ids must not contain duplicates")
if self.node_id in self.depends_on:
raise ValueError("a feature node cannot depend on itself")
return self
class FeaturePlan(_StrictModel):
schema_version: str = Field(pattern=r"^cad\.v3\.2\.feature-plan\.v1$")
parent_plan_hash: str = Field(default="", pattern=r"^(|[a-f0-9]{64})$")
replaces_node_ids: list[str] = Field(default_factory=list, max_length=128)
nodes: list[FeatureNode] = Field(min_length=1, max_length=256)
final_claim_ids: list[str] = Field(default_factory=list, max_length=128)
@model_validator(mode="after")
def _unique_plan_fields(self) -> "FeaturePlan":
node_ids = [node.node_id for node in self.nodes]
priorities = [node.priority for node in self.nodes]
if len(node_ids) != len(set(node_ids)):
raise ValueError("node_id values must be unique")
if len(priorities) != len(set(priorities)):
raise ValueError("priority values must be unique")
if len(self.replaces_node_ids) != len(set(self.replaces_node_ids)):
raise ValueError("replaces_node_ids must not contain duplicates")
if len(self.final_claim_ids) != len(set(self.final_claim_ids)):
raise ValueError("final_claim_ids must not contain duplicates")
return self
def plan_hash(plan: FeaturePlan | dict[str, Any]) -> str:
payload = plan.model_dump(mode="json") if isinstance(plan, FeaturePlan) else plan
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
def node_hash(node: FeatureNode | dict[str, Any]) -> str:
payload = node.model_dump(mode="json") if isinstance(node, FeatureNode) else node
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
def contract_claims(contract: dict[str, Any]) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for requirement in contract.get("requirements") or ():
if not isinstance(requirement, dict):
continue
for claim in requirement.get("acceptance_claims") or ():
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str):
result[claim["claim_id"]] = claim
return result
def validate_feature_plan(
plan: FeaturePlan,
contract: dict[str, Any],
atomic_ids: set[str] | frozenset[str] | tuple[str, ...],
*,
previous_plan: FeaturePlan | None = None,
completed_node_hashes: dict[str, str] | None = None,
required_replacements: set[str] | None = None,
) -> list[dict[str, str]]:
"""Return stable, tool-facing validation errors for a frozen plan."""
errors: list[dict[str, str]] = []
nodes = {node.node_id: node for node in plan.nodes}
known_atoms = set(atomic_ids)
claims = contract_claims(contract)
assigned: dict[str, str] = {}
for index, node in enumerate(plan.nodes):
prefix = f"/nodes/{index}"
if node.atomic_id not in known_atoms:
errors.append({"path": f"{prefix}/atomic_id", "message": "atomic_id is not supported by the runtime"})
for dependency in node.depends_on:
if dependency not in nodes:
errors.append({"path": f"{prefix}/depends_on", "message": f"unknown dependency '{dependency}'"})
for claim_id in node.claim_ids:
claim = claims.get(claim_id)
if claim is None:
errors.append({"path": f"{prefix}/claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
continue
if claim.get("verification_mode") != "deterministic":
errors.append({"path": f"{prefix}/claim_ids", "message": f"visual claim '{claim_id}' belongs in final_claim_ids"})
owner = assigned.setdefault(claim_id, node.node_id)
if owner != node.node_id:
errors.append({"path": f"{prefix}/claim_ids", "message": f"claim '{claim_id}' is already owned by '{owner}'"})
final = set(plan.final_claim_ids)
for claim_id in plan.final_claim_ids:
claim = claims.get(claim_id)
if claim is None:
errors.append({"path": "/final_claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
elif claim.get("verification_mode") == "deterministic":
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' must belong to one node"})
for claim_id, claim in claims.items():
deterministic = claim.get("verification_mode") == "deterministic"
if deterministic and claim_id not in assigned:
errors.append({"path": "/nodes", "message": f"deterministic claim '{claim_id}' has no owner"})
if not deterministic and claim_id not in final:
errors.append({"path": "/final_claim_ids", "message": f"visual claim '{claim_id}' has no final-review owner"})
if deterministic and claim_id in final:
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' has two owners"})
errors.extend(_cycle_errors(nodes))
if previous_plan is None:
if plan.parent_plan_hash:
errors.append({"path": "/parent_plan_hash", "message": "initial plan cannot have a parent_plan_hash"})
if plan.replaces_node_ids:
errors.append({"path": "/replaces_node_ids", "message": "initial plan cannot replace nodes"})
else:
errors.extend(_revision_errors(plan, previous_plan, completed_node_hashes or {}, required_replacements or set()))
return errors
def _cycle_errors(nodes: dict[str, FeatureNode]) -> list[dict[str, str]]:
visiting: set[str] = set()
visited: set[str] = set()
errors: list[dict[str, str]] = []
def walk(current: str) -> None:
if current in visiting:
errors.append({"path": "/nodes", "message": "feature dependencies contain a cycle"})
return
if current in visited:
return
visiting.add(current)
for dependency in nodes[current].depends_on:
if dependency in nodes:
walk(dependency)
visiting.remove(current)
visited.add(current)
for node_id in nodes:
walk(node_id)
return errors[:1]
def _revision_errors(plan: FeaturePlan, previous: FeaturePlan, completed: dict[str, str], required_replacements: set[str]) -> list[dict[str, str]]:
errors: list[dict[str, str]] = []
old_nodes = {node.node_id: node for node in previous.nodes}
next_nodes = {node.node_id: node for node in plan.nodes}
if plan.parent_plan_hash != plan_hash(previous):
errors.append({"path": "/parent_plan_hash", "message": "parent_plan_hash does not match the active plan"})
replaced = set(plan.replaces_node_ids)
if required_replacements and replaced != required_replacements:
errors.append({"path": "/replaces_node_ids", "message": "failed node and its unresolved downstream subgraph must be replaced together"})
for node_id, frozen_hash in completed.items():
node = next_nodes.get(node_id)
if node is None:
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was removed"})
elif node_hash(node) != frozen_hash:
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was modified"})
if node_id in replaced:
errors.append({"path": "/replaces_node_ids", "message": f"completed node '{node_id}' cannot be replaced"})
for node_id, old_node in old_nodes.items():
if node_id in replaced:
continue
current = next_nodes.get(node_id)
if current is None:
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was removed outside the replacement subgraph"})
elif node_hash(current) != node_hash(old_node):
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was modified outside the replacement subgraph"})
for node_id in replaced:
if node_id not in old_nodes:
errors.append({"path": "/replaces_node_ids", "message": f"unknown replaced node '{node_id}'"})
if node_id in next_nodes:
errors.append({"path": "/nodes", "message": f"replacement must use a new node_id, found '{node_id}'"})
return errors
class FeatureScheduler:
"""Derive a plan's runnable node from immutable ledger evidence."""
def __init__(self, plan: FeaturePlan, events: list[dict[str, Any]]) -> None:
self.plan = plan
self.events = events
self._nodes = {node.node_id: node for node in plan.nodes}
def statuses(self) -> dict[str, str]:
states: dict[str, str] = {node_id: "pending" for node_id in self._nodes}
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
for event in self.events:
node_id = str(event.get("node_id") or "")
if node_id not in states or event.get("node_hash") != expected_hashes[node_id]:
continue
if event.get("event") == "feature_node_verified":
states[node_id] = "done"
elif event.get("event") == "feature_node_invalidated" and states[node_id] != "done":
states[node_id] = "invalidated"
elif event.get("event") == "feature_node_failed" and states[node_id] != "done":
states[node_id] = "failed" if bool(event.get("terminal")) else "pending"
elif event.get("event") == "feature_node_scheduled" and states[node_id] == "pending":
states[node_id] = "running"
for node in self.plan.nodes:
if states[node.node_id] in {"done", "failed", "invalidated"}:
continue
dependency_states = [states.get(dependency, "blocked") for dependency in node.depends_on]
if any(value in {"failed", "invalidated", "blocked"} for value in dependency_states):
states[node.node_id] = "blocked"
elif all(value == "done" for value in dependency_states):
states[node.node_id] = "ready" if states[node.node_id] != "running" else "running"
return states
def next_ready(self) -> FeatureNode | None:
statuses = self.statuses()
ready = [node for node in self.plan.nodes if statuses[node.node_id] == "ready"]
return min(ready, key=lambda node: node.priority) if ready else None
def all_done(self) -> bool:
return all(value == "done" for value in self.statuses().values())
def feature_ids(self) -> dict[str, str]:
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
result: dict[str, str] = {}
for event in self.events:
node_id = str(event.get("node_id") or "")
feature_id = str(event.get("feature_id") or "")
if event.get("event") == "feature_node_verified" and node_id in expected_hashes and event.get("node_hash") == expected_hashes[node_id] and feature_id:
result[node_id] = feature_id
return result
def completed_node_hashes(self) -> dict[str, str]:
statuses = self.statuses()
return {
node_id: node_hash(self._nodes[node_id])
for node_id, status in statuses.items()
if status == "done"
}
def failure_count(self, node_id: str, failure_class: str) -> int:
expected = node_hash(self._nodes[node_id])
return sum(
1
for event in self.events
if event.get("event") == "feature_node_failed"
and event.get("node_id") == node_id
and event.get("node_hash") == expected
and event.get("failure_class") == failure_class
)
+51 -5
View File
@@ -12,6 +12,13 @@ class TaskPhase(StrEnum):
DRAFTING_REQUIREMENTS_DOCUMENT = "DRAFTING_REQUIREMENTS_DOCUMENT"
DRAFTING_COMPLETION_TARGET = "DRAFTING_COMPLETION_TARGET"
COMPILING_REQUIREMENTS = "COMPILING_REQUIREMENTS"
COMPILING_FEATURE_PLAN = "COMPILING_FEATURE_PLAN"
SCHEDULING_FEATURE = "SCHEDULING_FEATURE"
FEATURE_PENDING = "FEATURE_PENDING"
FEATURE_BUILDING = "FEATURE_BUILDING"
REPLANNING_FEATURE_SUBGRAPH = "REPLANNING_FEATURE_SUBGRAPH"
# Legacy v3.1 phases remain readable only so an interrupted process can
# fail cleanly during the protocol reset. New v3.2 tasks never enter them.
DRAFTING_MODELING_PLAN = "DRAFTING_MODELING_PLAN"
AWAITING_ACTION = "AWAITING_ACTION"
ACTION_PENDING = "ACTION_PENDING"
@@ -35,6 +42,15 @@ class PendingAction:
expected_change: str
contract_hash: str
idempotency_key: str
node_id: str = ""
plan_hash: str = ""
claim_ids: tuple[str, ...] = ()
depends_on_node_ids: tuple[str, ...] = ()
# The stored JSON key remains ``pending_action_json`` only in old artifacts.
# v3.2 code uses this alias to make the ownership boundary explicit.
PendingFeature = PendingAction
@dataclass(frozen=True, slots=True)
@@ -53,9 +69,16 @@ class TaskState:
requirements_document_path: str = ""
completion_target_path: str = ""
modeling_plan_path: str = ""
feature_plan_path: str = ""
feature_plan_hash: str = ""
feature_stage_id: str = ""
clarification_path: str = ""
requirements_contract_path: str = ""
@property
def pending_feature(self) -> PendingFeature | None:
return self.pending_action
@property
def working_head(self) -> str:
return f"{self.task_id}:{self.active_revision or 'root'}:v{self.version}"
@@ -67,7 +90,21 @@ _TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "image_observed"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "requirements_document_written"): TaskPhase.DRAFTING_COMPLETION_TARGET,
(TaskPhase.DRAFTING_COMPLETION_TARGET, "completion_target_written"): TaskPhase.COMPILING_REQUIREMENTS,
(TaskPhase.COMPILING_REQUIREMENTS, "requirements_compiled"): TaskPhase.DRAFTING_MODELING_PLAN,
(TaskPhase.COMPILING_REQUIREMENTS, "requirements_compiled"): TaskPhase.COMPILING_FEATURE_PLAN,
(TaskPhase.COMPILING_FEATURE_PLAN, "feature_plan_written"): TaskPhase.SCHEDULING_FEATURE,
# Retained for direct v3.1 handler callers only. The v3.2 workflow never
# exposes this event or accepts a Markdown plan from a model.
(TaskPhase.COMPILING_FEATURE_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
(TaskPhase.REPLANNING_FEATURE_SUBGRAPH, "feature_plan_revised"): TaskPhase.SCHEDULING_FEATURE,
(TaskPhase.SCHEDULING_FEATURE, "feature_scheduled"): TaskPhase.FEATURE_PENDING,
(TaskPhase.SCHEDULING_FEATURE, "final_requested"): TaskPhase.FINAL_VALIDATION,
(TaskPhase.SCHEDULING_FEATURE, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
(TaskPhase.FEATURE_PENDING, "feature_started"): TaskPhase.FEATURE_BUILDING,
(TaskPhase.FEATURE_PENDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
(TaskPhase.FEATURE_BUILDING, "feature_verified"): TaskPhase.SCHEDULING_FEATURE,
(TaskPhase.FEATURE_BUILDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
(TaskPhase.FEATURE_PENDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
(TaskPhase.FEATURE_BUILDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
(TaskPhase.DRAFTING_MODELING_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
# User clarifications are durable task evidence. Resume on the same task
@@ -89,6 +126,7 @@ _TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
(TaskPhase.CANDIDATE_REVIEW, "candidate_accepted"): TaskPhase.AWAITING_ACTION,
(TaskPhase.CANDIDATE_REVIEW, "candidate_rejected"): TaskPhase.AWAITING_ACTION,
(TaskPhase.AWAITING_ACTION, "final_requested"): TaskPhase.FINAL_VALIDATION,
(TaskPhase.FINAL_VALIDATION, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
(TaskPhase.FINAL_VALIDATION, "final_accepted"): TaskPhase.COMPLETED,
(TaskPhase.FINAL_VALIDATION, "final_repair"): TaskPhase.AWAITING_ACTION,
}
@@ -111,6 +149,11 @@ _RETRY_RESUMABLE_PHASES = frozenset({
TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
TaskPhase.DRAFTING_COMPLETION_TARGET,
TaskPhase.COMPILING_REQUIREMENTS,
TaskPhase.COMPILING_FEATURE_PLAN,
TaskPhase.SCHEDULING_FEATURE,
TaskPhase.FEATURE_PENDING,
TaskPhase.FEATURE_BUILDING,
TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
TaskPhase.DRAFTING_MODELING_PLAN,
TaskPhase.AWAITING_ACTION,
TaskPhase.ACTION_PENDING,
@@ -139,7 +182,7 @@ def retry_resume_event(state: TaskState) -> str | None:
return f"resume_{state.retry_from_phase.value.lower()}"
def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_spec_path: str | None = None, requirements_document_path: str | None = None, completion_target_path: str | None = None, modeling_plan_path: str | None = None, clarification_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState:
def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, feature_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_spec_path: str | None = None, requirements_document_path: str | None = None, completion_target_path: str | None = None, modeling_plan_path: str | None = None, feature_plan_path: str | None = None, feature_plan_hash: str | None = None, clarification_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState:
"""Apply one legal transition and advance optimistic-concurrency version."""
target = _TRANSITIONS.get((state.phase, event))
if target is None:
@@ -147,7 +190,7 @@ def transition(state: TaskState, event: str, *, pending_action: PendingAction |
if state.phase == TaskPhase.WAITING_RETRY and event.startswith("resume_") and state.retry_from_phase != target:
raise ValueError("WAITING_RETRY resume event does not match its persisted source phase")
next_pending = state.pending_action if pending_action is ... else pending_action
if target in {TaskPhase.AWAITING_ACTION, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
next_pending = None
return replace(
state,
@@ -155,8 +198,8 @@ def transition(state: TaskState, event: str, *, pending_action: PendingAction |
version=state.version + 1,
active_revision=state.active_revision if active_revision is None else active_revision,
pending_action=next_pending,
candidate_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_id if candidate_id is None else candidate_id,
candidate_stage_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_stage_id if candidate_stage_id is None else candidate_stage_id,
candidate_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_id if candidate_id is None else candidate_id,
candidate_stage_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_stage_id if candidate_stage_id is None else candidate_stage_id,
repair_required=state.repair_required if repair_required is None else repair_required,
last_error=error,
retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None,
@@ -164,6 +207,9 @@ def transition(state: TaskState, event: str, *, pending_action: PendingAction |
requirements_document_path=state.requirements_document_path if requirements_document_path is None else requirements_document_path,
completion_target_path=state.completion_target_path if completion_target_path is None else completion_target_path,
modeling_plan_path=state.modeling_plan_path if modeling_plan_path is None else modeling_plan_path,
feature_plan_path=state.feature_plan_path if feature_plan_path is None else feature_plan_path,
feature_plan_hash=state.feature_plan_hash if feature_plan_hash is None else feature_plan_hash,
feature_stage_id="" if target in {TaskPhase.SCHEDULING_FEATURE, TaskPhase.FEATURE_PENDING, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.feature_stage_id if feature_stage_id is None else feature_stage_id,
clarification_path=state.clarification_path if clarification_path is None else clarification_path,
requirements_contract_path=state.requirements_contract_path if requirements_contract_path is None else requirements_contract_path,
)
@@ -647,6 +647,60 @@ def _coaxial_through_bore_group(expected: dict[str, Any], facts: dict[str, Any])
return _pass({"diameter_mm": expected["diameter_mm"], "axes": deviations, "tolerance_mm": tolerance}) if all(item["axis_dot"] >= 1 - 1e-6 and item["axis_distance_mm"] <= tolerance for item in deviations) else _fail({"diameter_mm": expected["diameter_mm"], "axes": deviations, "tolerance_mm": tolerance})
def _concentric_bore_to_outer_cylinder(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
"""Verify one bore axis coincides with one external cylindrical shell.
This is distinct from ``coaxial_through_bore_group``, which compares two
or more inner bores. A centered flange bore has only one inner cylinder,
so treating its outer wall as a second bore makes the old verifier
permanently pending.
"""
if not _records(facts):
return _pending("topology is unavailable")
tolerance = float(expected["tolerance_mm"])
bore = _matching_cylinders({
"diameter_mm": expected["bore_diameter_mm"],
"tolerance_mm": tolerance,
}, facts)
outer_shells = _outer_cylinder_shells({
"diameter_mm": expected["outer_diameter_mm"],
"tolerance_mm": tolerance,
}, facts)
if not bore:
return _pending("the target bore has not been introduced at this checkpoint")
if not outer_shells:
return _pending("the target outer cylindrical surface has not been introduced at this checkpoint")
if len(bore) != 1 or len(outer_shells) != 1:
return _fail({
"expected_bore_count": 1,
"actual_bore_count": len(bore),
"expected_outer_count": 1,
"actual_outer_count": len(outer_shells),
"bore_diameter_mm": expected["bore_diameter_mm"],
"outer_diameter_mm": expected["outer_diameter_mm"],
})
bore_point = _cylinder_axis_point(bore[0])
bore_axis = _cylinder_axis_direction(bore[0])
outer_point = _cylinder_axis_point(outer_shells[0][0])
outer_axis = _cylinder_axis_direction(outer_shells[0][0])
if None in {bore_point, bore_axis, outer_point, outer_axis}:
return _pending("the bore or outer-cylinder axis is not measurable")
assert bore_point is not None and bore_axis is not None
assert outer_point is not None and outer_axis is not None
axis_dot = abs(sum(bore_axis[index] * outer_axis[index] for index in range(3)))
delta = tuple(bore_point[index] - outer_point[index] for index in range(3))
axial = sum(delta[index] * outer_axis[index] for index in range(3))
radial_distance = sqrt(sum((delta[index] - axial * outer_axis[index]) ** 2 for index in range(3)))
evidence = {
"bore_record_id": bore[0].get("record_id"),
"outer_record_ids": [record.get("record_id") for record in outer_shells[0]],
"axis_dot": axis_dot,
"axis_distance_mm": radial_distance,
"tolerance_mm": tolerance,
}
return _pass(evidence) if axis_dot >= 1 - 1e-6 and radial_distance <= tolerance else _fail(evidence)
def _orthogonal_intersecting_through_bores(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
first = _matching_cylinders({"diameter_mm": expected["first_diameter_mm"], "tolerance_mm": expected["tolerance_mm"]}, facts)
second = _matching_cylinders({"diameter_mm": expected["second_diameter_mm"], "tolerance_mm": expected["tolerance_mm"]}, facts)
@@ -889,6 +943,7 @@ def default_registry() -> VerifierRegistry:
ClaimDefinition("circular_hole_pattern", _closed_object({"count": {"type": "integer", "minimum": 2}, "diameter_mm": positive, "pitch_radius_mm": positive, "concentric_bore_diameter_mm": positive, "tolerance_mm": tolerance}, ["count", "diameter_mm", "pitch_radius_mm", "tolerance_mm"]), ("topology",), _hole_pattern),
ClaimDefinition("collinear_through_bore_chain", bore_chain, ("topology",), _collinear_bore_chain),
ClaimDefinition("coaxial_through_bore_group", _closed_object({"diameter_mm": positive, "count": {"type": "integer", "minimum": 2, "maximum": 16}, "tolerance_mm": alignment_tolerance}, ["diameter_mm", "count", "tolerance_mm"]), ("topology",), _coaxial_through_bore_group),
ClaimDefinition("concentric_bore_to_outer_cylinder", _closed_object({"bore_diameter_mm": positive, "outer_diameter_mm": positive, "tolerance_mm": alignment_tolerance}, ["bore_diameter_mm", "outer_diameter_mm", "tolerance_mm"]), ("topology",), _concentric_bore_to_outer_cylinder),
ClaimDefinition("orthogonal_intersecting_through_bores", _closed_object({"first_diameter_mm": positive, "second_diameter_mm": positive, "first_axis": {"enum": ["x", "y", "z"]}, "second_axis": {"enum": ["x", "y", "z"]}, "tolerance_mm": alignment_tolerance}, ["first_diameter_mm", "second_diameter_mm", "first_axis", "second_axis", "tolerance_mm"]), ("topology",), _orthogonal_intersecting_through_bores),
ClaimDefinition("rectangular_corner_through_bore_pattern", _closed_object({"diameter_mm": positive, "count": {"const": 4}, "edge_offset_mm": positive, "tolerance_mm": tolerance}, ["diameter_mm", "count", "edge_offset_mm", "tolerance_mm"]), ("rebuild_report", "topology"), _rectangular_corner_bore_pattern),
ClaimDefinition("coaxial", _closed_object({"record_ids": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 2, "maxItems": 2, "uniqueItems": True}, "tolerance": alignment_tolerance}, ["record_ids", "tolerance"]), ("topology",), _coaxial),
@@ -0,0 +1,40 @@
"""Create a resumable live-evaluation task without starting its workflow."""
from __future__ import annotations
import argparse
from dataclasses import replace
import json
from pathlib import Path
import secrets
import sys
from app.cad_agent.composition import compose_v3
from app.settings import get_settings
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Create one isolated live CAD task for resume_one_step.")
parser.add_argument("--report-root", required=True, type=Path)
parser.add_argument("--prompt", required=True)
parser.add_argument("--task-id")
return parser.parse_args()
def main() -> int:
arguments = _arguments()
root = arguments.report_root.resolve()
settings = get_settings()
services = compose_v3(replace(
settings,
task_root=root / "artifacts",
conversation_root=root / "conversations",
))
task_id = arguments.task_id or f"cad_{secrets.token_hex(6)}"
services.workflow.create_task(task_id, arguments.prompt)
print(json.dumps({"task_id": task_id, "report_root": str(root)}, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
+50 -10
View File
@@ -36,7 +36,7 @@ from app.cad_agent.evals.token_baseline import (
from app.settings import BACKEND_ROOT, get_settings
_CANDIDATE_EVIDENCE_FILES = frozenset({
_LEGACY_CANDIDATE_EVIDENCE_FILES = frozenset({
"candidate.json",
"candidate-review.json",
"model.cdsl.json",
@@ -48,6 +48,19 @@ _CANDIDATE_EVIDENCE_FILES = frozenset({
"renders/contact-sheet.jpg",
})
# A v3.2 Feature DAG publishes one atomic checkpoint after local verification.
# It deliberately has no candidate review or technical render bundle; the
# final review owns that later evidence. GLB is optional because a preview
# conversion outage must not invalidate an otherwise sound STEP checkpoint.
_FEATURE_NODE_EVIDENCE_FILES = frozenset({
"input.json",
"model.cdsl.json",
"model.step",
"model.topology.json",
"node-verification.json",
"rebuild-report.json",
})
_FAILURE_LAYERS = frozenset({
"model_format_or_decision",
"v3_contract_or_verifier",
@@ -240,13 +253,14 @@ def _artifact_evidence_complete(
revisions: list[str],
active_revision: str,
artifact_manifest: dict[str, Any] | None,
ledger: list[dict[str, Any]],
) -> bool:
"""Require every published CAD decision to retain its reviewable evidence.
A non-empty report manifest is not enough: a task could otherwise report
only a rendered contract view while silently losing the STEP, topology, or
candidate review used to accept a revision. Candidate manifests protect
the immutable build inputs/outputs; the report manifest additionally
node verification used to accept a revision. Checkpoint manifests protect
immutable build inputs and outputs; the report manifest additionally
protects the frozen contract and final independent review written later.
"""
if not revisions or not active_revision:
@@ -264,9 +278,19 @@ def _artifact_evidence_complete(
return False
if not all((artifact_root / path).is_file() for path in required_report_files):
return False
feature_revisions = {
str(item.get("revision_id") or "")
for item in ledger
if isinstance(item, dict) and item.get("event") == "feature_node_verified"
}
for revision_id in sorted(set(revisions)):
revision_root = artifact_root / "revisions" / revision_id
required_paths = {revision_root / relative for relative in _CANDIDATE_EVIDENCE_FILES}
evidence_files = (
_FEATURE_NODE_EVIDENCE_FILES
if revision_id in feature_revisions
else _LEGACY_CANDIDATE_EVIDENCE_FILES
)
required_paths = {revision_root / relative for relative in evidence_files}
manifest_path = revision_root / "manifest.json"
if not manifest_path.is_file() or not all(path.is_file() for path in required_paths):
return False
@@ -275,7 +299,7 @@ def _artifact_evidence_complete(
except (OSError, json.JSONDecodeError):
return False
declared = manifest.get("files") if isinstance(manifest, dict) else None
if not isinstance(declared, dict) or not _CANDIDATE_EVIDENCE_FILES.issubset(declared):
if not isinstance(declared, dict) or not evidence_files.issubset(declared):
return False
for relative, digest in declared.items():
path = revision_root / str(relative)
@@ -284,7 +308,7 @@ def _artifact_evidence_complete(
if sha256(path.read_bytes()).hexdigest() != digest:
return False
if artifact_manifest is not None:
expected_report_paths = {f"revisions/{revision_id}/{relative}" for relative in _CANDIDATE_EVIDENCE_FILES}
expected_report_paths = {f"revisions/{revision_id}/{relative}" for relative in evidence_files}
if not expected_report_paths.issubset(report_files):
return False
return True
@@ -356,9 +380,24 @@ def _run_checks(
reviewer_calls = [item for item in records if isinstance(item, dict) and item.get("role") == "reviewer"]
total_tokens = int(usage.get("prompt_tokens") or 0) + int(usage.get("completion_tokens") or 0)
audit_ledger = ledger if ledger is not None else [item for item in projection.get("action_ledger_summary") or () if isinstance(item, dict)]
accepted = [item for item in audit_ledger if isinstance(item, dict) and item.get("event") == "accepted"]
revisions = [str(item.get("revision_id") or "") for item in accepted]
operation_ids = {str(item.get("actual_atomic_id") or "") for item in accepted}
published = [
item for item in audit_ledger
if isinstance(item, dict) and item.get("event") in {"accepted", "feature_node_verified"}
]
revisions = [str(item.get("revision_id") or "") for item in published]
scheduled_atomic_ids = {
str(item.get("node_id") or ""): str(item.get("atomic_id") or "")
for item in audit_ledger
if isinstance(item, dict) and item.get("event") == "feature_node_scheduled"
}
operation_ids = {
str(
item.get("actual_atomic_id")
or item.get("atomic_id")
or scheduled_atomic_ids.get(str(item.get("node_id") or ""), "")
)
for item in published
}
required_operation_groups = [
{str(atomic_id) for atomic_id in group if isinstance(atomic_id, str) and atomic_id}
for group in scenario.get("required_any_atomic_id_groups") or ()
@@ -386,6 +425,7 @@ def _run_checks(
revisions,
active_revision,
artifact_manifest,
audit_ledger,
)
return {
"expected_terminal_phase": projection.get("phase") == scenario.get("expected_phase", "COMPLETED"),
@@ -712,7 +752,7 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An
"reviewer": str(reviewer_capability.get("mode") or ""),
},
"git_revision": _git_revision(),
"protocol_version": "3.0",
"protocol_version": "3.2",
"runtime_profile_sha256": runtime_profile_hash,
"operation_contracts": contracts,
"verifier_registry_version": "cad.verifier-registry.v1",
@@ -0,0 +1,90 @@
"""Advance one persisted event for a task in an isolated live-evaluation root.
Long real-provider evaluations can outlive a command host's execution window.
This utility takes exactly one event from ``WorkflowCoordinator.run`` and
closes the async generator after that event has been durably handled. Repeated
invocations therefore resume the same task without re-running already
persisted nodes.
"""
from __future__ import annotations
import argparse
import asyncio
from dataclasses import replace
import json
from pathlib import Path
import sys
from typing import Any
from app.cad_agent.application.workflow import ModelIdentity
from app.cad_agent.composition import compose_v3
from app.settings import get_settings
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Advance one event for an isolated live CAD evaluation task.")
parser.add_argument("--report-root", required=True, type=Path)
parser.add_argument("--task-id", required=True)
parser.add_argument("--author-provider")
parser.add_argument("--author-model")
parser.add_argument("--review-provider")
parser.add_argument("--review-model")
return parser.parse_args()
async def _advance(arguments: argparse.Namespace) -> dict[str, Any]:
settings = get_settings()
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
if arguments.review_provider or arguments.review_model:
review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model)
else:
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
report_root = arguments.report_root.resolve()
services = compose_v3(replace(
settings,
task_root=report_root / "artifacts",
conversation_root=report_root / "conversations",
))
before = services.repository.get_state(arguments.task_id)
if before is None:
raise ValueError(f"Unknown task {arguments.task_id!r} in {report_root}")
runner = services.workflow.run(
task_id=arguments.task_id,
author=ModelIdentity(author_provider.id, author_model.id),
reviewer=ModelIdentity(review_provider.id, review_model.id),
)
try:
name, payload = await anext(runner)
except StopAsyncIteration:
name, payload = "workflow_exhausted", {}
finally:
await runner.aclose()
after = services.repository.get_state(arguments.task_id)
return {
"task_id": arguments.task_id,
"event": {"name": name, "payload": payload},
"before": {"phase": before.phase.value, "version": before.version},
"after": {
"phase": after.phase.value if after is not None else "",
"version": after.version if after is not None else -1,
"active_revision": after.active_revision if after is not None else "",
"last_error": after.last_error.value if after is not None and after.last_error else "",
},
}
def main() -> int:
arguments = _arguments()
try:
result = asyncio.run(_advance(arguments))
except BaseException as error:
result = {"error": f"{type(error).__name__}: {str(error)[:1000]}"}
print(json.dumps(result, ensure_ascii=False))
return 2
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
+4 -1
View File
@@ -86,7 +86,10 @@ class CadRuntime(Protocol):
def operation_contract(self, atomic_id: str) -> dict[str, Any]: ...
def selector_tokens(self, topology: dict[str, Any] | None) -> dict[str, dict[str, Any]]: ...
def reference_tokens(self, cdsl: dict[str, Any] | None) -> dict[str, str]: ...
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False) -> tuple[dict[str, Any], dict[str, Any]]: ...
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False, depends_on_feature_ids: tuple[str, ...] | list[str] = ()) -> tuple[dict[str, Any], dict[str, Any]]: ...
def build_checkpoint(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
def create_preview(self, output_dir: str) -> dict[str, Any]: ...
def render_review_bundle(self, output_dir: str) -> dict[str, Any]: ...
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
def rebuild_best_effort(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> tuple[dict[str, Any], list[dict[str, Any]]]: ...
+39 -6
View File
@@ -7,6 +7,7 @@ from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from app.models.contracts import ChatRequest, ConversationPatch
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler
from app.services.agent_service import AgentService
from app.services.library import CdslLibrary
from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id
@@ -158,12 +159,27 @@ async def read_task(task_id: str) -> JSONResponse:
requirements_path = agent.v3.artifacts.artifact_path(safe_id, state.requirements_document_path) if state is not None and state.requirements_document_path else None
task["requirements_markdown"] = requirements_path.read_text(encoding="utf-8") if requirements_path and requirements_path.is_file() else None
target_path = agent.v3.artifacts.artifact_path(safe_id, state.completion_target_path) if state is not None and state.completion_target_path else None
plan_path = agent.v3.artifacts.artifact_path(safe_id, state.modeling_plan_path) if state is not None and state.modeling_plan_path else None
plan_path = agent.v3.artifacts.artifact_path(safe_id, state.feature_plan_path) if state is not None and state.feature_plan_path else None
result_path = agent.v3.artifacts.task_dir(safe_id) / "completion-result.md"
task["completion_target_markdown"] = target_path.read_text(encoding="utf-8") if target_path and target_path.is_file() else None
task["completion_target_path"] = state.completion_target_path if target_path and target_path.is_file() else ""
task["modeling_plan_markdown"] = plan_path.read_text(encoding="utf-8") if plan_path and plan_path.is_file() else None
task["modeling_plan_path"] = state.modeling_plan_path if plan_path and plan_path.is_file() else ""
task["feature_plan"] = agent.v3.artifacts.read_json(safe_id, state.feature_plan_path) if state is not None and state.feature_plan_path else None
task["feature_plan_path"] = state.feature_plan_path if plan_path and plan_path.is_file() else ""
task["feature_plan_hash"] = state.feature_plan_hash if state is not None else ""
if isinstance(task["feature_plan"], dict):
plan = FeaturePlan.model_validate(task["feature_plan"])
statuses = FeatureScheduler(plan, agent.v3.repository.ledger_events(safe_id)).statuses()
node_evidence = {str(item.get("node_id") or ""): item for item in task.get("feature_nodes") or () if isinstance(item, dict)}
task["feature_nodes"] = [
{
"node_id": str(node.get("node_id") or ""), "intent": str(node.get("intent") or ""),
"atomic_id": str(node.get("atomic_id") or ""), "priority": node.get("priority"),
"depends_on": node.get("depends_on") or [], "claim_ids": node.get("claim_ids") or [],
"status": statuses.get(str(node.get("node_id") or ""), "pending"),
**node_evidence.get(str(node.get("node_id") or ""), {}),
}
for node in task["feature_plan"].get("nodes") or () if isinstance(node, dict)
]
task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path.is_file() else None
task["completion_result_path"] = "completion-result.md" if result_path.is_file() else ""
task["usage"] = agent.v3.repository.usage_summary(safe_id)
@@ -179,7 +195,7 @@ def _claim_summary(contract: dict[str, Any] | None, ledger: Any) -> list[dict[st
"""
latest: dict[str, dict[str, Any]] = {}
for event in reversed(ledger if isinstance(ledger, list) else []):
if not isinstance(event, dict) or event.get("event") not in {"accepted", "completed"}:
if not isinstance(event, dict) or event.get("event") not in {"accepted", "completed", "feature_node_verified", "final_visual_reviewed"}:
continue
values = event.get("claim_results")
if not isinstance(values, list):
@@ -291,8 +307,25 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
raise HTTPException(status_code=403, detail="Only final delivery artifacts are downloadable")
return FileResponse(path, filename=path.name)
# A running task may render its current checkpoint in the browser, but
# cannot expose any other checkpoint artifact or a failed-task preview.
v32_checkpoint_paths = {
f"revisions/{revision_id}/model.cdsl.json",
f"revisions/{revision_id}/model.step",
f"revisions/{revision_id}/model.glb",
f"revisions/{revision_id}/rebuild-report.json",
}
verified_revisions = {
str(item.get("revision_id") or "")
for item in task.get("revisions") or ()
if isinstance(item, dict) and item.get("status") == "success"
}
# Each v3.2 node revision is immutable and manifest-published. Make those
# checkpoints inspectable from the DAG while keeping every staging input,
# rejected candidate and arbitrary task artifact private.
if task.get("schema_version") == "3.2" and revision_id in verified_revisions and artifact_path in v32_checkpoint_paths:
return FileResponse(path, media_type="model/gltf-binary" if path.suffix == ".glb" else None, headers={"Content-Disposition": "inline" if path.suffix == ".glb" else f"attachment; filename={path.name}"})
# A legacy running task may render its active checkpoint in the browser,
# but cannot expose any other checkpoint artifact or failed-task preview.
if (
str(task.get("lifecycle") or "") != "running"
or revision_id != active_revision
+13
View File
@@ -96,6 +96,19 @@ class WorkspaceStore:
def read_conversation(self, conversation_id: str) -> dict[str, Any] | None:
return _read_json(self.conversation_path(conversation_id))
def clear_current_task_references(self) -> int:
"""Detach conversations from task artifacts removed by a protocol reset."""
cleared = 0
for path in self.settings.conversation_root.glob("conv_*/conversation.json"):
record = _read_json(path)
if not isinstance(record, dict) or not record.get("current_task_id"):
continue
record["current_task_id"] = ""
record["updated_at"] = now_iso()
_write_json(path, record)
cleared += 1
return cleared
def append_conversation_message(self, conversation_id: str, message: dict[str, Any], current_task_id: str | None = None) -> dict[str, Any]:
record = self.ensure_conversation(conversation_id, current_task_id)
known = {str(item.get("id") or "") for item in record["messages"] if isinstance(item, dict)}
+360 -8
View File
@@ -18,12 +18,13 @@ sys.path.insert(0, str(ROOT / "backend"))
from app.cad_agent.adapters.artifact_store import FileArtifactStore
from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher
from app.cad_agent.adapters.review_gateway import RenderedReviewGateway
from app.cad_agent.adapters.runtime import ProfileCadRuntime
from app.cad_agent.adapters.runtime import ProfileCadRuntime, RuntimeAdapterError
from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository
from app.cad_agent.adapters.verifier import RegistryVerifierExecutor
from app.cad_agent.application.capabilities import cached_model_capability, conformance_hash, conformance_tools, verify_model_capability
from app.cad_agent.application.action_handlers import ActionCommandHandler
from app.cad_agent.application.llm_contracts import (
AcceptanceClaimInput,
CandidateReview,
CompiledRequirementsSpec,
MarkdownDocument,
@@ -42,9 +43,9 @@ from app.cad_agent.application.outbox import OutboxDispatcher
from app.cad_agent.application.requirements import RequirementsCommandHandler
from app.cad_agent.application.results import Accepted, Rejected, Waiting
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
from app.cad_agent.domain.errors import ErrorCode
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
from app.cad_agent.domain.operation_contract import fragment_schema, validate_fragment
from app.cad_agent.domain.state import TaskPhase, TaskState, legal_transitions, retry_resume_event, transition
from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState, legal_transitions, retry_resume_event, transition
from app.cad_agent.domain.verifier_registry import default_registry
from app.models.contracts import ChatMessage
from app.services.agent_service import AgentService
@@ -107,7 +108,10 @@ def completion_target() -> MarkdownDocument:
def compiled_flange() -> CompiledRequirementsSpec:
return CompiledRequirementsSpec.model_validate({"requirements": [
{"assumptions": [], "acceptance_claims": [{"claim_kind": "single_connected_body", "expected": {}}, {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 100, "tolerance_mm": 0.1}}, {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10, "tolerance_mm": 0.1}}]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]},
{"assumptions": [], "acceptance_claims": [
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}},
{"claim_kind": "concentric_bore_to_outer_cylinder", "expected": {"bore_diameter_mm": 30, "outer_diameter_mm": 100, "tolerance_mm": 0.01}},
]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "circular_hole_pattern", "expected": {"count": 4, "diameter_mm": 10, "pitch_radius_mm": 35, "tolerance_mm": 0.1}}]},
]})
@@ -196,6 +200,194 @@ class CadV3ProtocolTests(unittest.TestCase):
selector = schema["properties"]["feature"]["properties"]["selector_tokens"]["items"]
self.assertEqual(selector, {"enum": ["selector_one"]})
def test_counterbore_can_reuse_a_matching_pilot_inside_an_annular_host_face(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
runtime = ProfileCadRuntime(settings(Path(temporary)))
host = {
"kind": "face",
"geometry": {
"surface_type": "plane", "center_mm": [0, 0, 34], "normal": [0, 0, 1],
"bbox_mm": [-48, -48, 34, 48, 48, 34],
"boundary_loops_mm": [
[[-48, -48, 34], [48, -48, 34], [48, 48, 34], [-48, 48, 34]],
[[-16, -16, 34], [16, -16, 34], [16, 16, 34], [-16, 16, 34]],
],
},
}
pilot = {
"kind": "face",
"geometry": {
"surface_type": "cylinder", "cylinder_role": "inner", "through": True,
"radius_mm": 16, "axis_origin_mm": [0, 0, 0], "axis_direction": [0, 0, 1],
"bbox_mm": [-16, -16, 0, 16, 16, 34],
},
}
fragment = {
"feature": {
"atomic_id": "hole_counterbore", "selector_tokens": ["host"],
"params": {"diameter_mm": 32, "depth_mm": 34, "counterbore_diameter_mm": 62, "counterbore_depth_mm": 12, "positions": [{"mm": [0, 0, 34]}]},
},
}
runtime._preflight_hole_positions_on_host_plane(fragment, {"host": host, "pilot": pilot}, None, False)
fragment["feature"]["params"]["diameter_mm"] = 30
with self.assertRaises(RuntimeAdapterError):
runtime._preflight_hole_positions_on_host_plane(fragment, {"host": host, "pilot": pilot}, None, False)
def test_counterbore_operation_verifier_measures_the_new_recess_not_the_existing_pilot(self) -> None:
class CounterboreVerifier:
def __init__(self) -> None:
self.operation_claims: list[dict[str, object]] = []
def evaluate(self, claims: list[dict[str, object]], _facts: dict[str, object]) -> list[dict[str, object]]:
if claims[0]["claim_id"] == "operation_parent_bore_count":
self.assertEqual(claims[0]["expected"]["diameter_mm"], 62.0)
return [{"evidence": {"actual_count": 0}}]
self.operation_claims = claims
return [{"claim_id": claim["claim_id"], "claim_kind": claim["claim_kind"], "deterministic": True, "status": "pass", "evidence": {}} for claim in claims]
def assertEqual(self, actual: object, expected: object) -> None:
if actual != expected:
raise AssertionError(f"{actual!r} != {expected!r}")
verifier = CounterboreVerifier()
handler = ActionCommandHandler(None, None, None, verifier)
action = PendingAction(
action_id="counterbore", working_head="cad_test:rev_001:v1", intent="Counterbore.", requirement_ids=(),
atomic_id="hole_counterbore", expected_change="Cut a counterbore.", contract_hash="contract", idempotency_key="key",
)
results = handler._operation_candidate_results(
action,
{"candidate_verifiers": ["cylindrical_bore"]},
{"features": [{"atomic_id": "hole_counterbore", "params": {"diameter_mm": 32, "counterbore_diameter_mm": 62, "positions": [{"mm": [0, 0, 34]}]}}]},
{"health": {}, "topology": {}, "report": {}},
parent_facts={"health": {}, "topology": {}, "report": {}},
require_through=False,
)
self.assertEqual(verifier.operation_claims[0]["expected"], {"diameter_mm": 62.0, "count": 1, "tolerance_mm": 0.01})
self.assertEqual(results[0]["status"], "pass")
def test_extrude_cut_rejects_a_slot_profile_floating_above_the_base(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
runtime = ProfileCadRuntime(settings(Path(temporary)))
base_face = {
"kind": "face",
"geometry": {
"surface_type": "plane", "center_mm": [0, 0, 16], "normal": [0, 0, 1],
"boundary_loops_mm": [[[-90, -50, 16], [90, -50, 16], [90, 50, 16], [-90, 50, 16]]],
},
}
boss_face = {
"kind": "face",
"geometry": {
"surface_type": "plane", "center_mm": [0, 0, 34], "normal": [0, 0, 1],
"boundary_loops_mm": [[[48, 0, 34], [0, 48, 34], [-48, 0, 34], [0, -48, 34]]],
},
}
fragment = {
"sketch": {
"workplane": {"origin_mm": [0, 0, 34], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
"profile": {"type": "analytic_contours", "contours": [{"closed": True, "role": "outer", "segments": [
{"type": "line", "start": [68, 40], "end": [85, 40]},
{"type": "line", "start": [85, 40], "end": [85, 49]},
{"type": "line", "start": [85, 49], "end": [68, 49]},
{"type": "line", "start": [68, 49], "end": [68, 40]},
]}]},
},
"feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 2}},
}
with self.assertRaisesRegex(RuntimeAdapterError, "profile does not contact material"):
runtime._preflight_extrude_cut_contacts_material(fragment, {"base": base_face, "boss": boss_face})
fragment["sketch"]["workplane"]["origin_mm"][2] = 16
runtime._preflight_extrude_cut_contacts_material(fragment, {"base": base_face, "boss": boss_face})
def test_surface_attached_cut_direction_is_normalized_into_material(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
runtime = ProfileCadRuntime(settings(Path(temporary)))
base = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "cut_direction",
"geometry": {"sketches": [{
"id": "sketch_001",
"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
}]},
"features": [{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"}],
}
fragment = {
"sketch": {
"workplane": {"origin_mm": [0, 0, 5], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
"profile": {"type": "circle", "center": [6, 0], "radius_mm": 1},
},
"feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 2}},
}
top_face = {
"kind": "face",
"geometry": {
"surface_type": "plane", "center_mm": [0, 0, 5], "normal": [0, 0, 1],
"boundary_loops_mm": [[[-10, -10, 5], [10, -10, 5], [10, 10, 5], [-10, 10, 5]]],
},
}
document, audit = runtime.materialize_fragment(
base,
fragment,
runtime.operation_contract("extrude_cut_blind"),
{"top": top_face},
runtime.reference_tokens(base),
)
self.assertTrue(document["features"][-1]["params"]["reverse"])
direction = next(item for item in audit["server_normalizations"] if item["path"] == "feature.params.reverse")
self.assertFalse(direction["submitted"])
self.assertTrue(direction["materialized"])
def test_sketch_workplane_candidates_prefer_the_broad_base_support(self) -> None:
candidates = WorkflowCoordinator._sketch_workplane_candidates({"records": [
{
"kind": "face",
"geometry": {
"surface_type": "plane", "center_mm": [0, 0, 34], "normal": [0, 0, 1],
"bbox_mm": [-48, -48, 34, 48, 48, 34],
},
},
{
"kind": "face",
"geometry": {
"surface_type": "plane", "center_mm": [0, 0, 16], "normal": [0, 0, 1],
"bbox_mm": [-90, -50, 16, 90, 50, 16],
},
},
{
"kind": "face",
"geometry": {
"surface_type": "plane", "center_mm": [90, 0, 8], "normal": [1, 0, 0],
"bbox_mm": [90, -50, 0, 90, 50, 16],
},
},
]})
self.assertEqual(candidates[0]["point_mm"], [0.0, 0.0, 16.0])
self.assertEqual(candidates[0]["footprint_bbox_area_mm2"], 18000.0)
self.assertEqual(candidates[1]["point_mm"], [0.0, 0.0, 34.0])
def test_replan_budget_spans_replacement_node_ids_at_one_checkpoint(self) -> None:
events = [
{
"event": "feature_node_failed", "node_id": node_id,
"atomic_id": "extrude_cut_blind", "checkpoint_revision": "rev_005", "terminal": True,
}
for node_id in ("corner_slots_v1", "corner_slots_v2", "corner_slots_v3")
]
class Repository:
@staticmethod
def ledger_events(_task_id: str) -> list[dict[str, object]]:
return events
workflow = object.__new__(WorkflowCoordinator)
workflow.repository = Repository()
state = TaskState("cad_123456abcdef", TaskPhase.REPLANNING_FEATURE_SUBGRAPH, 10, active_revision="rev_005")
exhausted = workflow._feature_replan_exhausted(state.task_id, state)
self.assertIsNotNone(exhausted)
self.assertEqual(exhausted["terminal_failure_count"], 3)
self.assertEqual(exhausted["node_ids"], ["corner_slots_v1", "corner_slots_v2", "corner_slots_v3"])
def test_root_extrusion_schema_fixes_world_xy_datum_without_deciding_z(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
runtime = ProfileCadRuntime(settings(Path(temporary)))
@@ -445,7 +637,10 @@ class CadV3ProtocolTests(unittest.TestCase):
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
compiled = CompiledRequirementsSpec.model_validate({"requirements": [
{"assumptions": [], "acceptance_claims": [{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 100, "axial_span_mm": 10, "count": 1}}]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]},
{"assumptions": [], "acceptance_claims": [
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}},
{"claim_kind": "concentric_bore_to_outer_cylinder", "expected": {"bore_diameter_mm": 30, "outer_diameter_mm": 100, "tolerance_mm": 0.01}},
]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 10, "count": 4, "pitch_radius_mm": 35, "tolerance_mm": 0.1}}]},
]})
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted)
@@ -467,18 +662,125 @@ class CadV3ProtocolTests(unittest.TestCase):
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
compiled = CompiledRequirementsSpec.model_validate({"requirements": [
{"assumptions": [], "acceptance_claims": [{"claim_kind": "coaxial", "expected": {"record_ids": ["outer", "bore"], "tolerance": 0.01}}]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]},
{"assumptions": [], "acceptance_claims": [
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}},
{"claim_kind": "concentric_bore_to_outer_cylinder", "expected": {"bore_diameter_mm": 30, "outer_diameter_mm": 100, "tolerance_mm": 0.01}},
]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "coplanar", "expected": {"record_ids": ["top_face", "bottom_face"], "tolerance_mm": 0.1}}]},
]})
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted)
state = repository.get_state(task_id)
contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
claims = [claim for requirement in contract["requirements"] for claim in requirement["acceptance_claims"]]
self.assertEqual([claim["claim_kind"] for claim in claims], ["visual", "through_cylindrical_bore", "visual"])
self.assertEqual([claim["verification_mode"] for claim in claims], ["visual", "deterministic", "visual"])
self.assertEqual([claim["claim_kind"] for claim in claims], ["visual", "through_cylindrical_bore", "concentric_bore_to_outer_cylinder", "visual"])
self.assertEqual([claim["verification_mode"] for claim in claims], ["visual", "deterministic", "deterministic", "visual"])
self.assertTrue(any("coaxial verifier" in warning for warning in contract["verification_warnings"]))
self.assertTrue(any("coplanar verifier" in warning for warning in contract["verification_warnings"]))
def test_unbacked_coaxial_bore_group_is_not_frozen_as_a_deterministic_claim(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
handler = RequirementsCommandHandler(repository, artifacts, default_registry())
output = CompiledRequirementsSpec.model_validate({"requirements": [
{"assumptions": [], "acceptance_claims": [
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}},
]},
{"assumptions": [], "acceptance_claims": [
{"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 40, "count": 2, "tolerance_mm": 0.01}},
]},
]})
normalized, warnings = handler._normalize_compiled_spec(output, ["A through bore.", "The bore is concentric with the outer profile."])
self.assertEqual(normalized.requirements[1].acceptance_claims[0].claim_kind, "visual")
self.assertTrue(any("no matching multi-bore target" in warning for warning in warnings))
def test_obround_slot_is_not_compiled_as_a_corner_bore_pattern(self) -> None:
output = CompiledRequirementsSpec.model_validate({"requirements": [{
"assumptions": [],
"acceptance_claims": [{
"claim_kind": "rectangular_corner_through_bore_pattern",
"expected": {"diameter_mm": 9, "count": 4, "edge_offset_mm": 26, "tolerance_mm": 0.1},
}],
}]})
normalized, warnings = RequirementsCommandHandler(None, None, default_registry())._normalize_compiled_spec(
output,
["Four 26 x 9 mm oblong adjustment slots are present at the four corners."],
)
claim = normalized.requirements[0].acceptance_claims[0]
self.assertEqual(claim.claim_kind, "visual")
self.assertEqual(claim.expected["description"], "Four 26 x 9 mm oblong adjustment slots are present at the four corners.")
self.assertTrue(any("describes an obround slot" in warning for warning in warnings))
def test_centered_bore_checklist_item_requires_concentric_claim_coverage(self) -> None:
output = CompiledRequirementsSpec.model_validate({"requirements": [{
"assumptions": [],
"acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}}],
}]})
errors = RequirementsCommandHandler._relationship_claim_errors(
output,
["A centered 40 mm through bore is present."],
)
self.assertEqual(errors[0]["path"], "/requirements/0/acceptance_claims")
output.requirements[0].acceptance_claims.append(AcceptanceClaimInput.model_validate({
"claim_kind": "concentric_bore_to_outer_cylinder",
"expected": {"bore_diameter_mm": 40, "outer_diameter_mm": 120, "tolerance_mm": 0.01},
}))
self.assertEqual(
RequirementsCommandHandler._relationship_claim_errors(output, ["A centered 40 mm through bore is present."]),
[],
)
def test_compiler_derives_concentric_claim_from_frozen_outer_cylinder_and_centered_bore(self) -> None:
output = CompiledRequirementsSpec.model_validate({"requirements": [
{"assumptions": [], "acceptance_claims": [{
"claim_kind": "outer_cylindrical_surface",
"expected": {"diameter_mm": 120, "axial_span_mm": 12, "count": 1, "tolerance_mm": 0.01},
}]},
{"assumptions": [], "acceptance_claims": [{
"claim_kind": "through_cylindrical_bore",
"expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01},
}]},
]})
normalized, _ = RequirementsCommandHandler(None, None, default_registry())._normalize_compiled_spec(
output,
["A 120 mm cylindrical outer flange is present.", "A centered 40 mm through bore is present."],
)
derived = normalized.requirements[1].acceptance_claims[-1]
self.assertEqual(derived.claim_kind, "concentric_bore_to_outer_cylinder")
self.assertEqual(derived.expected, {"bore_diameter_mm": 40.0, "outer_diameter_mm": 120.0, "tolerance_mm": 0.01})
self.assertEqual(
RequirementsCommandHandler._relationship_claim_errors(
normalized,
["A 120 mm cylindrical outer flange is present.", "A centered 40 mm through bore is present."],
),
[],
)
def test_concentric_bore_to_outer_cylinder_verifier_measures_axis_offset(self) -> None:
outer = {
"record_id": "outer", "geometry": {
"surface_type": "cylinder", "cylinder_role": "outer", "radius_mm": 60.0,
"axis_origin_mm": [0.0, 0.0, 0.0], "axis_direction": [0.0, 0.0, 1.0],
"bbox_mm": [-60.0, -60.0, 0.0, 60.0, 60.0, 12.0],
},
}
bore = {
"record_id": "bore", "geometry": {
"surface_type": "cylinder", "cylinder_role": "inner", "radius_mm": 20.0,
"axis_origin_mm": [0.0, 0.0, 0.0], "axis_direction": [0.0, 0.0, 1.0],
"bbox_mm": [-20.0, -20.0, 0.0, 20.0, 20.0, 12.0], "through": True,
},
}
expected = {"bore_diameter_mm": 40.0, "outer_diameter_mm": 120.0, "tolerance_mm": 0.01}
registry = default_registry()
result = registry.evaluate("concentric_bore_to_outer_cylinder", expected, {"topology": {"records": [outer, bore]}})
self.assertEqual(result["status"], "pass")
bore["geometry"]["axis_origin_mm"] = [0.1, 0.0, 0.0]
result = registry.evaluate("concentric_bore_to_outer_cylinder", expected, {"topology": {"records": [outer, bore]}})
self.assertEqual(result["status"], "fail")
self.assertAlmostEqual(result["evidence"]["axis_distance_mm"], 0.1)
def test_local_cylindrical_span_does_not_become_global_bbox_requirement(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@@ -572,6 +874,56 @@ class CadV3ProtocolTests(unittest.TestCase):
self.assertEqual(result.error.code, ErrorCode.REQUIREMENTS_SPEC_INVALID)
self.assertEqual(repository.get_state(task_id), before)
def test_feature_plan_rejection_has_an_independent_retry_budget(self) -> None:
"""A failed replan must not consume a prior requirements-format retry."""
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
runtime = ProfileCadRuntime(settings(root))
requirements = RequirementsCommandHandler(repository, artifacts, default_registry())
actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry()))
workflow = WorkflowCoordinator(
WorkflowConfig(max_turns=8, format_error_limit=2),
repository,
artifacts,
runtime,
object(), # The direct retry-budget test does not call a model.
object(),
requirements,
actions,
)
initial = repository.create_task("cad_123456abcdef", "Create a plate.")
documented = transition(initial, "requirements_document_written", requirements_document_path="requirements.md")
targeted = transition(documented, "completion_target_written", completion_target_path="completion-target.md")
compiling = transition(targeted, "requirements_compiled", requirements_contract_path="requirements-contract.json")
scheduled = transition(
compiling,
"feature_plan_written",
feature_plan_path="plans/feature-plan-active.json",
feature_plan_hash="a" * 64,
)
replanning = transition(scheduled, "feature_replan", error=ErrorCode.CANDIDATE_REVIEW_REJECTED)
self.assertTrue(repository.compare_and_swap(documented))
self.assertTrue(repository.compare_and_swap(targeted))
self.assertTrue(repository.compare_and_swap(compiling))
self.assertTrue(repository.compare_and_swap(scheduled))
self.assertTrue(repository.compare_and_swap(replanning))
counters = {"requirements_spec": 1}
feedback: list[dict[str, object]] = []
terminal = workflow._requirements_rejection(
replanning.task_id,
replanning,
WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan violates immutable-node rules."),
counters,
feedback,
tool="write_feature_plan",
)
self.assertIsNone(terminal)
self.assertEqual(repository.get_state(replanning.task_id).phase, TaskPhase.REPLANNING_FEATURE_SUBGRAPH)
self.assertEqual(counters, {"requirements_spec": 1, "write_feature_plan": 1})
self.assertEqual(len(feedback), 1)
def test_completion_result_reports_frozen_checklist(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
+357
View File
@@ -0,0 +1,357 @@
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
from app.cad_agent.adapters.artifact_store import FileArtifactStore
from app.cad_agent.adapters.runtime import ProfileCadRuntime
from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository
from app.cad_agent.adapters.verifier import RegistryVerifierExecutor
from app.cad_agent.application.action_handlers import ActionCommandHandler
from app.cad_agent.application.llm_contracts import CompiledRequirementsSpec, MarkdownDocument
from app.cad_agent.application.requirements import RequirementsCommandHandler
from app.cad_agent.application.results import Accepted, Rejected
from app.cad_agent.domain.state import TaskPhase, transition
from app.cad_agent.domain.verifier_registry import default_registry
from app.settings import ProviderConfig, ProviderModel, Settings
def runtime_settings(root: Path) -> Settings:
provider = ProviderConfig("test", "Test", "https://test.invalid/v1", "key", (ProviderModel("test-model"),))
return Settings(
task_root=root / "tasks", conversation_root=root / "conversations",
library_root=ROOT / "backend" / "cdsl_library", engine_root=ROOT / "backend" / "engine" / "cdsl_engine",
llm_base_url=provider.base_url, llm_api_key=provider.api_key, llm_model="test-model", llm_timeout_s=1,
default_provider_id="test", providers=(provider,),
)
def contract() -> dict[str, object]:
return {
"requirements": [
{
"requirement_id": "req_001",
"acceptance_claims": [
{"claim_id": "claim_base", "verification_mode": "deterministic"},
{"claim_id": "claim_visual", "verification_mode": "visual"},
],
},
{
"requirement_id": "req_002",
"acceptance_claims": [{"claim_id": "claim_bore", "verification_mode": "deterministic"}],
},
{
"requirement_id": "req_003",
"acceptance_claims": [{"claim_id": "claim_pattern", "verification_mode": "deterministic"}],
},
],
}
def initial_plan() -> FeaturePlan:
return FeaturePlan.model_validate({
"schema_version": "cad.v3.2.feature-plan.v1",
"parent_plan_hash": "",
"replaces_node_ids": [],
"nodes": [
{"node_id": "base", "priority": 10, "intent": "Create the base.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_base"], "expected_change": "One base solid."},
{"node_id": "bore", "priority": 20, "intent": "Cut the bore.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_bore"], "expected_change": "One through bore."},
{"node_id": "pattern", "priority": 30, "intent": "Add the pattern.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_pattern"], "expected_change": "Mounting holes."},
],
"final_claim_ids": ["claim_visual"],
})
class FeaturePlanTests(unittest.TestCase):
def test_plan_requires_exact_claim_ownership(self) -> None:
plan = initial_plan()
self.assertEqual(validate_feature_plan(plan, contract(), {"extrude_add_blind", "hole_blind"}), [])
broken = plan.model_copy(deep=True)
broken.nodes[1].claim_ids = ["claim_base"]
messages = [item["message"] for item in validate_feature_plan(broken, contract(), {"extrude_add_blind", "hole_blind"})]
self.assertTrue(any("already owned" in message for message in messages))
self.assertTrue(any("has no owner" in message for message in messages))
def test_visual_claim_repeated_on_a_node_is_removed_before_persisting_the_plan(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
handler = RequirementsCommandHandler(repository, artifacts, default_registry(), atomic_ids=lambda: ("extrude_add_blind", "hole_blind"))
task_id = "cad_123456abcdef"
repository.create_task(task_id, "Create a part.")
artifacts.initialize_task(task_id, "Create a part.")
self.assertIsInstance(handler.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a part."), invocation_id="requirements"), Accepted)
self.assertIsInstance(handler.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] A base and a visual edge treatment."), invocation_id="target"), Accepted)
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "visual", "expected": {"description": "A visible edge treatment."}},
]}]})
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
submitted = FeaturePlan.model_validate({
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create the base.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001", "claim_002"], "expected_change": "One base solid."}],
"final_claim_ids": ["claim_002"],
})
accepted = handler.submit_feature_plan(task_id, submitted, invocation_id="plan")
self.assertIsInstance(accepted, Accepted)
state = repository.get_state(task_id)
persisted = artifacts.read_json(task_id, state.feature_plan_path)
self.assertEqual(persisted["nodes"][0]["claim_ids"], ["claim_001"])
self.assertEqual(persisted["final_claim_ids"], ["claim_002"])
def test_unowned_global_health_claim_is_bound_to_the_unique_root_add_feature(self) -> None:
plan = FeaturePlan.model_validate({
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
"nodes": [
{"node_id": "base", "priority": 10, "intent": "Create the base.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_bore"], "expected_change": "One base solid."},
{"node_id": "finish", "priority": 20, "intent": "Finish the part.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_pattern"], "expected_change": "One bore."},
],
"final_claim_ids": ["claim_visual"],
})
health_contract = contract()
health_contract["requirements"][0]["acceptance_claims"][0]["claim_kind"] = "single_connected_body"
normalized = RequirementsCommandHandler._assign_unowned_global_health_claims(plan, health_contract)
self.assertEqual(normalized.nodes[0].claim_ids, ["claim_bore", "claim_base"])
self.assertEqual(validate_feature_plan(normalized, health_contract, {"extrude_add_blind", "hole_blind"}), [])
def test_scheduler_uses_ready_nodes_and_fixed_priority(self) -> None:
plan = initial_plan()
scheduler = FeatureScheduler(plan, [])
self.assertEqual(scheduler.next_ready().node_id, "base")
base = plan.nodes[0]
events = [{
"event": "feature_node_verified", "node_id": "base", "node_hash": node_hash(base),
"feature_id": "feature_001", "revision_id": "rev_001",
}]
scheduler = FeatureScheduler(plan, events)
self.assertEqual(scheduler.statuses()["base"], "done")
# Both bore and pattern are ready; priority decides deterministically.
self.assertEqual(scheduler.next_ready().node_id, "bore")
self.assertEqual(scheduler.feature_ids(), {"base": "feature_001"})
def test_revision_cannot_change_done_node_and_replaces_failed_subgraph(self) -> None:
previous = initial_plan()
base = previous.nodes[0]
bore = previous.nodes[1]
events = [
{"event": "feature_node_verified", "node_id": "base", "node_hash": node_hash(base), "feature_id": "feature_001", "revision_id": "rev_001"},
{"event": "feature_node_failed", "node_id": "bore", "node_hash": node_hash(bore), "failure_class": "engine_build", "attempt": 2, "terminal": True},
]
completed = FeatureScheduler(previous, events).completed_node_hashes()
revision = FeaturePlan.model_validate({
"schema_version": "cad.v3.2.feature-plan.v1",
"parent_plan_hash": plan_hash(previous),
"replaces_node_ids": ["bore"],
"nodes": [
base.model_dump(mode="json"),
{"node_id": "bore_revised", "priority": 20, "intent": "Cut the bore with revised operation.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_bore"], "expected_change": "One through bore."},
previous.nodes[2].model_dump(mode="json"),
],
"final_claim_ids": ["claim_visual"],
})
self.assertEqual(
validate_feature_plan(revision, contract(), {"extrude_add_blind", "hole_blind"}, previous_plan=previous, completed_node_hashes=completed, required_replacements={"bore"}),
[],
)
changed = revision.model_copy(deep=True)
changed.nodes[0].intent = "Changed completed node."
self.assertTrue(any("completed node 'base' was modified" in item["message"] for item in validate_feature_plan(changed, contract(), {"extrude_add_blind", "hole_blind"}, previous_plan=previous, completed_node_hashes=completed, required_replacements={"bore"})))
def test_materialized_feature_uses_direct_dag_dependencies_not_previous_history_item(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
runtime = ProfileCadRuntime(runtime_settings(Path(temporary)))
base = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "dag_test",
"geometry": {"sketches": [{
"id": "sketch_001", "workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
}]},
"features": [
{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"},
{"id": "feature_002", "atomic_id": "reference_plane", "params": {"plane": {"origin_mm": [0, 0, 5], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}}, "depends_on": ["feature_001"]},
],
}
contract = runtime.operation_contract("reference_axis")
document, audit = runtime.materialize_fragment(
base,
{"feature": {"atomic_id": "reference_axis", "params": {"axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}}}},
contract,
{},
runtime.reference_tokens(base),
depends_on_feature_ids=("feature_001",),
)
self.assertEqual(document["features"][-1]["depends_on"], ["feature_001"])
self.assertEqual(audit["depends_on_feature_ids"], ["feature_001"])
def test_required_through_cut_gets_a_server_recorded_exit_allowance(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
runtime = ProfileCadRuntime(runtime_settings(Path(temporary)))
base = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "through_cut",
"geometry": {"sketches": [{
"id": "sketch_001",
"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
}]},
"features": [{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"}],
}
document, audit = runtime.materialize_fragment(
base,
{
"sketch": {
"workplane": {"origin_mm": [0, 0, 5], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 2},
},
"feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 5, "reverse": True}},
},
runtime.operation_contract("extrude_cut_blind"),
{"body": {"kind": "body", "geometry": {"bbox_mm": [-10, -10, 0, 10, 10, 5]}}},
runtime.reference_tokens(base),
require_through=True,
depends_on_feature_ids=("feature_001",),
)
self.assertAlmostEqual(document["features"][-1]["params"]["distance_mm"], 5.01)
self.assertEqual(audit["server_normalizations"][0]["submitted_mm"], 5.0)
self.assertEqual(audit["server_normalizations"][0]["reason"], "required through-cut exit allowance")
def test_verified_node_publishes_without_a_render_bundle_or_candidate_review(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
registry = default_registry()
runtime = ProfileCadRuntime(runtime_settings(root))
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
requirements = RequirementsCommandHandler(repository, artifacts, registry, atomic_ids=runtime.supported_atomic_ids)
actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(registry))
task_id = "cad_123456abcdef"
repository.create_task(task_id, "Create a disk.")
artifacts.initialize_task(task_id, "Create a disk.")
self.assertIsInstance(requirements.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a disk."), invocation_id="requirements"), Accepted)
self.assertIsInstance(requirements.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] One connected disk with 20 mm diameter and 5 mm thickness."), invocation_id="target"), Accepted)
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "axial_span_mm": 5, "tolerance_mm": 0.1}},
]}]})
self.assertIsInstance(requirements.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
plan = FeaturePlan.model_validate({
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create disk.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001", "claim_002"], "expected_change": "One disk solid."}],
"final_claim_ids": [],
})
self.assertIsInstance(requirements.submit_feature_plan(task_id, plan, invocation_id="plan"), Accepted)
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.SCHEDULING_FEATURE)
self.assertIsInstance(actions.schedule_next_feature(task_id), Accepted)
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FEATURE_PENDING)
result = actions.submit_feature_fragment(task_id, {
"sketch": {"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}},
"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}},
}, invocation_id="fragment")
self.assertIsInstance(result, Accepted)
state = repository.get_state(task_id)
self.assertEqual(state.phase, TaskPhase.SCHEDULING_FEATURE)
self.assertEqual(state.active_revision, "rev_001")
self.assertFalse((artifacts.task_dir(task_id) / "revisions" / "rev_001" / "renders").exists())
events = repository.ledger_events(task_id)
self.assertTrue(any(event.get("event") == "feature_node_verified" for event in events))
self.assertFalse(any(event.get("event") == "candidate_built" for event in events))
final_gate = actions.schedule_next_feature(task_id)
self.assertIsInstance(final_gate, Accepted)
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FINAL_VALIDATION)
self.assertTrue(any(event.get("event") == "feature_plan_complete" for event in repository.ledger_events(task_id)))
def test_feature_plan_schema_binds_plan_revision_lineage_to_state(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
runtime = ProfileCadRuntime(runtime_settings(root))
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
requirements = RequirementsCommandHandler(repository, artifacts, default_registry(), atomic_ids=runtime.supported_atomic_ids)
task_id = "cad_123456abcdef"
repository.create_task(task_id, "Create a disk.")
artifacts.initialize_task(task_id, "Create a disk.")
self.assertIsInstance(requirements.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a disk."), invocation_id="requirements"), Accepted)
self.assertIsInstance(requirements.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] One disk."), invocation_id="target"), Accepted)
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
]}]})
self.assertIsInstance(requirements.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
plan = FeaturePlan.model_validate({
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create disk.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001"], "expected_change": "One disk solid."}],
"final_claim_ids": [],
})
self.assertIsInstance(requirements.submit_feature_plan(task_id, plan, invocation_id="plan"), Accepted)
scheduled = transition(repository.get_state(task_id), "feature_scheduled")
self.assertTrue(repository.compare_and_swap(scheduled))
replanning = transition(repository.get_state(task_id), "feature_replan")
self.assertTrue(repository.compare_and_swap(replanning))
schema = requirements.feature_plan_schema(task_id)
properties = schema["properties"]
self.assertEqual(properties["parent_plan_hash"]["enum"], [plan_hash(plan)])
self.assertEqual(properties["replaces_node_ids"]["minItems"], 0)
self.assertEqual(properties["replaces_node_ids"]["maxItems"], 0)
node_schema = schema["$defs"]["FeatureNode"]["properties"]
self.assertEqual(node_schema["claim_ids"]["items"], {"enum": ["claim_001"]})
self.assertEqual(properties["final_claim_ids"]["items"], {"enum": []})
def test_pending_assigned_claim_rejects_the_node_checkpoint(self) -> None:
class PendingAssignedClaimVerifier:
def evaluate(self, claims: list[dict[str, object]], _facts: dict[str, object]) -> list[dict[str, object]]:
return [
{
"claim_id": str(claim.get("claim_id") or ""),
"claim_kind": str(claim.get("claim_kind") or ""),
"deterministic": True,
"status": "pending" if claim.get("claim_id") == "claim_002" else "pass",
"evidence": {"reason": "target geometry has not been introduced"},
}
for claim in claims
]
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
runtime = ProfileCadRuntime(runtime_settings(root))
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
requirements = RequirementsCommandHandler(repository, artifacts, default_registry(), atomic_ids=runtime.supported_atomic_ids)
actions = ActionCommandHandler(repository, artifacts, runtime, PendingAssignedClaimVerifier())
task_id = "cad_123456abcdef"
repository.create_task(task_id, "Create a disk.")
artifacts.initialize_task(task_id, "Create a disk.")
self.assertIsInstance(requirements.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a disk."), invocation_id="requirements"), Accepted)
self.assertIsInstance(requirements.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] One disk."), invocation_id="target"), Accepted)
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "tolerance_mm": 0.1}},
]}]})
self.assertIsInstance(requirements.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
plan = FeaturePlan.model_validate({
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create disk.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001", "claim_002"], "expected_change": "One disk solid."}],
"final_claim_ids": [],
})
self.assertIsInstance(requirements.submit_feature_plan(task_id, plan, invocation_id="plan"), Accepted)
self.assertIsInstance(actions.schedule_next_feature(task_id), Accepted)
result = actions.submit_feature_fragment(task_id, {
"sketch": {"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}},
"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}},
}, invocation_id="fragment")
self.assertIsInstance(result, Rejected)
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FEATURE_PENDING)
events = repository.ledger_events(task_id)
failure = next(event for event in reversed(events) if event.get("event") == "feature_node_failed")
self.assertEqual(failure["failure_class"], "node_validation")
self.assertTrue(any(item.get("claim_id") == "claim_002" and item.get("status") == "pending" for item in failure["blockers"]))
self.assertFalse(any(event.get("event") == "feature_node_verified" for event in events))
if __name__ == "__main__":
unittest.main()
+6 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import argparse, json
from pathlib import Path
from .pipeline import load_samples, run_stage, scan, select_samples
from .reports import generate_reports, read_json
from .reports import generate_markdown_report, generate_reports, read_json
DEFAULT_INPUT = Path("data/cadfs-sample/CADFS_test")
DEFAULT_OUTPUT = Path("cadfs_to_cdsl/output")
@@ -25,6 +25,8 @@ def _parser() -> argparse.ArgumentParser:
command.add_argument("--compare-mode", choices=("rp", "strict"), default="rp")
command.add_argument("--force", action="store_true")
command.add_argument("--timeout-seconds", type=float, default=30.0, help="per-model OCC timeout (default: 30)")
if name == "report":
command.add_argument("--markdown", action="store_true", help="also write full_run_report.md")
return parser
@@ -35,6 +37,9 @@ def main(argv: list[str] | None = None) -> int:
elif args.command == "report":
manifest = args.output / "manifest.jsonl"; records = [json.loads(line) for line in manifest.read_text().splitlines() if line.strip()]
result = generate_reports(args.output, records)
if args.markdown:
command_text = "python -m cadfs_to_cdsl report --markdown"
result = {**result, "markdown_report": str(generate_markdown_report(args.output, records, input_root=args.input, command=command_text))}
else:
if not 1 <= args.workers <= 8: raise ValueError("--workers must be between 1 and 8")
samples = select_samples(load_samples(args.input, args.output), sample_ids=args.sample_id, offset=args.offset, limit=args.limit, seed=args.seed)
+112 -26
View File
@@ -24,6 +24,11 @@ class LoweringResult:
history: list[dict[str, Any]]
class UnsupportedCapability(ValueError):
def __init__(self, capability: str, message: str):
super().__init__(message); self.capability = capability
def plain(value: Any) -> Any:
if isinstance(value, Call): return {"call": value.name, "args": [plain(arg) for arg in value.args], "line": value.line}
if isinstance(value, list): return [plain(item) for item in value]
@@ -38,7 +43,7 @@ def _bool(value: Any) -> bool:
def _number(value: Any, units: bool = False) -> float:
if isinstance(value, (float, int)): return float(value)
if isinstance(value, str):
constants = {"mm": 1., "millimeter": 1., "cm": 10., "m": 1000., "inch": 25.4, "in": 25.4, "ft": 304.8}
constants = {"mm": 1., "millimeter": 1., "cm": 10., "m": 1000., "inch": 25.4, "in": 25.4, "ft": 304.8, "degree": 1.}
if value in constants: return constants[value]
return float(value)
if isinstance(value, Call) and value.name == "__binary__":
@@ -71,19 +76,40 @@ def _shift_plane(plane: dict[str, Any], distance: float) -> dict[str, Any]:
return {**plane, "origin_mm": [plane["origin_mm"][i] + plane["normal"][i]*distance for i in range(3)]}
def _plane_from_query(value: Any, feature_frames: dict[str, dict[str, Any]]) -> dict[str, Any]:
def _oriented_plane(plane: dict[str, Any], normal_sign: float, distance: float = 0.0) -> dict[str, Any]:
shifted = _shift_plane(plane, distance)
return {**shifted, "normal": [normal_sign * value for value in plane["normal"]]}
def _plane_from_query(value: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]] | None = None) -> dict[str, Any]:
for call in walk_calls(value):
if call.name in {"makeId", "qCreatedBy"}:
text = " ".join(symbolic_string(arg) for arg in call.args)
for name, plane in PLANES.items():
if f"{name}.planeOp" in text: return dict(plane)
query = parse_query(value)
if query.topology_type == "IMPRINT" and sketch_by_source and query.source_sketch in sketch_by_source:
return dict(sketch_by_source[query.source_sketch]["workplane"])
frame = feature_frames.get(query.owner_feature or "")
if frame:
if frame and query.topology_type == "CAP_FACE":
return dict(frame["start" if query.is_start is not False else "end"])
if frame and frame.get("start") == frame.get("end") and "qCreatedBy" in query.calls:
return dict(frame["start"])
raise ValueError("unsupported or unresolved sketch workplane")
def _bound_name(value: Any) -> str:
return str(value or "BLIND").split(".")[-1].upper()
def _end_condition(value: Any) -> dict[str, Any]:
name = _bound_name(value)
if name == "BLIND": return {"type": "blind", "solidworks_code": 0}
if name == "SYMMETRIC": return {"type": "mid_plane", "solidworks_code": 8}
if name == "THROUGH_ALL": return {"type": "through_all", "solidworks_code": 1}
raise UnsupportedCapability(f"extrude_extent:{name.lower()}", f"current CDSL atomic set has no exact extrusion operation for {name}")
def _arc(start: list[float], mid: list[float], end: list[float]) -> dict[str, Any]:
ax, ay = start; bx, by = mid; cx, cy = end
d = 2 * (ax*(by-cy) + bx*(cy-ay) + cx*(ay-by))
@@ -134,7 +160,10 @@ def _lower_sketch(sketch: SketchIR, plane: dict[str, Any]) -> tuple[dict[str, An
if len(segments) == 1 and segments[0]["type"] == "circle" and not explicit_construction:
profile = {"type": "circle", "center": segments[0]["center"], "radius_mm": segments[0]["radius_mm"]}
else:
contours, construction = _contours(segments); construction.extend(explicit_construction)
contours, open_segments = _contours(segments)
if open_segments:
raise ValueError(f"sketch has {len(open_segments)} open non-construction segment(s)")
construction = list(explicit_construction)
if not contours:
profile = {"type": "analytic_contours", "contours": [], "construction": construction}
return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities
@@ -164,6 +193,13 @@ def _source_sketch(params: dict[str, Any]) -> str | None:
return None
def _profile_query_kind(params: dict[str, Any]) -> str | None:
for key in ("entities", "sheetProfilesArray"):
if key in params:
return parse_query(params[key]).topology_type
return None
def _profile_executable(sketch: dict[str, Any]) -> bool:
profile = sketch.get("profile") or {}
if profile.get("type") == "circle": return True
@@ -188,7 +224,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
if isinstance(step, SketchIR):
history.append({"feature_id": step.feature_id, "operation": "newSketch", "parameters": {"sketchPlane": plain(step.workplane)}, "entities": [{"entity_id": e.feature_id, "operation": e.operation, "parameters": plain(e.params)} for e in step.entities]})
try:
plane = _plane_from_query(step.workplane, feature_frames)
plane = _plane_from_query(step.workplane, feature_frames, sketch_by_source)
lowered, entities = _lower_sketch(step, plane); sketches.append(lowered); sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities
except Exception as exc:
diagnostics.append({"code": "sketch_deferred", "feature_id": step.feature_id, "message": str(exc)}); complete = False
@@ -200,20 +236,52 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
try:
fid = f"f_{item.feature_id}"; depends = list(previous[-1:]); p = item.params; feature: dict[str, Any]
if item.operation == "cPlane":
base = _plane_from_query(p.get("entities"), feature_frames); offset = _number(p.get("offset", 0), True); plane = _shift_plane(base, offset)
plane_type = str(p.get("cplaneType") or "OFFSET").split(".")[-1].upper()
if plane_type != "OFFSET":
raise UnsupportedCapability(f"reference_plane:{plane_type.lower()}", f"current converter only supports exact OFFSET reference planes, not {plane_type}")
base = _plane_from_query(p.get("entities"), feature_frames, sketch_by_source); offset = _number(p.get("offset", 0), True); plane = _shift_plane(base, offset)
feature = {"id": fid, "name": item.feature_id, "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane, "offset_mm": offset}, "execution_status": "supported"}
feature_frames[item.feature_id] = {"start": plane, "end": plane}
elif item.operation == "extrude":
if p.get("surfaceOperationType") is not None:
raise UnsupportedCapability("extrude_surface_or_mixed", "current CDSL engine has no exact surface or mixed solid/surface extrusion operation")
profile_kind = _profile_query_kind(p)
if profile_kind and profile_kind not in {"IMPRINT"}:
raise UnsupportedCapability(f"extrude_profile_topology:{profile_kind.lower()}", f"current CDSL engine cannot exactly replay an extrude profile selected from {profile_kind}")
source = _source_sketch(p)
if not source or source not in sketch_by_source: raise ValueError("extrude sketch query is unresolved")
if not _profile_executable(sketch_by_source[source]): raise ValueError("extrude sketch has no closed profile")
depth = _number(p.get("depth"), True); operation = str(p.get("operationType") or "NEW").upper(); reverse = _bool(p.get("oppositeDirection"))
atomic = "extrude_cut_blind" if any(x in operation for x in ("REMOVE", "CUT")) else "extrude_add_two_sided" if _bool(p.get("hasSecondDirection")) else "extrude_add_blind"
params = {"distance_mm": depth, "reverse": reverse}
if atomic == "extrude_add_two_sided": params["reverse_distance_mm"] = _number(p.get("secondDirectionDepth", depth), True)
end = _end_condition("SYMMETRIC" if _bool(p.get("symmetric")) else p.get("endBound"))
depth_value = p.get("depth"); depth = _number(depth_value, True) if depth_value is not None else 1.0
operation = str(p.get("operationType") or "NEW").upper(); reverse = _bool(p.get("oppositeDirection")); cutting = any(x in operation for x in ("REMOVE", "CUT"))
second = _bool(p.get("hasSecondDirection"))
if cutting and (second or end["type"] != "blind"):
capability = "extrude_cut_two_sided" if second or end["type"] == "mid_plane" else f"extrude_cut_{end['type']}"
raise UnsupportedCapability(capability, f"current CDSL atomic set has no exact {capability} operation")
if not cutting and not second and end["type"] not in {"blind", "mid_plane"}:
capability = f"extrude_add_{end['type']}"
raise UnsupportedCapability(capability, f"current CDSL atomic set has no exact {capability} operation")
if second:
atomic = "extrude_add_two_sided"; params = {"distance_mm": depth, "reverse": reverse, "end_condition": end}
reverse_depth = _number(p.get("secondDirectionDepth", depth), True)
params.update({"reverse_distance_mm": reverse_depth, "reverse_end_condition": _end_condition(p.get("secondDirectionBound"))})
elif end["type"] == "mid_plane" and not cutting:
blind = _end_condition("BLIND")
atomic = "extrude_add_two_sided"; params = {"distance_mm": depth / 2, "reverse_distance_mm": depth / 2, "reverse": reverse, "end_condition": blind, "reverse_end_condition": dict(blind)}
else:
atomic = "extrude_cut_blind" if cutting else "extrude_add_blind"
params = {"distance_mm": depth, "reverse": reverse, "end_condition": end}
feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": params, "execution_status": "supported"}
plane = sketch_by_source[source]["workplane"]; feature_frames[item.feature_id] = {"start": plane, "end": _shift_plane(plane, -depth if reverse else depth)}
plane = sketch_by_source[source]["workplane"]
if end["type"] == "blind" and not second:
direction = -1 if reverse else 1
feature_frames[item.feature_id] = {"start": dict(plane), "end": _shift_plane(plane, direction * depth)}
elif end["type"] == "mid_plane":
direction = -1 if reverse else 1
feature_frames[item.feature_id] = {"start": _shift_plane(plane, -direction * depth / 2), "end": _shift_plane(plane, direction * depth / 2)}
elif item.operation == "revolve":
if p.get("surfaceOperationType") is not None and p.get("operationType") is None:
raise UnsupportedCapability("revolve_surface", "current CDSL engine has no exact surface-revolve operation")
source = _source_sketch(p)
if not source or source not in sketch_by_source: raise ValueError("revolve sketch query is unresolved")
if not _profile_executable(sketch_by_source[source]): raise ValueError("revolve sketch has no closed profile")
@@ -223,17 +291,24 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
direction = [end[i]-start[i] for i in range(3)]; norm = math.sqrt(sum(x*x for x in direction)); direction = [x/norm for x in direction]
operation = str(p.get("operationType") or p.get("surfaceOperationType") or "NEW").upper(); atomic = "revolve_cut" if "REMOVE" in operation else "revolve_add"
full = "FULL" in str(p.get("revolveType") or "FULL").upper(); angle = 360.0 if full else _number(p.get("angle", 360.0))
feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": {"angle_deg": angle, "axis": {"origin_mm": start, "direction": direction}}, "execution_status": "supported"}
feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": {"angle_deg": angle, "reverse": _bool(p.get("oppositeDirection")), "axis": {"origin_mm": start, "direction": direction}}, "execution_status": "supported"}
elif item.operation in {"fillet", "chamfer"}:
key = "radius" if item.operation == "fillet" else "width"; amount = _number(p.get(key), True); selectors = []
key = "radius" if item.operation == "fillet" else "width"
chamfer_type = str(p.get("chamferType") or "EQUAL_OFFSETS").split(".")[-1].upper()
amount_value = p.get(key)
if item.operation == "chamfer" and chamfer_type == "TWO_OFFSETS": amount_value = p.get("width1")
amount = _number(amount_value, True); selectors = []
for index, query_value in enumerate(_queries(p.get("entities"))):
query = parse_query(query_value); owner = query.owner_feature
if not owner: raise ValueError("selector owner is unresolved")
selector_kind = "face" if query.kind in {"face", "entitytype.face"} or query.topology_type in {"CAP_FACE", "SWEPT_FACE"} else "edge"
refs = _source_refs(query_value)
source_entity = (entity_by_sketch.get(query.source_sketch or "") or {}).get(query.source_entity or "")
geometry: dict[str, Any] = {}
frame = feature_frames.get(owner); cap = frame and frame["start" if query.is_start else "end"]
if source_entity and cap:
if selector_kind == "face" and query.topology_type == "CAP_FACE" and cap:
geometry = {"normal": cap["normal"], "plane_offset_mm": sum(cap["normal"][i] * cap["origin_mm"][i] for i in range(3))}
if selector_kind == "edge" and source_entity and cap:
if query.topology_type == "SWEPT_EDGE" and frame:
local_point = source_entity.get("point") if source_entity["type"] == "point" else None
if len(refs) >= 2:
@@ -244,20 +319,28 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
start, end = _global(frame["start"], local_point), _global(frame["end"], local_point)
geometry = {"curve_type": "line", "bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]}
elif source_entity["type"] == "circle":
# OCC/build123d commonly splits a closed circular edge into four
# quarter-circle records. Bind all four deterministic arc centres.
radius = source_entity["radius_mm"]; center = source_entity["center"]
for quadrant, (sx, sy) in enumerate(((1, 1), (-1, 1), (-1, -1), (1, -1))):
local = [center[0] + sx*radius/math.sqrt(2), center[1] + sy*radius/math.sqrt(2)]
selectors.append({"kind": "edge", "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}_{quadrant}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": {"curve_type": "circle", "center_mm": _global(cap, local)}})
# Keep the source circle signature until the prefix has been
# rebuilt. OCC may expose it as one edge or several arcs.
selectors.append({"kind": selector_kind, "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry or {"curve_type": "circle", "source_circle_center_mm": _global(cap, source_entity["center"]), "source_circle_radius_mm": source_entity["radius_mm"], "source_plane_normal": cap["normal"]}})
continue
elif source_entity["type"] == "line":
start, end = _global(cap, source_entity["start"]), _global(cap, source_entity["end"])
geometry = {"curve_type": "line", "bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]}
if not geometry: raise ValueError("selector geometry is unresolved")
selectors.append({"kind": "edge", "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry})
if selector_kind == "face" and not geometry:
raise ValueError(f"{query.topology_type or 'face'} selector geometry is unresolved")
selectors.append({"kind": selector_kind, "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry})
params = {"radius_mm" if item.operation == "fillet" else "distance_mm": amount}
if item.operation == "fillet": params["tangent_propagation"] = _bool(p.get("tangentPropagation"))
elif chamfer_type == "TWO_OFFSETS":
second = _number(p.get("width2"), True)
if _bool(p.get("oppositeDirection")): params["distance_mm"], second = second, params["distance_mm"]
params["distance_2_mm"] = second
elif chamfer_type == "OFFSET_ANGLE":
angle = math.radians(_number(p.get("angle")))
if _bool(p.get("oppositeDirection")):
second = amount * math.tan(angle); params["distance_mm"] = second; params["distance_2_mm"] = amount
else: params["angle_rad"] = angle
feature = {"id": fid, "name": item.feature_id, "atomic_id": item.operation, "depends_on": depends, "params": params, "selectors": selectors, "execution_status": "supported"}
elif item.operation == "hole":
locations = _queries(p.get("locations")); positions = []; host_plane = None
@@ -274,11 +357,12 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
depth_value = p.get("holeDepth") or p.get("tappedDepth")
if condition == "blind" and depth_value is None: raise ValueError("blind hole depth is unresolved")
depth = _number(depth_value, True) if depth_value is not None else 1.0
hole_params: dict[str, Any] = {"hole_type": style, "diameter_mm": _number(p.get("holeDiameter"), True), "depth_mm": depth, "end_condition": {"type": condition, "solidworks_code": 1}, "positions": positions, "host_face": {"frame": frame}}
if "COUNTERSINK" in style.upper():
hole_params["countersink"] = {"diameter_mm": _number(p.get("countersinkDiameter") or p.get("majorDiameter"), True), "angle_rad": math.radians(_number(p.get("countersinkAngle") or 90.0))}
if "COUNTERBORE" in style.upper():
hole_params["counterbore"] = {"diameter_mm": _number(p.get("counterboreDiameter") or p.get("majorDiameter"), True), "depth_mm": _number(p.get("counterboreDepth"), True)}
condition_code = {"blind": 0, "through_all": 1, "through_all_both": 2}[condition]
hole_params: dict[str, Any] = {"hole_type": style, "diameter_mm": _number(p.get("holeDiameter"), True), "depth_mm": depth, "end_condition": {"type": condition, "solidworks_code": condition_code}, "positions": positions, "host_face": {"frame": frame}}
if style.upper() in {"COUNTERSINK", "C_SINK"}:
hole_params["countersink"] = {"diameter_mm": _number(p.get("countersinkDiameter") or p.get("cSinkDiameter") or p.get("majorDiameter"), True), "angle_rad": math.radians(_number(p.get("countersinkAngle") or p.get("cSinkAngle") or 90.0))}
if style.upper() in {"COUNTERBORE", "C_BORE"}:
hole_params["counterbore"] = {"diameter_mm": _number(p.get("counterboreDiameter") or p.get("cBoreDiameter") or p.get("majorDiameter"), True), "depth_mm": _number(p.get("counterboreDepth") or p.get("cBoreDepth"), True)}
if _bool(p.get("isTappedThrough")) or p.get("tapSize") is not None: hole_params["thread"] = {"source": "CADFS", "decorative": True}
feature = {"id": fid, "name": item.feature_id, "atomic_id": "hole_wizard", "depends_on": depends, "params": hole_params, "execution_status": "supported"}
elif item.operation == "mirror":
@@ -304,6 +388,8 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
else:
raise ValueError(f"operation mapping not implemented: {item.operation}")
features.append(feature); previous.append(fid)
except UnsupportedCapability as exc:
diagnostics.append({"code": "unsupported_engine_capability", "capability": exc.capability, "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False
except Exception as exc:
diagnostics.append({"code": "feature_deferred", "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False
if not features: return LoweringResult(None, "deferred_no_executable_feature", diagnostics, history)
+21 -10
View File
@@ -11,7 +11,7 @@ from .lowering import lower_model
from .rebuild import rebuild_candidate
from .reports import generate_reports, read_json, write_json, write_manifest
PIPELINE_VERSION = "cadfs_to_cdsl.v1"
PIPELINE_VERSION = "cadfs_to_cdsl.v2"
def _fingerprint(sample: Sample) -> str:
@@ -55,6 +55,9 @@ def convert_one(sample: Sample, output: Path, *, force: bool = False) -> dict[st
cached = read_json(status_path)
if cached.get("input_fingerprint") == fingerprint and cached.get("conversion_status"):
return cached
if force:
for name in ("candidate.cdsl.json", "bound.cdsl.json", "rebuild.step", "rebuild.json", "rebuild.worker.json", "comparison.json", "comparison.worker.json"):
(directory / name).unlink(missing_ok=True)
diagnostics = list(sample.diagnostics)
try:
feature_path = Path(sample.files["featurescript"])
@@ -85,7 +88,7 @@ def _compare_worker(gold: str, rebuilt: str, result: str) -> None:
write_json(Path(result), compare_steps(Path(gold), Path(rebuilt)))
def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_seconds: float) -> bool:
def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_seconds: float) -> str:
result_path.unlink(missing_ok=True)
context = multiprocessing.get_context("spawn")
process = context.Process(target=target, args=args)
@@ -93,8 +96,8 @@ def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_sec
if process.is_alive():
process.terminate(); process.join(5)
if process.is_alive(): process.kill(); process.join()
return False
return process.exitcode == 0 and result_path.exists()
return "timeout"
return "completed" if process.exitcode == 0 and result_path.exists() else "failed"
def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_seconds: float = 30.0) -> dict[str, Any]:
@@ -105,11 +108,16 @@ def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_se
if not force and rebuild_path.exists() and status.get("rebuild_status"):
if status.get("rebuild_status") != "rebuilt" or step_path.exists(): return status
worker_result = directory / "rebuild.worker.json"
completed = _isolated(_rebuild_worker, (str(directory / "candidate.cdsl.json"), str(step_path), str(worker_result)), worker_result, timeout_seconds)
if completed: result = read_json(worker_result); worker_result.unlink(missing_ok=True)
outcome = _isolated(_rebuild_worker, (str(directory / "candidate.cdsl.json"), str(step_path), str(worker_result)), worker_result, timeout_seconds)
if outcome == "completed":
result = read_json(worker_result); worker_result.unlink(missing_ok=True)
bound_cdsl = result.pop("bound_cdsl", None)
if bound_cdsl is not None: write_json(directory / "bound.cdsl.json", bound_cdsl)
else:
step_path.unlink(missing_ok=True)
result = {"status": "rebuild_timeout", "error": {"type": "TimeoutError", "message": f"rebuild exceeded {timeout_seconds:g} seconds"}}
error_type = "TimeoutError" if outcome == "timeout" else "WorkerProcessError"
message = f"rebuild exceeded {timeout_seconds:g} seconds" if outcome == "timeout" else "rebuild worker exited without a result"
result = {"status": "rebuild_timeout" if outcome == "timeout" else "rebuild_failed", "error": {"type": error_type, "message": message}}
write_json(rebuild_path, result)
status["rebuild_status"] = result["status"]; status["status"] = result["status"]
write_json(status_path, status); return status
@@ -123,10 +131,13 @@ def compare_one(sample: Sample, output: Path, *, force: bool = False, compare_mo
if not force and status.get("status") in {"comparison_failed", "comparison_timeout"}: return status
if force or not comparison_path.exists():
worker_result = directory / "comparison.worker.json"
completed = _isolated(_compare_worker, (sample.files["step"], str(directory / "rebuild.step"), str(worker_result)), worker_result, timeout_seconds)
if completed: comparison = read_json(worker_result); worker_result.unlink(missing_ok=True); write_json(comparison_path, comparison)
outcome = _isolated(_compare_worker, (sample.files["step"], str(directory / "rebuild.step"), str(worker_result)), worker_result, timeout_seconds)
if outcome == "completed": comparison = read_json(worker_result); worker_result.unlink(missing_ok=True); write_json(comparison_path, comparison)
else:
status["status"] = "comparison_timeout"; status["comparison_error"] = {"type": "TimeoutError", "message": f"comparison exceeded {timeout_seconds:g} seconds"}; write_json(status_path, status); return status
status["status"] = "comparison_timeout" if outcome == "timeout" else "comparison_failed"
error_type = "TimeoutError" if outcome == "timeout" else "WorkerProcessError"
message = f"comparison exceeded {timeout_seconds:g} seconds" if outcome == "timeout" else "comparison worker exited without a result"
status["comparison_error"] = {"type": error_type, "message": message}; write_json(status_path, status); return status
else: comparison = read_json(comparison_path)
status["comparison_decision"] = comparison["decision"]
accepted = comparison[compare_mode]["passed"]
+4 -2
View File
@@ -6,13 +6,15 @@ from typing import Any
def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]:
from engine.cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
from .selector_binding import bind_candidate_selectors
analysis = analyze_cdsl(cdsl)
analysis_dict = analysis.as_dict() if hasattr(analysis, "as_dict") else {"runtime_eligible": analysis.runtime_eligible}
if not analysis.runtime_eligible:
return {"status": "runtime_ineligible", "analysis": analysis_dict}
try:
result = rebuild_cdsl(cdsl, output, strict=True)
return {"status": "rebuilt", "analysis": analysis_dict, "result": result}
bound, binding = bind_candidate_selectors(cdsl)
result = rebuild_cdsl(bound, output, strict=True)
return {"status": "rebuilt", "analysis": analysis_dict, "selector_binding": binding, "bound_cdsl": bound, "result": result}
except Exception as exc:
detail = {"type": type(exc).__name__, "message": str(exc)}
if hasattr(exc, "selector_resolutions"): detail["selector_resolutions"] = exc.selector_resolutions
+314 -2
View File
@@ -1,7 +1,8 @@
from __future__ import annotations
import csv, json
import csv, json, re
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -31,7 +32,8 @@ def generate_reports(output: Path, records: list[dict[str, Any]]) -> dict[str, A
if diagnostics_path.exists():
for diagnostic in read_json(diagnostics_path):
reasons[str(diagnostic.get("code") or "unknown")] += 1
if diagnostic.get("operation"): gaps[str(diagnostic["operation"])].append(str(item["sample_id"]))
gap = diagnostic.get("capability") or diagnostic.get("operation")
if gap: gaps[str(gap)].append(str(item["sample_id"]))
history_path = sample_dir / "history.json"
if history_path.exists():
for step in read_json(history_path): operations[str(step.get("operation") or "unknown")] += 1
@@ -48,3 +50,313 @@ def generate_reports(output: Path, records: list[dict[str, Any]]) -> dict[str, A
with (output / "comparison_summary.csv").open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=["sample_id", "decision", "bbox_max_delta_mm", "volume_relative_error", "surface_area_relative_error"]); writer.writeheader(); writer.writerows(rows)
return summary
def _pct(count: int, total: int) -> str:
return "0.00%" if total <= 0 else f"{count / total * 100:.2f}%"
def _table(headers: list[str], rows: list[list[Any]]) -> list[str]:
lines = ["| " + " | ".join(headers) + " |", "| " + " | ".join(["---"] * len(headers)) + " |"]
for row in rows:
lines.append("| " + " | ".join(str(value).replace("\n", " ") for value in row) + " |")
return lines
def _read_optional_json(path: Path) -> Any | None:
return read_json(path) if path.exists() else None
def _normalize_error(message: str) -> str:
message = re.sub(r"/Users/[^ ]+", "<path>", message)
message = re.sub(r"0x[0-9a-fA-F]+", "0x...", message)
message = re.sub(r"\d+\.\d{4,}", "<float>", message)
return message[:180] if len(message) > 180 else message
def generate_markdown_report(
output: Path,
records: list[dict[str, Any]],
*,
input_root: Path | None = None,
command: str | None = None,
report_name: str = "full_run_report.md",
) -> Path:
summary = generate_reports(output, records)
total = len(records)
sample_root = output / "samples"
status_records = []
modality_missing: Counter[str] = Counter()
alignment_fallbacks = 0
dataset_index = _read_optional_json(output / "dataset_index.json") or {}
indexed_records = {str(item.get("sample_id")): item for item in dataset_index.get("records") or []}
for record in records:
indexed = indexed_records.get(str(record["sample_id"]), {})
for diagnostic in indexed.get("diagnostics") or record.get("diagnostics") or []:
if diagnostic.get("code") == "missing_modality":
modality_missing[str(diagnostic.get("modality") or "unknown")] += 1
if diagnostic.get("code") == "alignment_fallback":
alignment_fallbacks += 1
status = _read_optional_json(sample_root / str(record["sample_id"]) / "status.json") or record
status_records.append(status)
statuses = Counter(str(item.get("status") or "unknown") for item in status_records)
conversion_statuses = Counter(str(item.get("conversion_status") or "missing") for item in status_records)
file_counts = {
"candidate.cdsl.json": sum(1 for _ in sample_root.glob("*/candidate.cdsl.json")),
"bound.cdsl.json": sum(1 for _ in sample_root.glob("*/bound.cdsl.json")),
"rebuild.step": sum(1 for _ in sample_root.glob("*/rebuild.step")),
"comparison.json": sum(1 for _ in sample_root.glob("*/comparison.json")),
"status.json": sum(1 for _ in sample_root.glob("*/status.json")),
}
comparison_decisions: Counter[str] = Counter()
strict_pass = 0
rp_pass = 0
comparison_failures: Counter[str] = Counter()
comparison_errors: Counter[str] = Counter()
comparison_error_examples: dict[str, list[str]] = defaultdict(list)
metric_rows = []
for status in status_records:
error = status.get("comparison_error") or {}
if error:
key = f"{error.get('type') or 'Error'}: {_normalize_error(str(error.get('message') or ''))}"
comparison_errors[key] += 1
if len(comparison_error_examples[key]) < 5:
comparison_error_examples[key].append(str(status.get("sample_id") or "unknown"))
for path in sample_root.glob("*/comparison.json"):
comparison = _read_optional_json(path)
if not comparison:
continue
sample_id = path.parent.name
comparison_decisions[str(comparison.get("decision") or "unknown")] += 1
strict_pass += 1 if ((comparison.get("strict") or {}).get("passed")) else 0
rp_pass += 1 if ((comparison.get("rp") or {}).get("passed")) else 0
for reason in ((comparison.get("raw") or {}).get("failure_reasons") or []):
comparison_failures[str(reason)] += 1
metrics = ((comparison.get("raw") or {}).get("metrics") or {})
metric_rows.append((
sample_id,
comparison.get("decision"),
metrics.get("bbox_max_delta_mm"),
metrics.get("volume_relative_error"),
metrics.get("surface_area_relative_error"),
))
rebuild_statuses: Counter[str] = Counter()
rebuild_errors: Counter[str] = Counter()
rebuild_error_examples: dict[str, list[str]] = defaultdict(list)
for path in sample_root.glob("*/rebuild.json"):
rebuild = _read_optional_json(path)
if not rebuild:
continue
rebuild_statuses[str(rebuild.get("status") or "unknown")] += 1
error = rebuild.get("error") or {}
if error:
key = f"{error.get('type') or 'Error'}: {_normalize_error(str(error.get('message') or ''))}"
rebuild_errors[key] += 1
if len(rebuild_error_examples[key]) < 5:
rebuild_error_examples[key].append(path.parent.name)
diagnostic_counts: Counter[str] = Counter()
diagnostic_examples: dict[str, list[str]] = defaultdict(list)
capability_examples: dict[str, list[str]] = defaultdict(list)
capability_source: dict[str, str] = {}
for record in records:
sample_id = str(record["sample_id"])
diagnostics = _read_optional_json(sample_root / sample_id / "diagnostics.json") or []
for diagnostic in diagnostics:
code = str(diagnostic.get("code") or "unknown")
diagnostic_counts[code] += 1
if len(diagnostic_examples[code]) < 8:
diagnostic_examples[code].append(sample_id)
capability = diagnostic.get("capability") or diagnostic.get("operation")
if capability:
capability = str(capability)
if len(capability_examples[capability]) < 10:
capability_examples[capability].append(sample_id)
capability_source.setdefault(capability, str(diagnostic.get("operation") or capability))
gap_payload = _read_optional_json(output / "capability_gaps.json") or {}
top_gaps = sorted(
((name, int(value.get("sample_count") or 0), ", ".join((value.get("sample_ids") or [])[:8])) for name, value in gap_payload.items()),
key=lambda item: (-item[1], item[0]),
)
unsupported_ops = {
"shell", "loft", "sweep", "draft", "thicken", "split", "booleanBodies", "circularPattern",
"moveFace", "replaceFace", "deleteFace", "import", "derive",
}
exact_mappings = {
"extrude": "extrude_add_blind / extrude_add_two_sided / extrude_cut_blind",
"revolve": "revolve_add / revolve_cut",
"fillet": "fillet",
"chamfer": "chamfer",
"hole": "hole_wizard",
"mirror": "pattern_mirror",
"cPlane": "reference_plane (OFFSET only)",
}
try:
from engine.cdsl_engine.runtime import EXECUTORS
engine_atomic_ids = sorted(EXECUTORS)
except Exception:
engine_atomic_ids = []
operation_rows = sorted(
((name, count) for name, count in (summary.get("operation_counts") or {}).items()),
key=lambda item: (-item[1], item[0]),
)
status_rows = [[name, count, _pct(count, total)] for name, count in sorted(statuses.items(), key=lambda item: (-item[1], item[0]))]
conversion_rows = [[name, count, _pct(count, total)] for name, count in sorted(conversion_statuses.items(), key=lambda item: (-item[1], item[0]))]
lines: list[str] = [
"# CADFS full conversion report",
"",
f"- Generated at: {datetime.now().isoformat(timespec='seconds')}",
f"- Input: `{input_root}`" if input_root else "- Input: not recorded",
f"- Output: `{output}`",
f"- Command: `{command}`" if command else "- Command: not recorded",
f"- Total samples: {total}",
f"- Manifest rows: {sum(1 for _ in (output / 'manifest.jsonl').open(encoding='utf-8')) if (output / 'manifest.jsonl').exists() else 'missing'}",
"",
"## Acceptance summary",
"",
f"- RP accepted samples: {rp_pass} ({_pct(rp_pass, total)})",
f"- Strict accepted samples: {strict_pass} ({_pct(strict_pass, total)})",
f"- Rebuilt STEP files present: {file_counts['rebuild.step']}",
f"- Comparison reports present: {file_counts['comparison.json']}",
f"- Candidate CDSL files present: {file_counts['candidate.cdsl.json']}",
f"- Bound CDSL files present: {file_counts['bound.cdsl.json']}",
"",
"## Final statuses",
"",
*_table(["Status", "Count", "Share"], status_rows),
"",
"## Conversion statuses",
"",
*_table(["Conversion status", "Count", "Share"], conversion_rows),
"",
"## Modality and alignment",
"",
]
if modality_missing:
lines.extend(_table(["Missing modality", "Count"], sorted(modality_missing.items())))
else:
lines.append("- No missing local modalities were recorded in the manifest.")
lines.extend(["", f"- JSONL content alignment fallbacks: {alignment_fallbacks}", ""])
lines.extend([
"## Comparison results",
"",
*_table(["Decision", "Count"], sorted(comparison_decisions.items(), key=lambda item: (-item[1], item[0]))),
"",
"Top strict/RP comparison failure checks:",
"",
])
if comparison_failures:
lines.extend(_table(["Failure check", "Count"], sorted(comparison_failures.items(), key=lambda item: (-item[1], item[0]))[:12]))
else:
lines.append("- No comparison failure checks were recorded.")
lines.extend(["", "Comparison worker errors:", ""])
if comparison_errors:
rows = [[name, count, ", ".join(comparison_error_examples[name])] for name, count in sorted(comparison_errors.items(), key=lambda item: (-item[1], item[0]))[:12]]
lines.extend(_table(["Error", "Count", "Examples"], rows))
else:
lines.append("- No comparison worker errors were recorded.")
lines.extend([
"",
"## Rebuild outcomes",
"",
*_table(["Rebuild status", "Count"], sorted(rebuild_statuses.items(), key=lambda item: (-item[1], item[0]))),
"",
"Top rebuild/runtime errors:",
"",
])
if rebuild_errors:
rows = [[name, count, ", ".join(rebuild_error_examples[name])] for name, count in sorted(rebuild_errors.items(), key=lambda item: (-item[1], item[0]))[:12]]
lines.extend(_table(["Error", "Count", "Examples"], rows))
else:
lines.append("- No rebuild errors were recorded.")
lines.extend([
"",
"## Diagnostics",
"",
*_table(
["Diagnostic code", "Count", "Example samples"],
[[name, count, ", ".join(diagnostic_examples[name])] for name, count in sorted(diagnostic_counts.items(), key=lambda item: (-item[1], item[0]))],
),
"",
"## Capability gaps",
"",
*_table(["Capability", "Affected samples", "Example samples"], top_gaps[:25]),
"",
"## FeatureScript operation counts",
"",
*_table(["Operation", "Occurrences"], operation_rows),
"",
"## Exact mapping policy",
"",
"- The converter keeps FeatureScript operation identity in `history.json` and diagnostics.",
"- Unsupported operations are not rewritten as substitute atomics.",
"- Parameters are statically evaluated from FeatureScript only; STEP geometry is not used to infer or tune CDSL parameters.",
"",
*_table(["FeatureScript operation", "CDSL atomic policy"], sorted(exact_mappings.items())),
"",
"Known unsupported FeatureScript operations recorded as capability gaps:",
"",
", ".join(sorted(unsupported_ops)),
"",
"Engine executor atomic IDs:",
"",
", ".join(engine_atomic_ids) if engine_atomic_ids else "Unable to import engine executor registry while generating this report.",
"",
"## Regression check",
"",
])
regression = _read_optional_json(sample_root / "00000173" / "comparison.json")
if regression:
metrics = ((regression.get("raw") or {}).get("metrics") or {})
lines.extend([
"- Sample `00000173` decision: `" + str(regression.get("decision")) + "`",
"- RP passed: `" + str((regression.get("rp") or {}).get("passed")) + "`, strict passed: `" + str((regression.get("strict") or {}).get("passed")) + "`",
f"- BBox max delta: `{metrics.get('bbox_max_delta_mm')}` mm",
f"- Volume relative error: `{metrics.get('volume_relative_error')}`",
f"- Surface area relative error: `{metrics.get('surface_area_relative_error')}`",
"- This is consistent with the known CADFS RP radius quantization case: FeatureScript uses 9.53 mm while the source STEP is about 9.525 mm.",
])
else:
lines.append("- Sample `00000173` has no comparison report.")
lines.extend([
"",
"## Output locations",
"",
f"- Per-sample artifacts: `{sample_root}/<sample_id>/`",
f"- Manifest: `{output / 'manifest.jsonl'}`",
f"- Summary JSON: `{output / 'summary.json'}`",
f"- Capability gaps JSON: `{output / 'capability_gaps.json'}`",
f"- Unsupported capabilities Markdown: `{output / 'unsupported_capabilities.md'}`",
f"- Comparison CSV: `{output / 'comparison_summary.csv'}`",
"",
"## Notes",
"",
"- The source CADFS directory was treated as read-only by the pipeline.",
"- `workers=1` was used for OCC stability and reproducibility.",
"- Accepted samples require a generated CDSL candidate, rebuilt STEP, and comparison report.",
"- Deferred or rejected samples retain evidence in `diagnostics.json`, `history.json`, `rebuild.json`, or `comparison.json`.",
"",
])
if metric_rows:
worst_bbox = sorted(metric_rows, key=lambda item: (item[2] is None, item[2] or 0), reverse=True)[:5]
worst_volume = sorted(metric_rows, key=lambda item: (item[3] is None, item[3] or 0), reverse=True)[:5]
lines.extend(["## Largest observed comparison deltas", "", "BBox delta:", ""])
lines.extend(_table(["Sample", "Decision", "BBox max delta mm", "Volume rel err", "Area rel err"], worst_bbox))
lines.extend(["", "Volume relative error:", ""])
lines.extend(_table(["Sample", "Decision", "BBox max delta mm", "Volume rel err", "Area rel err"], worst_volume))
lines.append("")
report_path = output / report_name
report_path.write_text("\n".join(lines), encoding="utf-8")
return report_path
+74 -1
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
import math
from copy import deepcopy
from pathlib import Path
import tempfile
from typing import Any
@@ -17,6 +20,11 @@ def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None:
try: delta = abs(float(expected[key]) - float(actual[key]))
except Exception: return None
scores.append(max(0.0, 1.0 - delta / 0.05))
if "bbox_mm" in expected:
actual_box = actual.get("bbox_mm")
if not isinstance(actual_box, (list, tuple)) or len(actual_box) != 6: return None
delta = max(abs(float(a) - float(b)) for a, b in zip(expected["bbox_mm"], actual_box))
scores.append(max(0.0, 1.0 - delta / 0.05))
return sum(scores) / len(scores) if scores else 0.0
@@ -31,4 +39,69 @@ def bind_selector(kind: str, owner_feature_id: str, geometry: dict[str, Any], re
if not candidates: raise ValueError("selector_not_found")
if len(candidates) > 1 and abs(candidates[0][0] - candidates[1][0]) <= 1e-9: raise ValueError("selector_ambiguous")
score, record = candidates[0]
return {"kind": kind, "owner_feature_id": owner_feature_id, "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "cadfs_featurescript", "confidence": round(score, 6), "geometry": record.get("geometry") or geometry}
return {"kind": kind, "owner_feature_id": owner_feature_id, "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": round(score, 6), "geometry": record.get("geometry") or geometry}
def _dot(left: list[float], right: list[float]) -> float: return sum(float(a)*float(b) for a, b in zip(left, right))
def _circle_records(expected: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, Any]]:
center = expected.get("source_circle_center_mm"); normal = expected.get("source_plane_normal"); radius = float(expected.get("source_circle_radius_mm") or 0)
if not isinstance(center, list) or not isinstance(normal, list) or radius <= 0: return []
plane_offset = _dot(center, normal); matches = []
for record in records:
geometry = record.get("geometry") or {}
if record.get("kind") != "edge" or geometry.get("curve_type") != "circle": continue
points = [geometry.get("start_mm"), geometry.get("end_mm")]
if not all(isinstance(point, list) and len(point) == 3 for point in points): continue
if any(abs(_dot(point, normal) - plane_offset) > 0.05 for point in points): continue
radial = []
for point in points:
delta = [float(point[i])-float(center[i]) for i in range(3)]; axial = _dot(delta, normal)
radial.append(math.sqrt(max(0.0, sum(value*value for value in delta)-axial*axial)))
if all(abs(value-radius) <= max(0.05, radius*1e-4) for value in radial): matches.append(record)
return matches
def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Rebuild every selector-bearing prefix and bind against its active body."""
from engine.cdsl_engine.runtime import rebuild_cdsl
bound = deepcopy(cdsl); evidence = []
with tempfile.TemporaryDirectory(prefix="cadfs-bind-") as temporary:
for index, feature in enumerate(bound.get("features") or []):
placeholders = list(feature.get("selectors") or [])
if not placeholders: continue
prefix = deepcopy(bound); prefix["features"] = bound["features"][:index]
if not prefix["features"]: raise ValueError(f"{feature['id']}: selector has no executable prefix")
report = rebuild_cdsl(prefix, Path(temporary) / f"prefix-{index}.step", strict=True)
body_id = next((item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")), None)
records = [item for item in report.get("topology_records") or [] if not body_id or item.get("body_id") == body_id or str(item.get("body_id") or "").startswith(f"{body_id}:")]
resolved = []
for placeholder in placeholders:
geometry = placeholder.get("geometry") or {}
candidates = _circle_records(geometry, records) if geometry.get("source_circle_radius_mm") else []
if not candidates:
same_kind = [record for record in records if record.get("kind") == placeholder.get("kind")]
owner = placeholder.get("owner_feature_id")
owner_matches = [record for record in same_kind if owner in (record.get("owner_feature_ids") or [record.get("feature_id")])]
pool = owner_matches or same_kind
if not geometry and len(pool) != 1:
raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild")
scored = [(score, record) for record in pool if (score := _score(geometry, record.get("geometry") or {})) is not None and score >= 0.8]
scored.sort(key=lambda value: (-value[0], str(value[1].get("record_id"))))
if scored:
if len(scored) > 1 and abs(scored[0][0] - scored[1][0]) <= 1e-9:
raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild")
candidates = [scored[0][1]]
if not candidates: raise ValueError(f"{feature['id']}: selector_not_found after prefix rebuild")
for record in candidates:
owners = record.get("owner_feature_ids") or [record.get("feature_id")]
resolved.append({"kind": placeholder["kind"], "owner_feature_id": str(owners[0]), "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": 1.0, "geometry": record.get("geometry") or {}})
unique = {selector["stable_id"]: selector for selector in resolved}; feature["selectors"] = list(unique.values())
if feature.get("atomic_id") == "pattern_mirror":
planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"]
if len(planes) != 1:
raise ValueError(f"{feature['id']}: mirror plane binding is not unique")
feature.setdefault("params", {})["mirror_plane"] = planes[0]
evidence.append({"feature_id": feature["id"], "prefix_feature_count": index, "selectors": feature["selectors"]})
return bound, evidence
+21 -2
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from cadfs_to_cdsl.compare import compare_steps
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
from cadfs_to_cdsl.lowering import lower_model
from cadfs_to_cdsl.rebuild import rebuild_candidate
class IntegrationTests(unittest.TestCase):
@@ -13,13 +14,31 @@ class IntegrationTests(unittest.TestCase):
feature = root / "featurescript_rp/0000/00000173.txt"; gold = root / "step_abc/0000/00000173.step"
if not feature.exists() or not gold.exists(): self.skipTest("CADFS sample is not installed")
cdsl = lower_model(parse_featurescript(feature.read_text(), "00000173"), {}).cdsl
from engine.cdsl_engine.runtime import rebuild_cdsl
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"; rebuild_cdsl(cdsl, rebuilt, strict=True)
rebuilt = Path(tmp) / "rebuild.step"
result = rebuild_candidate(cdsl, rebuilt)
self.assertEqual(result["status"], "rebuilt")
comparison = compare_steps(gold, rebuilt)
self.assertFalse(comparison["strict"]["passed"])
self.assertTrue(comparison["rp"]["passed"])
self.assertEqual(comparison["decision"], "approximate_pass")
def test_counterbore_abbreviation_00002243(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0000/00002243.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00002243"), {})
first = next(item for item in result.cdsl["features"] if item["id"] == "f_F1")
self.assertEqual(first["atomic_id"], "extrude_add_two_sided")
self.assertEqual(first["params"]["distance_mm"], 125.0)
self.assertEqual(first["params"]["reverse_distance_mm"], 125.0)
gap = next(item for item in result.diagnostics if item.get("feature_id") == "F3")
self.assertEqual(gap["code"], "unsupported_engine_capability")
self.assertEqual(gap["capability"], "extrude_cut_two_sided")
hole = next(item for item in result.cdsl["features"] if item["atomic_id"] == "hole_wizard")
self.assertEqual(hole["params"]["hole_type"], "c_bore")
self.assertEqual(hole["params"]["counterbore"]["diameter_mm"], 17.25)
self.assertEqual(hole["params"]["counterbore"]["depth_mm"], 10.0)
if __name__ == "__main__": unittest.main()
+30
View File
@@ -30,6 +30,36 @@ class LoweringTests(unittest.TestCase):
self.assertIsNone(result.cdsl)
self.assertEqual(result.diagnostics[0]["code"], "unsupported_operation")
def test_symmetric_cut_is_not_disguised_as_blind_cut(self):
source = SOURCE.replace('"depth":120 * mm', '"operationType":NewBodyOperationType.REMOVE, "depth":120 * mm, "symmetric":true')
result = lower_model(parse_featurescript(source, "symmetric-cut"), {})
self.assertIsNone(result.cdsl)
self.assertEqual(result.diagnostics[0]["code"], "unsupported_engine_capability")
self.assertEqual(result.diagnostics[0]["capability"], "extrude_cut_two_sided")
def test_open_nonconstruction_geometry_is_not_silently_dropped(self):
source = SOURCE.replace('skSolve(sketch);', 'skLineSegment(sketch, "open", {"start":v(0, 0) * mm, "end":v(20, 0) * mm}); skSolve(sketch);', 1)
result = lower_model(parse_featurescript(source, "open-profile"), {})
self.assertIsNone(result.cdsl)
self.assertEqual(result.diagnostics[0]["code"], "sketch_deferred")
self.assertIn("open non-construction", result.diagnostics[0]["message"])
def test_surface_revolve_is_not_disguised_as_solid_revolve(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0095/00957738.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00957738"), {})
gap = next(item for item in result.diagnostics if item.get("feature_id") == "F2")
self.assertEqual(gap["capability"], "revolve_surface")
self.assertIsNone(result.cdsl)
def test_feature_face_profile_is_not_reused_as_original_sketch(self):
source = SOURCE.replace('qSketchRegion(id + "F0", true)', 'makeQuery(id+"F1.opExtrude","CAP_FACE",FACE,{"isStart":false})')
result = lower_model(parse_featurescript(source, "face-profile"), {})
self.assertIsNone(result.cdsl)
self.assertEqual(result.diagnostics[0]["code"], "unsupported_engine_capability")
self.assertIn("extrude_profile_topology:cap_face", result.diagnostics[0]["capability"])
def test_conversion_writes_status_and_sidecars(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE)
+3 -1
View File
@@ -395,7 +395,9 @@ button:disabled {
.studio-main { display: flex; min-height: 0; flex: 1; }
.task-documents { border-top: 1px solid var(--ui-border); max-height: 34vh; overflow: auto; background: var(--ui-panel-bg); }
.task-checklist { padding: 8px 12px; border-bottom: 1px solid var(--ui-border); display: grid; gap: 5px; }
.task-checklist-item { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 8px; font-size: 12px; line-height: 1.35; }
.task-checklist-item { display: grid; grid-template-columns: 48px minmax(0, 1fr) auto; gap: 8px; font-size: 12px; line-height: 1.35; align-items: start; }
.task-checklist-item small { display: block; color: var(--ui-muted); margin-top: 2px; overflow-wrap: anywhere; }
.task-checklist-item button { min-height: 24px; padding: 2px 7px; font-size: 12px; }
.task-checklist-item > span { color: var(--ui-muted); }
.task-checklist-item.is-pass > span { color: #16784d; }
.task-checklist-item.is-fail > span { color: #b73c32; }
+29 -4
View File
@@ -561,7 +561,23 @@ function StudioShell({
<div className="studio-main">
<aside className="agent-pane">
<AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} taskRunning={taskRunning} onUpload={onUpload} onCancel={onCancel} />
<TaskDocuments task={taskRecord} />
<TaskDocuments task={taskRecord} onSelectRevision={(revisionId) => {
const revision = taskRecord?.revisions.find((item) => item.revision_id === revisionId);
if (!revision?.cdsl_path || !revision.step_path || !revision.glb_path || !revision.report_path || !taskRecord) return;
onCadResult({
taskId: taskRecord.task_id,
revisionId,
cdslPath: revision.cdsl_path,
stepPath: revision.step_path,
glbPath: revision.glb_path,
reportPath: revision.report_path,
summary: revision.summary || "CDSL CAD model",
referenceIds: revision.reference_ids || [],
engine: revision.engine || "cdsl_only",
checkpoint: revision.visibility === "checkpoint",
lifecycle: taskRecord.lifecycle || "running",
});
}} />
</aside>
<section className="preview-pane">
<CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onError={handleViewerError} onSelectionChange={onSelectionChange} />
@@ -571,17 +587,26 @@ function StudioShell({
);
}
function TaskDocuments({ task }: { task: TaskRecord | null }) {
function TaskDocuments({ task, onSelectRevision }: { task: TaskRecord | null; onSelectRevision: (revisionId: string) => void }) {
const documents = [
["需求文档", task?.requirements_markdown],
["完成目标", task?.completion_target_markdown],
["建模计划", task?.modeling_plan_markdown],
] as const;
if (!documents.some(([, markdown]) => markdown)) return null;
if (!documents.some(([, markdown]) => markdown) && !task?.feature_nodes?.length) return null;
return <section className="task-documents" aria-label="任务文档">
{task?.checklist_progress?.length ? <div className="task-checklist" aria-label="验收进度">
{task.checklist_progress.map((item) => <div key={item.requirement_id || item.statement} className={`task-checklist-item is-${item.status}`}><span>{item.status === "pass" ? "完成" : item.status === "fail" ? "未通过" : "待验证"}</span>{item.statement}</div>)}
</div> : null}
{task?.feature_nodes?.length ? <details open className="task-document" aria-label="特征 DAG">
<summary> DAG</summary>
<div className="task-checklist">
{task.feature_nodes.slice().sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)).map((node) => <div key={node.node_id} className={`task-checklist-item is-${node.status || "pending"}`}>
<span>{node.status === "done" ? "完成" : node.status === "running" ? "执行中" : node.status === "failed" ? "失败" : node.status === "blocked" ? "阻塞" : "待执行"}</span>
<div><strong>{node.intent || node.node_id}</strong><small>{node.atomic_id} · {node.priority}{node.depends_on?.length ? ` · 依赖 ${node.depends_on.join(", ")}` : ""}{node.attempt ? ` · 尝试 ${node.attempt}` : ""}</small>{node.error ? <small>{node.error}</small> : null}</div>
{node.status === "done" && node.revision_id ? <button type="button" title="查看此特征检查点" onClick={() => onSelectRevision(node.revision_id!)}></button> : null}
</div>)}
</div>
</details> : null}
{documents.map(([title, markdown]) => markdown ? <details key={title} className="task-document"><summary>{title}</summary><MarkdownDocument>{markdown}</MarkdownDocument></details> : null)}
</section>;
}
+4 -3
View File
@@ -44,9 +44,10 @@ export function activeCheckpointPreview(task: TaskRecord | null): CadResult | nu
export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null {
if (!task) return null;
// Checkpoints are intentionally private after a failed run. Returning one
// here makes the restored viewer request an artifact the backend denies.
if (task.lifecycle === "failed" && !task.published_revision) return null;
// v3.2 deliberately exposes its last verified checkpoint on a failed DAG:
// failure means requirements were not completed, not that earlier geometry
// should disappear. Legacy task projections retain the former policy.
if (task.lifecycle === "failed" && !task.published_revision && task.schema_version !== "3.2") return null;
const current =
task.revisions.find((revision) => revision.revision_id === (task.published_revision || task.current_revision)) ??
[...task.revisions].reverse().find((revision) => revision.status === "success" && revision.visibility !== "checkpoint");
+15
View File
@@ -180,6 +180,21 @@ test("does not restore a private checkpoint after a failed run", () => {
assert.equal(result, null);
});
test("restores the last verified v3.2 DAG checkpoint after a failed run", () => {
const result = latestSuccessfulResult({
schema_version: "3.2",
task_id: "cad_abc",
current_revision: "rev_002",
active_revision: "rev_002",
lifecycle: "failed",
revisions: [
{ revision_id: "rev_002", status: "success", visibility: "checkpoint", cdsl_path: "aa", step_path: "bb", glb_path: "cc", report_path: "dd" },
],
});
assert.equal(result?.revisionId, "rev_002");
assert.equal(result?.checkpoint, true);
});
test("restores an active checkpoint only while the task is running", () => {
const result = activeCheckpointPreview({
task_id: "cad_abc", current_revision: "rev_002", active_revision: "rev_002", published_revision: "rev_001", lifecycle: "running",
+26 -2
View File
@@ -124,8 +124,32 @@ export type TaskRecord = {
requirements_document_path?: string;
completion_target_markdown?: string | null;
completion_target_path?: string;
modeling_plan_markdown?: string | null;
modeling_plan_path?: string;
feature_plan?: {
schema_version?: string;
parent_plan_hash?: string;
replaces_node_ids?: string[];
nodes?: Array<Record<string, unknown>>;
final_claim_ids?: string[];
} | null;
feature_plan_path?: string;
feature_plan_hash?: string;
current_feature_node_id?: string;
pending_feature?: { action_id: string; node_id: string; plan_hash: string; atomic_id: string; claim_ids: string[]; depends_on_node_ids: string[] } | null;
feature_nodes?: Array<{
node_id: string;
intent?: string;
atomic_id?: string;
priority?: number;
depends_on?: string[];
claim_ids?: string[];
status?: "pending" | "ready" | "running" | "done" | "failed" | "blocked" | "invalidated" | string;
attempt?: number;
failure_class?: string;
error?: string;
revision_id?: string;
feature_id?: string;
evidence?: Array<Record<string, unknown>>;
}>;
completion_result_markdown?: string | null;
completion_result_path?: string;
claim_summary?: Array<{
@@ -129,6 +129,8 @@ def download_one(ref: PartStudioRef, authorization: str, raw_root: Path, *, time
("tessellated_edges", part_studio_path(ref, "/tessellatededges"), "tessellated_edges.json", common),
("shaded_views", part_studio_path(ref, "/shadedviews"), "shaded_views.json", {"viewMatrix": "front", "outputWidth": 512, "outputHeight": 512, "edges": "show", "showAllParts": "true"}),
]
if os.environ.get("ONSHAPE_FAST_STEP") == "1":
resources = [item for item in resources if item[0] not in {"tessellated_faces", "tessellated_edges", "shaded_views"}]
# These document and Part Studio evidence endpoints are independent. A
# bounded fan-out keeps an interactive acquisition from being cut off
# before it can reach the asynchronous STEP export, while preserving the
@@ -136,12 +138,13 @@ def download_one(ref: PartStudioRef, authorization: str, raw_root: Path, *, time
worker_count = max(1, min(16, int(os.environ.get("ONSHAPE_DOWNLOAD_WORKERS", "4"))))
with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="onshape-download") as executor:
artifacts.extend(executor.map(lambda item: _save(client, root, *item), resources))
artifacts.extend(_sketch_artifacts(client, root))
artifacts.extend([
_save(client, root, "parasolid", part_studio_path(ref, "/parasolid"), "model.x_t", {"version": "0", "includeExportIds": "true", "binaryExport": "false"}, "text/plain, application/octet-stream"),
_save(client, root, "gltf", part_studio_path(ref, "/gltf"), "model.gltf", {**common, "outputSeparateFaceNodes": "true"}, "model/gltf+json, application/octet-stream"),
_export_step(client, root, ref.sample_id, poll_seconds, poll_limit),
])
if os.environ.get("ONSHAPE_FAST_STEP") != "1":
artifacts.extend(_sketch_artifacts(client, root))
manifest = {"schema": "onshape_to_cdsl.raw_sample.v1", "source": ref.as_dict(), "resource_count": len(artifacts), "downloaded_count": sum(item.status in {"downloaded", "reused"} for item in artifacts), "unavailable_count": sum(item.status not in {"downloaded", "reused"} for item in artifacts), "resources": [asdict(item) for item in artifacts]}
write_json(root / "manifest.json", manifest)
return manifest