diff --git a/.gitignore b/.gitignore index 5783912f..0747fbf3 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* +text-to-cad-requirements/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..67adbaf4 --- /dev/null +++ b/AGENTS.md @@ -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 的协议,或会在没有改善可执行模型的情况下无限消耗调用的重试机制。 diff --git a/backend/app/cad_agent/adapters/runtime.py b/backend/app/cad_agent/adapters/runtime.py index 1c4facad..7eed94fd 100644 --- a/backend/app/cad_agent/adapters/runtime.py +++ b/backend/app/cad_agent/adapters/runtime.py @@ -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 [])} diff --git a/backend/app/cad_agent/adapters/sqlite_repository.py b/backend/app/cad_agent/adapters/sqlite_repository.py index 53ad9ada..7ec0d95e 100644 --- a/backend/app/cad_agent/adapters/sqlite_repository.py +++ b/backend/app/cad_agent/adapters/sqlite_repository.py @@ -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: diff --git a/backend/app/cad_agent/application/action_handlers.py b/backend/app/cad_agent/application/action_handlers.py index 1c80cca3..0f7294ba 100644 --- a/backend/app/cad_agent/application/action_handlers.py +++ b/backend/app/cad_agent/application/action_handlers.py @@ -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() ] diff --git a/backend/app/cad_agent/application/capabilities.py b/backend/app/cad_agent/application/capabilities.py index e17328b2..509be1fa 100644 --- a/backend/app/cad_agent/application/capabilities.py +++ b/backend/app/cad_agent/application/capabilities.py @@ -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() diff --git a/backend/app/cad_agent/application/requirements.py b/backend/app/cad_agent/application/requirements.py index b8e9b590..a7dba993 100644 --- a/backend/app/cad_agent/application/requirements.py +++ b/backend/app/cad_agent/application/requirements.py @@ -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": diff --git a/backend/app/cad_agent/application/workflow.py b/backend/app/cad_agent/application/workflow.py index 54bf8e20..f92e33d0 100644 --- a/backend/app/cad_agent/application/workflow.py +++ b/backend/app/cad_agent/application/workflow.py @@ -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 diff --git a/backend/app/cad_agent/composition.py b/backend/app/cad_agent/composition.py index 9efd999a..d8f99944 100644 --- a/backend/app/cad_agent/composition.py +++ b/backend/app/cad_agent/composition.py @@ -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) diff --git a/backend/app/cad_agent/domain/feature_plan.py b/backend/app/cad_agent/domain/feature_plan.py new file mode 100644 index 00000000..494d2f69 --- /dev/null +++ b/backend/app/cad_agent/domain/feature_plan.py @@ -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 + ) diff --git a/backend/app/cad_agent/domain/state.py b/backend/app/cad_agent/domain/state.py index 39bf20a2..d017247e 100644 --- a/backend/app/cad_agent/domain/state.py +++ b/backend/app/cad_agent/domain/state.py @@ -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, ) diff --git a/backend/app/cad_agent/domain/verifier_registry.py b/backend/app/cad_agent/domain/verifier_registry.py index ed13ba58..86a47cdc 100644 --- a/backend/app/cad_agent/domain/verifier_registry.py +++ b/backend/app/cad_agent/domain/verifier_registry.py @@ -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), diff --git a/backend/app/cad_agent/evals/create_isolated_task.py b/backend/app/cad_agent/evals/create_isolated_task.py new file mode 100644 index 00000000..cf0c236e --- /dev/null +++ b/backend/app/cad_agent/evals/create_isolated_task.py @@ -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()) diff --git a/backend/app/cad_agent/evals/live.py b/backend/app/cad_agent/evals/live.py index 9362f83e..7b6656ee 100644 --- a/backend/app/cad_agent/evals/live.py +++ b/backend/app/cad_agent/evals/live.py @@ -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", diff --git a/backend/app/cad_agent/evals/resume_one_step.py b/backend/app/cad_agent/evals/resume_one_step.py new file mode 100644 index 00000000..72456d84 --- /dev/null +++ b/backend/app/cad_agent/evals/resume_one_step.py @@ -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()) diff --git a/backend/app/cad_agent/ports.py b/backend/app/cad_agent/ports.py index cf47eef8..5c8809bc 100644 --- a/backend/app/cad_agent/ports.py +++ b/backend/app/cad_agent/ports.py @@ -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]]]: ... diff --git a/backend/app/main.py b/backend/app/main.py index 53bdb6f7..d9e025d0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index 4a3c26ce..51f1f5b1 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -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)} diff --git a/backend/tests/test_cad_agent_v3.py b/backend/tests/test_cad_agent_v3.py index dd551f66..e5138dc5 100644 --- a/backend/tests/test_cad_agent_v3.py +++ b/backend/tests/test_cad_agent_v3.py @@ -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) diff --git a/backend/tests/test_feature_plan.py b/backend/tests/test_feature_plan.py new file mode 100644 index 00000000..f9d895a0 --- /dev/null +++ b/backend/tests/test_feature_plan.py @@ -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() diff --git a/cadfs_to_cdsl/cli.py b/cadfs_to_cdsl/cli.py index c52af7fa..0374c8c0 100644 --- a/cadfs_to_cdsl/cli.py +++ b/cadfs_to_cdsl/cli.py @@ -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) diff --git a/cadfs_to_cdsl/lowering.py b/cadfs_to_cdsl/lowering.py index 30b22f45..0d7fd0d8 100644 --- a/cadfs_to_cdsl/lowering.py +++ b/cadfs_to_cdsl/lowering.py @@ -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) diff --git a/cadfs_to_cdsl/pipeline.py b/cadfs_to_cdsl/pipeline.py index 0aed61a0..5e7655f6 100644 --- a/cadfs_to_cdsl/pipeline.py +++ b/cadfs_to_cdsl/pipeline.py @@ -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"] diff --git a/cadfs_to_cdsl/rebuild.py b/cadfs_to_cdsl/rebuild.py index ee913283..89543a2e 100644 --- a/cadfs_to_cdsl/rebuild.py +++ b/cadfs_to_cdsl/rebuild.py @@ -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 diff --git a/cadfs_to_cdsl/reports.py b/cadfs_to_cdsl/reports.py index 9761f339..53543bb9 100644 --- a/cadfs_to_cdsl/reports.py +++ b/cadfs_to_cdsl/reports.py @@ -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/[^ ]+", "", message) + message = re.sub(r"0x[0-9a-fA-F]+", "0x...", message) + message = re.sub(r"\d+\.\d{4,}", "", 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}//`", + 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 diff --git a/cadfs_to_cdsl/selector_binding.py b/cadfs_to_cdsl/selector_binding.py index dd7a8ae6..93ab6ef0 100644 --- a/cadfs_to_cdsl/selector_binding.py +++ b/cadfs_to_cdsl/selector_binding.py @@ -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 diff --git a/cadfs_to_cdsl/tests/test_integration.py b/cadfs_to_cdsl/tests/test_integration.py index a100399c..20b8e712 100644 --- a/cadfs_to_cdsl/tests/test_integration.py +++ b/cadfs_to_cdsl/tests/test_integration.py @@ -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() diff --git a/cadfs_to_cdsl/tests/test_lowering.py b/cadfs_to_cdsl/tests/test_lowering.py index da4b4828..6f958a82 100644 --- a/cadfs_to_cdsl/tests/test_lowering.py +++ b/cadfs_to_cdsl/tests/test_lowering.py @@ -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) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index df9f6b8d..0a64b78a 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; } diff --git a/frontend/src/components/agent-studio.tsx b/frontend/src/components/agent-studio.tsx index f61d5554..22a6d798 100644 --- a/frontend/src/components/agent-studio.tsx +++ b/frontend/src/components/agent-studio.tsx @@ -561,7 +561,23 @@ function StudioShell({
@@ -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
{task?.checklist_progress?.length ?
{task.checklist_progress.map((item) =>
{item.status === "pass" ? "完成" : item.status === "fail" ? "未通过" : "待验证"}{item.statement}
)}
: null} + {task?.feature_nodes?.length ?
+ 特征 DAG +
+ {task.feature_nodes.slice().sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)).map((node) =>
+ {node.status === "done" ? "完成" : node.status === "running" ? "执行中" : node.status === "failed" ? "失败" : node.status === "blocked" ? "阻塞" : "待执行"} +
{node.intent || node.node_id}{node.atomic_id} · 优先级 {node.priority}{node.depends_on?.length ? ` · 依赖 ${node.depends_on.join(", ")}` : ""}{node.attempt ? ` · 尝试 ${node.attempt}` : ""}{node.error ? {node.error} : null}
+ {node.status === "done" && node.revision_id ? : null} +
)} +
+
: null} {documents.map(([title, markdown]) => markdown ?
{title}{markdown}
: null)}
; } diff --git a/frontend/src/lib/cad-artifacts.ts b/frontend/src/lib/cad-artifacts.ts index 46f163a8..0c3c2256 100644 --- a/frontend/src/lib/cad-artifacts.ts +++ b/frontend/src/lib/cad-artifacts.ts @@ -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"); diff --git a/frontend/src/lib/cad-stream.test.ts b/frontend/src/lib/cad-stream.test.ts index 59abfde2..c1618a1e 100644 --- a/frontend/src/lib/cad-stream.test.ts +++ b/frontend/src/lib/cad-stream.test.ts @@ -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", diff --git a/frontend/src/lib/cad-types.ts b/frontend/src/lib/cad-types.ts index 74be573b..7dc98db1 100644 --- a/frontend/src/lib/cad-types.ts +++ b/frontend/src/lib/cad-types.ts @@ -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>; + 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>; + }>; completion_result_markdown?: string | null; completion_result_path?: string; claim_summary?: Array<{ diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/download.py b/onshape_to_cdsl/src/onshape_to_cdsl/download.py index 89cafaf5..8d995375 100644 --- a/onshape_to_cdsl/src/onshape_to_cdsl/download.py +++ b/onshape_to_cdsl/src/onshape_to_cdsl/download.py @@ -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